using ErsatzTV.Application.Search.Queries; using ErsatzTV.Infrastructure; using ErsatzTV.Infrastructure.Data; using ErsatzTV.Infrastructure.Sqlite.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"); } /// /// ersatztv#668. The SQL function name is duplicated — the handler lives in Application, which must /// not reference a provider assembly, so it cannot use the constant the registration side defines. A /// rename on one side alone would compile cleanly and fail only at runtime, only on SQLite, only for /// non-ASCII queries; this pins the two together instead. /// [Test] public void Unicode_Fold_Function_Name_Matches_The_Registration() => GetSearchFieldValuesHandler.UpperFunction.ShouldBe(SqliteUnicodeFunctions.UpperInvariantFunction); /// /// ersatztv#668. Unlike the list-valued walk, this query KEEPS its selectivity in SQL — it is a /// bounded LIMITed prefix query exactly like the EF one it supplements, so a LIKE here /// is correct rather than the trap the walk's shape test guards against. What must hold is that the /// fold is the registered Unicode-correct one and NOT the provider's ASCII-only builtin, and that the /// wildcard escape is declared. /// [Test] public void Unicode_Fold_Query_Uses_The_Registered_Fold_And_Declares_Its_Escape() { string sql = GetSearchFieldValuesHandler.UnicodeFoldSql("Genre", "Name", null); sql.ShouldBe( "SELECT DISTINCT Name AS Value FROM Genre " + "WHERE etv_upper(Name) LIKE @Pattern ESCAPE '\\' ORDER BY Name LIMIT @Limit"); // The point of the whole change: SQLite's BUILTIN lower()/upper() fold ASCII only, so quietly falling // back to one reinstates #668. Checked by removing the qualified call first — Shouldly's string // assertions are case-INSENSITIVE by default, so a bare ShouldNotContain("UPPER(") matches inside // "etv_upper(" and fails against correct SQL. sql.ShouldNotContain("LOWER("); sql.Replace($"{GetSearchFieldValuesHandler.UpperFunction}(", "", StringComparison.Ordinal) .ShouldNotContain("UPPER("); // a discriminator predicate is parenthesised and ANDed, so an OR inside it cannot swallow the match GetSearchFieldValuesHandler.UnicodeFoldSql("Tag", "Name", "ExternalTypeId IS NULL OR X") .ShouldContain("WHERE (ExternalTypeId IS NULL OR X) AND etv_upper(Name) LIKE @Pattern"); } 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)); }