Files
ersatztv/ErsatzTV.Infrastructure.Sqlite/Data/SqliteUnicodeFunctions.cs
T
timothy 05542946ad fix(668): reach accented facet values via a registered Unicode fold on SQLite
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
2026-07-27 20:36:28 +02:00

58 lines
3.2 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.Data;
using Microsoft.Data.Sqlite;
namespace ErsatzTV.Infrastructure.Sqlite.Data;
/// <summary>
/// ersatztv#668. SQLite's built-in <c>lower()</c>/<c>upper()</c> fold ASCII ONLY — <c>lower('Édith')</c>
/// returns <c>'Édith'</c> unchanged — so a facet value whose prefix carries an uppercase non-ASCII
/// character can never be matched by the prefix predicate the facet-value endpoint emits. Registering a
/// managed scalar gives that one query a Unicode-correct fold. Wired to
/// <see cref="ErsatzTV.Infrastructure.Data.TvContext.RegisterUnicodeCaseFunctions" /> at startup.
/// </summary>
public static class SqliteUnicodeFunctions
{
/// <summary>
/// SQL name of the invariant-uppercase fold. The facet-value handler interpolates this constant into
/// its SQL, so the two cannot drift apart.
/// </summary>
public const string UpperInvariantFunction = "etv_upper";
/// <summary>
/// Registers <see cref="UpperInvariantFunction" /> on <paramref name="connection" /> when it is a
/// SQLite connection, and does nothing otherwise. Idempotent — a repeat registration replaces the
/// previous delegate with an identical one — so the single call site may call it unconditionally.
/// <para>
/// The property this fold has to satisfy is ONE-SIDED: the SQL stage may over-match freely,
/// because the endpoint applies an exact <see cref="StringComparison.OrdinalIgnoreCase" /> filter
/// in memory afterwards, but it must never UNDER-match — no later stage can reintroduce a row SQL
/// never returned. <see cref="string.ToUpperInvariant" /> satisfies it because
/// <c>OrdinalIgnoreCase</c> equality is a strict SUBSET of invariant-uppercase equality, so
/// folding both sides with it yields a superset of the final filter's matches.
/// </para>
/// <para>
/// Do not restate that as "<c>OrdinalIgnoreCase</c> IS invariant-uppercase-then-ordinal" — it is
/// not, and the difference is measurable: <c>char.ToUpperInvariant('ſ')</c> (U+017F) is <c>'S'</c>,
/// yet <c>"ſweet".StartsWith("S", OrdinalIgnoreCase)</c> is <b>false</b>. That gap is precisely
/// the harmless direction — SQL returns the row, the in-memory filter drops it. The containment,
/// not any identity of the two foldings, is what makes this safe.
/// </para>
/// <para>
/// Registration is per-connection and therefore done at the one call site that uses the function,
/// not through an EF connection interceptor: Dapper opens a closed connection itself, and a direct
/// ADO open does not raise EF's interceptors — so an interceptor-based seam would silently miss
/// exactly the query that needs it.
/// </para>
/// </summary>
public static void Register(IDbConnection connection)
{
if (connection is SqliteConnection sqlite)
{
sqlite.CreateFunction(
UpperInvariantFunction,
(string? value) => value?.ToUpperInvariant(),
isDeterministic: true);
}
}
}