SQLite's LOWER() folds ASCII only -- lower('Édith') is 'Édith' unchanged --
so the EF-sourced facet fields UNDER-matched any stored value whose prefix
carries an uppercase non-ASCII character. An under-match is unrecoverable:
no later stage can reintroduce a row SQL never returned.
Adds a SECOND, ADDITIVE query taken only when the provider is SQLite and q
contains a non-ASCII character: raw Dapper SQL folding through etv_upper(),
a SqliteConnection.CreateFunction scalar implementing ToUpperInvariant.
Every other case -- all-ASCII q, and MySQL for all q -- runs the existing
EF query byte-identically.
MySQL needed no change and gets none: verified on MySQL 8.4 that its LOWER()
is Unicode-aware and its ci collation makes the predicate OVER-match, which
the existing ordinal filter already discards.
The fold is ToUpperInvariant because OrdinalIgnoreCase equality is a strict
SUBSET of invariant-uppercase equality, so the SQL stage yields a superset of
the final filter's matches and can never under-match. Note OrdinalIgnoreCase
is NOT "invariant-upper then ordinal": ToUpperInvariant('ſ') is 'S', yet
"ſweet".StartsWith("S", OrdinalIgnoreCase) is false. Tests pin that.
No migration, no model change; both provider snapshots are untouched.
Refs #668
Decisions-Edit: yes
175 lines
8.5 KiB
C#
175 lines
8.5 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Provider-shape guards for the <c>artist</c> / <c>album_artist</c> facet-value sources (#578).
|
|
/// <para>
|
|
/// <see cref="GetSearchFieldValuesHandlerTests" /> 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 — <c>ToQueryString</c> compiles the query without
|
|
/// touching a server, so no MySQL instance is needed.
|
|
/// </para>
|
|
/// </summary>
|
|
[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<TvContext> 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<TvContext> create) in Providers())
|
|
{
|
|
using TvContext context = create();
|
|
|
|
Should.Throw<InvalidOperationException>(
|
|
() => context.SongMetadata.SelectMany(m => m.Artists).Distinct().Take(50).ToQueryString(),
|
|
$"{provider} unexpectedly translated a primitive-collection projection");
|
|
|
|
Should.Throw<InvalidOperationException>(
|
|
() => 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");
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[Test]
|
|
public void Unicode_Fold_Function_Name_Matches_The_Registration() =>
|
|
GetSearchFieldValuesHandler.UpperFunction.ShouldBe(SqliteUnicodeFunctions.UpperInvariantFunction);
|
|
|
|
/// <summary>
|
|
/// ersatztv#668. Unlike the list-valued walk, this query KEEPS its selectivity in SQL — it is a
|
|
/// bounded <c>LIMIT</c>ed prefix query exactly like the EF one it supplements, so a <c>LIKE</c> 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.
|
|
/// </summary>
|
|
[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<TvContext> Create)> Providers() =>
|
|
[
|
|
("sqlite", Sqlite),
|
|
("mysql", MySql)
|
|
];
|
|
|
|
private static TvContext Sqlite()
|
|
{
|
|
TvContext.IsSqlite = true;
|
|
var builder = new DbContextOptionsBuilder<TvContext>();
|
|
builder.UseSqlite("Data Source=:memory:");
|
|
return Create(builder.Options);
|
|
}
|
|
|
|
private static TvContext MySql()
|
|
{
|
|
TvContext.IsSqlite = false;
|
|
var builder = new DbContextOptionsBuilder<TvContext>();
|
|
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<TvContext> options) =>
|
|
new(
|
|
options,
|
|
NullLoggerFactory.Instance,
|
|
new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
|
|
}
|