using System.Data; using Microsoft.Data.Sqlite; namespace ErsatzTV.Infrastructure.Sqlite.Data; /// /// ersatztv#668. SQLite's built-in lower()/upper() fold ASCII ONLY — lower('Édith') /// returns 'Édith' 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 /// at startup. /// public static class SqliteUnicodeFunctions { /// /// SQL name of the invariant-uppercase fold. The facet-value handler interpolates this constant into /// its SQL, so the two cannot drift apart. /// public const string UpperInvariantFunction = "etv_upper"; /// /// Registers on 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. /// /// The property this fold has to satisfy is ONE-SIDED: the SQL stage may over-match freely, /// because the endpoint applies an exact filter /// in memory afterwards, but it must never UNDER-match — no later stage can reintroduce a row SQL /// never returned. satisfies it because /// OrdinalIgnoreCase equality is a strict SUBSET of invariant-uppercase equality, so /// folding both sides with it yields a superset of the final filter's matches. /// /// /// Do not restate that as "OrdinalIgnoreCase IS invariant-uppercase-then-ordinal" — it is /// not, and the difference is measurable: char.ToUpperInvariant('ſ') (U+017F) is 'S', /// yet "ſweet".StartsWith("S", OrdinalIgnoreCase) is false. 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. /// /// /// 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. /// /// public static void Register(IDbConnection connection) { if (connection is SqliteConnection sqlite) { sqlite.CreateFunction( UpperInvariantFunction, (string? value) => value?.ToUpperInvariant(), isDeterministic: true); } } }