using ErsatzTV.Application.Search.Queries; using ErsatzTV.Infrastructure; using ErsatzTV.Infrastructure.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging.Abstractions; using NUnit.Framework; using Shouldly; namespace ErsatzTV.Tests.Application.Search; /// /// Provider-shape guards for the artist / album_artist facet-value sources (#578). /// /// runs against in-memory SQLite, so it structurally /// cannot see a MySQL translation or collation difference. These tests build the same LINQ against the /// Pomelo MySQL provider and assert the generated SQL — ToQueryString compiles the query without /// touching a server, so no MySQL instance is needed. /// /// [TestFixture] [NonParallelizable] public class SearchFieldValuesQueryShapeTests { private bool _wasSqlite; [SetUp] public void SetUp() => _wasSqlite = TvContext.IsSqlite; [TearDown] public void TearDown() => TvContext.IsSqlite = _wasSqlite; [Test] public void Artist_Entity_Union_Translates_On_Both_Providers_With_Lower_And_A_Row_Limit() { foreach ((string provider, Func create) in Providers()) { using TvContext context = create(); // calls the handler's own source builder (internal, via InternalsVisibleTo) rather than rebuilding // the LINQ here — a copy would keep passing after the handler's query changed underneath it string sql = GetSearchFieldValuesHandler.GetSource(context, "artist") .Where(v => v != null && v.ToLower().StartsWith("a")) .Distinct() .OrderBy(v => v) .Take(50) .ToQueryString(); // case-insensitivity comes from LOWER() on the column, not from the provider's LIKE collation sql.ShouldContain("LOWER(", Case.Insensitive, $"{provider}: {sql}"); sql.ShouldContain("LIKE", Case.Insensitive, $"{provider}: {sql}"); sql.ShouldContain("MusicVideoArtist", Case.Insensitive, $"{provider}: {sql}"); // the whole thing is one bounded server-side query, never a client-side scan sql.ShouldContain("LIMIT", Case.Insensitive, $"{provider}: {sql}"); } } [Test] public void Regression_Pin_Song_List_Columns_Cannot_Be_Projected_Server_Side_On_Either_Provider() { // REGRESSION PIN, not coverage of #578: this asserts pre-existing EF/provider behaviour and passes // against the code before this change. // // Documents WHY the handler drops to raw SQL for SongMetadata.Artists / .AlbumArtists rather than // SelectMany-ing them: EF maps them as JSON primitive collections and neither provider can translate // the projection (SQLite needs APPLY; Pomelo has no primitive-collection support). If a provider // upgrade ever makes this translate, this test fails and the raw-SQL path can be retired. foreach ((string provider, Func create) in Providers()) { using TvContext context = create(); Should.Throw( () => context.SongMetadata.SelectMany(m => m.Artists).Distinct().Take(50).ToQueryString(), $"{provider} unexpectedly translated a primitive-collection projection"); Should.Throw( () => context.SongMetadata.SelectMany(m => m.AlbumArtists).Distinct().Take(50).ToQueryString(), $"{provider} unexpectedly translated a primitive-collection projection"); } } [Test] public void List_Valued_Page_Query_Has_No_Predicate_Beyond_The_Keyset_Cursor() { // This is the whole basis of the row bound, so it is asserted rather than assumed. LIMIT truncates what // survives a RESIDUAL predicate — one that discards rows the engine already produced — so with such a // predicate present it bounds the output rather than the row count, and the engine may produce and // discard arbitrarily many rows first. That is how four successive revisions scanned past their own // bound. The cursor `Id > @AfterId` is NOT such a predicate: it is a seek on the ordering key, which // positions the scan without discarding anything, so LIMIT n yields n logical rows. // // What this test can and cannot do: it pins the SQL STRING. It cannot pin an execution plan, MVCC // visibility work or payload I/O -- physical work is NOT bounded (see the record: MySQL traverses // deleted-but-unpurged index records, and TEXT payloads spill to overflow pages). string sql = GetSearchFieldValuesHandler.ListValuedSql("Artists"); sql.ShouldBe( "SELECT Id, Artists AS Payload FROM SongMetadata WHERE Id > @AfterId ORDER BY Id LIMIT @Batch"); // named explicitly so a future "optimization" that reintroduces server-side selectivity fails here sql.ShouldNotContain("LIKE"); sql.ShouldNotContain("LOWER"); sql.ShouldNotContain("IS NOT NULL"); } private static IEnumerable<(string Provider, Func Create)> Providers() => [ ("sqlite", Sqlite), ("mysql", MySql) ]; private static TvContext Sqlite() { TvContext.IsSqlite = true; var builder = new DbContextOptionsBuilder(); builder.UseSqlite("Data Source=:memory:"); return Create(builder.Options); } private static TvContext MySql() { TvContext.IsSqlite = false; var builder = new DbContextOptionsBuilder(); builder.UseMySql( "Server=localhost;Database=ersatztv_query_shape;User=root;Password=ersatztv;", new MySqlServerVersion(new Version(8, 0, 36))); return Create(builder.Options); } private static TvContext Create(DbContextOptions options) => new( options, NullLoggerFactory.Instance, new SlowQueryInterceptor(NullLogger.Instance)); }