Files
ersatztv/ErsatzTV.Tests/Application/Search/SearchFieldValuesQueryShapeTests.cs
T
timothy fc3ede09bc
PR Gates / Script tests (pytest) (pull_request) Successful in 51s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 1m38s
Review verdict / Set review-verdict status (pull_request) Successful in 1m18s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 4m51s
Build ErsatzTV Image / Functional E2E (curl + UI contracts) (pull_request) Successful in 18m52s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 21m10s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 25m44s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped
review-verdict/h10 Review-verdict: MERGEABLE @ fc3ede0 (base: main)
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 7s
PR Gates / Docs update reminder (pull_request) Successful in 8s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
docs(578): the retracted claim survived in 9 places, including the record title and rule
Comment- and docs-only; verified no non-comment line changed in any .cs.

I reported last round that I had "classified every surviving hit". That was false, and the false
confidence is the expensive part: a confidently-stated "I checked everything" stops anyone else
checking. The retracted wording survived in nine places, two of them the record's title and rule: —
and the catalog copies rule: verbatim, so the generated entry point and the record disagreed
semantically while docs/decisions.md said the correct thing.

Root cause of the miss, because it will recur otherwise: I built the sweep term list from the
DELETED MECHANISM's vocabulary (LIKE, superset, keyspace, anchor, over-match) and never added the
RETRACTED CLAIM's own words. "no predicate", "bound on work", "index entries", "no gap" and
"holds in memory" were never grepped. After a retraction the subject list has to include the words
of the thing being retracted, not just the thing already deleted.

Second, worse: my first attempt at this round's sweep printed nothing for every term and I nearly
read that as "all clear". zsh does not word-split an unquoted $FILES, so grep received one giant
non-existent path — and the `|| echo "(none)"` never fired because the pipeline's exit status was
sed's. Same failure shape as the bug arc itself: a check reporting success while examining nothing.
Re-run with a proper array plus a control term ("SongMetadata" -> 42 hits) so an empty result is
distinguishable from a broken grep.

Fixed all nine, replacing "no predicate" with the seekable-cursor-vs-residual distinction already
written correctly elsewhere:
- handler: the "real bound on work" claim, the short-page rationale
- SearchFieldValuesQueryShapeTests: "ANY predicate" + "reads exactly n index entries", and added what
  the test can and cannot pin (a SQL string, not a plan / visibility work / payload I/O)
- GetSearchFieldValuesHandlerTests: "no gap between what the engine looks at and what it hands back",
  and the current-behaviour comment
- record title, rule:, attempt-5 table row; api-conventions
- regenerated docs/decisions/README.md so catalog and record agree again

Tenth item, the same overclaim one level down and it survived the first retraction: the row bound was
said to cap what the process holds in memory. It does not — payload width is unrestricted and one
JSON array can contain arbitrarily many strings, each of which may enter the in-memory distinct set.
It caps logical rows returned/materialized and round-trip count, nothing about bytes. Added as a
third struck-through bullet next to the other two retractions.
2026-07-27 03:10:36 +02:00

135 lines
6.1 KiB
C#

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;
/// <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");
}
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));
}