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
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.
135 lines
6.1 KiB
C#
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));
|
|
}
|