Files
ersatztv/ErsatzTV.Application/Search/Queries/GetSearchFieldValuesHandler.cs
T
timothy 1641ca8305 fix(578): the LIKE prefilter under-matched every accented artist; make the superset provable
Review of 1b78dc9e found the pre-filter's correctness claim was false, and the claim was in the
decision record as well as the code.

F1 (high). The pattern JSON-encoded the whole query prefix on the reasoning that the stored text
escapes non-ASCII, so encoding the prefix the same way would line up. It does not: SQL LOWER()
lowercases the *escape text* (`É` -> `é`); it cannot case-fold the codepoint that escape
denotes. So `q=é` built `%"é%`, the stored `Édith Piaf` never matched, and the row was
discarded before the in-memory filter could accept it. Every accented artist — Beyoncé, Björk,
Sigur Rós, Édith Piaf — was silently unsuggestable, which in a music library is the common case.

The invariant that was missing, now stated in the code: the SQL pre-filter is an OPTIMIZATION. It
may over-match; it must never under-match. Correctness lives in the in-memory filter. So the pattern
now narrows only on the leading run of characters the JSON writer stores verbatim and stops at the
first character it cannot prove — `q=Beyoncé` still narrows on `beyonc`, `q=é` narrows on nothing
and leans on the row cap. Soundness rests on two facts now asserted by exhaustive computation rather
than argued: no non-ASCII codepoint in U+0080..U+10FFFF OrdinalIgnoreCase-equals a printable ASCII
character (false for InvariantCultureIgnoreCase, which folds ~190 — the choice of Ordinal is
load-bearing), and the exact set of ASCII the encoder escapes.

F1b. `UseRequestLocalization` honours Accept-Language, so the culture was caller-controlled and
`ToLower()` plus the default linguistic `StartsWith(string)` let a header change the answer.
Comparison is now OrdinalIgnoreCase and ordering StringComparer.Ordinal throughout — including the
shared FilterSortTake that state/video_dynamic_range/content_rating also use. Sets unchanged,
order now ordinal rather than culture-dependent.

F2. The merge comment asserted an exactness the code does not have: sources truncate by their own
ordering (DB collation / primary key), not the merge's, so a dropped value can outrank a survivor.
Comment and record now say best-effort, exact only below the truncation points.

F3/F4. The cap now rides `ORDER BY Id` rather than the JSON column: MySQL sorts TEXT by only
max_sort_length bytes, so the old ordering was not deterministic there, and sorting the whole
matching set was avoidable work. What the cap still does NOT bound is the scan — a leading-wildcard
LIKE cannot seek an index — so that cost is now documented as accepted, with a normalized
`SongArtist` table named as the follow-up candidate rather than left implicit.

Every clause above is covered by a test verified to FAIL when that clause is mutated (old pattern
builder: 5 red; culture chain: 3 red; cap=3 / cap=limit / ORDER BY json / no cap: red each).

F5. Converted to a proper supersession. The old record did not merely hold a stale fact — it
recorded song/music-video credits as an "intentionally-uncovered gap" and album_artist as
unsupported, and this reverses that call, which `docs.decision-lifecycle` says is never a
line-edit. `api.search-field-values` is archived with its original prose restored, and
`api.search-field-values-sources` replaces it carrying the whole endpoint contract.
2026-07-27 03:10:35 +02:00

324 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text;
using System.Text.Json;
using Dapper;
using ErsatzTV.Core.Api.Search;
using ErsatzTV.Core.Domain;
using ErsatzTV.Infrastructure.Data;
using Microsoft.EntityFrameworkCore;
namespace ErsatzTV.Application.Search.Queries;
public class GetSearchFieldValuesHandler(IDbContextFactory<TvContext> dbContextFactory)
: IRequestHandler<GetSearchFieldValues, Option<SearchFieldValuesResponseModel>>
{
private const int DefaultLimit = 50;
private const int MaxLimit = 50;
/// <summary>
/// Row cap for the list-valued (JSON-array) columns on <c>SongMetadata</c>. The SQL pre-filter over the
/// raw JSON is a deliberate superset (a matching row is fetched whole, including its non-matching
/// elements), so the number of rows it can return has to be bounded independently of <c>limit</c> —
/// otherwise an empty <c>q</c> degenerates into materializing the entire table. The cap is applied on
/// <c>ORDER BY Id</c>, which is a unique integer primary key on both providers: unlike ordering on the
/// JSON column itself, that makes the truncation point genuinely deterministic (MySQL sorts <c>TEXT</c>
/// using only the first <c>max_sort_length</c> bytes, so long JSON rows sharing a prefix would tie
/// arbitrarily) and lets the engine walk the primary key instead of sorting the whole matching set.
/// </summary>
internal const int ListValuedRowCap = 1000;
/// <summary>
/// Escape character for the raw-JSON <c>LIKE</c> pre-filter. Deliberately NOT a backslash: the stored
/// JSON is full of backslashes (<c>\u00E9</c>, <c>\u0022</c>) and, worse, <c>ESCAPE '\'</c> is not a
/// portable literal — MySQL applies C-style escaping to string literals, so it reads as an unterminated
/// string, while SQLite would reject the doubled <c>'\\'</c> form as a two-character escape.
/// </summary>
private const char LikeEscapeChar = '/';
/// <summary>
/// The ASCII characters the JSON writer escapes rather than emitting verbatim (<c>&amp;</c> and friends
/// become <c>&</c>). A prefix character in this set cannot be matched literally against the stored
/// text, so it terminates the narrowing run in <see cref="JsonElementPrefixPattern" />.
/// <c>SearchFieldValuesPrefilterSupersetTests</c> pins this set against the encoder itself.
/// </summary>
private const string JsonEscapedAscii = "\"&'+<>\\`";
public async Task<Option<SearchFieldValuesResponseModel>> Handle(
GetSearchFieldValues request,
CancellationToken cancellationToken)
{
SearchFieldResponseModel field = SearchFieldCatalog.Fields
.FirstOrDefault(f => f.Name == request.Name);
if (field is null || field.Type != "text")
{
return Option<SearchFieldValuesResponseModel>.None;
}
int limit = request.Limit <= 0 ? DefaultLimit : Math.Clamp(request.Limit, 1, MaxLimit);
string query = request.Query ?? string.Empty;
// Invariant, not current-culture: UseRequestLocalization honours Accept-Language, so a caller can select
// tr-TR and turn `q=I` into `ı` — which then matches nothing a Turkish-dotless-i-free library contains.
// This feeds the EF-translated filter, which has no StringComparison overload EF can translate.
string qLower = query.ToLowerInvariant();
// in-memory special cases (no DB query needed)
switch (request.Name)
{
case "state":
return new SearchFieldValuesResponseModel(
FilterSortTake(Enum.GetNames<MediaItemState>(), query, limit));
case "video_dynamic_range":
return new SearchFieldValuesResponseModel(
FilterSortTake(["hdr", "sdr"], query, limit));
}
await using TvContext dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
if (request.Name == "content_rating")
{
return new SearchFieldValuesResponseModel(
await GetContentRatingValues(dbContext, query, limit, cancellationToken));
}
IQueryable<string> source = GetSource(dbContext, request.Name);
string listColumn = GetSongListValuedColumn(request.Name);
if (source is null && listColumn is null)
{
return Option<SearchFieldValuesResponseModel>.None;
}
var values = new List<string>();
if (source is not null)
{
values.AddRange(
await source
.Where(v => v != null && v.ToLower().StartsWith(qLower))
.Distinct()
.OrderBy(v => v)
.Take(limit)
.ToListAsync(cancellationToken));
}
if (listColumn is not null)
{
values.AddRange(await GetSongListValuedValues(dbContext, listColumn, query, cancellationToken));
}
// ORDERING IS BEST-EFFORT, NOT EXACT. Each source truncates using its own ordering — the EF source by the
// database collation (SQLite's NOCASE/BINARY is ASCII-only), the list source by primary key — and neither
// is the ordinal ordering applied here. So when a source actually truncates, a value it dropped may have
// outranked one that survived: with "Zulu" and "Éclair" and limit=1 the EF source keeps "Zulu" and the
// merge never sees "Éclair". Below the truncation points (the normal typeahead case) the result is exact.
return new SearchFieldValuesResponseModel(FilterSortTake(values.Distinct(StringComparer.Ordinal), query, limit));
}
internal static IQueryable<string> GetSource(TvContext dbContext, string name) => name switch
{
"genre" or "show_genre" => dbContext.Set<Genre>().Select(g => g.Name),
"studio" => dbContext.Set<Studio>().Select(s => s.Name),
"director" => dbContext.Set<Director>().Select(d => d.Name),
"writer" => dbContext.Set<Writer>().Select(w => w.Name),
"actor" => dbContext.Actors.Select(a => a.Name),
// Mirrors what LuceneSearchIndex writes to the `artist` field: the music video's linked artist entity
// (ArtistMetadata.Title) plus its free-text credits (MusicVideoArtist rows). The third contributor —
// SongMetadata.Artists — is a JSON-array column and is handled by GetSongListValuedValues instead.
"artist" => dbContext.ArtistMetadata.Select(m => m.Title)
.Concat(dbContext.Set<MusicVideoArtist>().Select(a => a.Name)),
"tag" => dbContext.Set<Tag>()
.Where(t => t.ExternalTypeId != Tag.NfoCountryTypeId && t.ExternalTypeId != Tag.PlexNetworkTypeId)
.Select(t => t.Name),
"network" => dbContext.Set<Tag>()
.Where(t => t.ExternalTypeId == Tag.PlexNetworkTypeId)
.Select(t => t.Name),
"collection" => dbContext.Collections.Select(c => c.Name),
"video_codec" => dbContext.MediaStreams
.Where(s => s.MediaStreamKind == MediaStreamKind.Video && s.Codec != null)
.Select(s => s.Codec),
"album" => dbContext.MusicVideoMetadata
.Where(m => m.Album != null)
.Select(m => m.Album)
.Concat(dbContext.SongMetadata.Where(m => m.Album != null).Select(m => m.Album)),
_ => null
};
/// <summary>
/// Maps a field name onto the <c>SongMetadata</c> column that backs it as an <c>IList&lt;string&gt;</c>.
/// The returned value is a compile-time constant from this switch — never caller input — so it is safe
/// to interpolate into the SQL in <see cref="ListValuedSql" />.
/// </summary>
private static string GetSongListValuedColumn(string name) => name switch
{
"artist" => "Artists",
"album_artist" => "AlbumArtists",
_ => null
};
/// <summary>
/// Reads whole values out of a <c>SongMetadata</c> <c>IList&lt;string&gt;</c> column.
/// <para>
/// EF maps these as primitive collections: one JSON array per row in a single <c>TEXT</c>/
/// <c>longtext</c> column. Neither provider can project the elements server-side — SQLite needs
/// the SQL <c>APPLY</c> operator it doesn't have, and Pomelo MySQL doesn't implement primitive
/// collections at all — so there is no server-side <c>SELECT DISTINCT</c> over the elements.
/// </para>
/// <para>
/// Instead: pre-filter on the raw JSON text in SQL (a superset — a row is matched, not an element,
/// so its other elements come along), cap the rows by primary key, then split and exact-filter in
/// memory. Correctness lives in the in-memory filter; the SQL pattern is only an optimization and is
/// allowed to over-match but never to under-match. When the library has more matching rows than
/// <see cref="ListValuedRowCap" /> the suggestion list for these fields is the lowest-id prefix of
/// the matches rather than the complete set.
/// </para>
/// </summary>
private static async Task<List<string>> GetSongListValuedValues(
TvContext dbContext,
string column,
string query,
CancellationToken cancellationToken)
{
IEnumerable<string> rows = await dbContext.Connection.QueryAsync<string>(
new CommandDefinition(
ListValuedSql(column),
new { Pattern = JsonElementPrefixPattern(query), Cap = ListValuedRowCap },
cancellationToken: cancellationToken));
var values = new List<string>();
foreach (string row in rows)
{
if (string.IsNullOrWhiteSpace(row))
{
continue;
}
string[] elements;
try
{
elements = JsonSerializer.Deserialize<string[]>(row);
}
catch (JsonException)
{
continue;
}
foreach (string element in elements ?? [])
{
if (!string.IsNullOrEmpty(element) && element.StartsWith(query, StringComparison.OrdinalIgnoreCase))
{
values.Add(element);
}
}
}
return values;
}
/// <summary>
/// The bounded pre-filter query. <c>LOWER(...) LIKE ... ESCAPE</c>, <c>ORDER BY</c> and <c>LIMIT</c> are
/// all portable between SQLite and MySQL, and the identifiers are unquoted so neither provider's
/// quoting dialect is baked in. The cap rides <c>ORDER BY Id</c> — a unique integer key both providers
/// can walk — rather than the JSON column, so the truncation point is well-defined and no full sort of
/// the matching set is required.
/// </summary>
internal static string ListValuedSql(string column) =>
$"SELECT {column} FROM SongMetadata WHERE {column} IS NOT NULL " +
$"AND LOWER({column}) LIKE @Pattern ESCAPE '{LikeEscapeChar}' " +
"ORDER BY Id LIMIT @Cap";
/// <summary>
/// Builds the raw-JSON <c>LIKE</c> pattern used to narrow the rows fetched for a list-valued column.
/// <para>
/// <b>This is an optimization with one hard obligation: it must never under-match.</b> Every row
/// holding an element that starts with <paramref name="query" /> under
/// <see cref="StringComparison.OrdinalIgnoreCase" /> — the comparison
/// <see cref="GetSongListValuedValues" /> then applies — has to survive it. Over-matching is free;
/// the in-memory filter discards the extras.
/// </para>
/// <para>
/// So the pattern narrows on the <b>leading run of ASCII characters the JSON writer stores
/// verbatim</b>, and stops at the first character it cannot reason about — a non-ASCII character, or
/// one of <see cref="JsonEscapedAscii" />. A query starting with such a character narrows to the bare
/// element-opening anchor <c>%"%</c> and leans entirely on the row cap and the in-memory filter.
/// </para>
/// <para>
/// Why that is sound, and why an earlier "JSON-encode the whole prefix" version was NOT: the stored
/// text escapes non-ASCII, so <c>Édith</c> is on disk as <c>Édith</c>. SQL <c>LOWER()</c>
/// lowercases the <i>escape text</i>, giving <c>É</c> — it cannot case-fold the codepoint that
/// escape denotes, so a <c>q=é</c> pattern of <c>é</c> never matched and every accented artist
/// was silently unsuggestable. Restricting the run to verbatim ASCII removes the escape from the
/// comparison entirely. And for a run character <c>c</c>, any element character that
/// OrdinalIgnoreCase-equals <c>c</c> is itself ASCII — no non-ASCII codepoint in all of Unicode
/// OrdinalIgnoreCase-equals a printable ASCII character, which
/// <c>SearchFieldValuesPrefilterSupersetTests</c> proves by exhaustive sweep rather than assertion.
/// (That is false for <c>InvariantCultureIgnoreCase</c>, which has 190 such codepoints — the choice
/// of Ordinal is load-bearing, not stylistic.) ASCII case-folding is exactly what both providers'
/// <c>LOWER()</c> implements, and lowercasing the pattern here rather than trusting <c>LIKE</c>'s own
/// case rules keeps it correct under a case-sensitive MySQL collation as well as a case-insensitive
/// one.
/// </para>
/// </summary>
internal static string JsonElementPrefixPattern(string query)
{
var builder = new StringBuilder("%\"");
foreach (char c in query ?? string.Empty)
{
if (!char.IsAscii(c) || char.IsControl(c) || JsonEscapedAscii.Contains(c))
{
// cannot prove this character's stored form, so stop narrowing here
break;
}
char lower = char.ToLowerInvariant(c);
if (lower is '%' or '_' or LikeEscapeChar)
{
builder.Append(LikeEscapeChar);
}
builder.Append(lower);
}
return builder.Append('%').ToString();
}
private static async Task<List<string>> GetContentRatingValues(
TvContext dbContext,
string query,
int limit,
CancellationToken cancellationToken)
{
List<string> raw = await dbContext.MovieMetadata
.Where(m => m.ContentRating != null)
.Select(m => m.ContentRating)
.Concat(dbContext.ShowMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
.Concat(dbContext.OtherVideoMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
.Concat(dbContext.RemoteStreamMetadata.Where(m => m.ContentRating != null).Select(m => m.ContentRating))
.Distinct()
.ToListAsync(cancellationToken);
IEnumerable<string> split = raw
.SelectMany(cr => cr.Split('/'))
.Select(cr => cr.Trim())
.Where(cr => !string.IsNullOrEmpty(cr))
.Distinct();
return FilterSortTake(split, query, limit);
}
/// <summary>
/// The one in-memory filter/sort/take every field funnels through. Both the comparison and the ordering
/// are ORDINAL on purpose: <c>UseRequestLocalization</c> honours <c>Accept-Language</c>, so the current
/// culture is caller-controlled, and <c>ToLower()</c> plus the default (linguistic)
/// <c>StartsWith(string)</c> would make the result depend on it — under <c>tr-TR</c>, <c>q=I</c> lowers
/// to <c>ı</c> and stops matching <c>Istanbul</c>. Ordinal is also what the SQL pre-filter's superset
/// guarantee is proved against; see <see cref="JsonElementPrefixPattern" />.
/// </summary>
private static List<string> FilterSortTake(IEnumerable<string> values, string query, int limit) =>
values
.Where(v => v.StartsWith(query, StringComparison.OrdinalIgnoreCase))
.OrderBy(v => v, StringComparer.Ordinal)
.Take(limit)
.ToList();
}