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
58 lines
3.2 KiB
C#
58 lines
3.2 KiB
C#
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);
|
||
}
|
||
}
|
||
}
|