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
70 lines
2.6 KiB
C#
70 lines
2.6 KiB
C#
using ErsatzTV.Infrastructure;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Infrastructure.Sqlite.Data;
|
|
using Microsoft.Data.Sqlite;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace ErsatzTV.Tests.Support;
|
|
|
|
/// <summary>
|
|
/// In-memory SQLite harness for handler/integration tests. A single <see cref="SqliteConnection" />
|
|
/// is kept open for the lifetime of the harness so the schema (built via
|
|
/// <see cref="DatabaseFacade.EnsureCreatedAsync" />) and data persist across the multiple
|
|
/// <see cref="TvContext" /> instances created by an <see cref="IDbContextFactory{TvContext}" />.
|
|
/// Foreign keys are disabled so partial graphs can be seeded without satisfying every FK.
|
|
/// </summary>
|
|
public sealed class InMemoryTvContext : IAsyncDisposable
|
|
{
|
|
private readonly SqliteConnection _connection;
|
|
private readonly DbContextOptions<TvContext> _options;
|
|
|
|
private InMemoryTvContext(SqliteConnection connection, DbContextOptions<TvContext> options)
|
|
{
|
|
_connection = connection;
|
|
_options = options;
|
|
}
|
|
|
|
public IDbContextFactory<TvContext> Factory => new TestDbContextFactory(_options);
|
|
|
|
public static async Task<InMemoryTvContext> CreateAsync()
|
|
{
|
|
TvContext.IsSqlite = true;
|
|
TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation;
|
|
TvContext.RegisterUnicodeCaseFunctions = SqliteUnicodeFunctions.Register;
|
|
|
|
var connection = new SqliteConnection("Data Source=:memory:;Foreign Keys=False");
|
|
await connection.OpenAsync();
|
|
|
|
DbContextOptions<TvContext> options = new DbContextOptionsBuilder<TvContext>()
|
|
.UseSqlite(connection)
|
|
.Options;
|
|
|
|
await using (TvContext context = Create(options))
|
|
{
|
|
await context.Database.EnsureCreatedAsync();
|
|
await context.Database.ExecuteSqlRawAsync("PRAGMA foreign_keys=OFF");
|
|
}
|
|
|
|
return new InMemoryTvContext(connection, options);
|
|
}
|
|
|
|
public TvContext CreateContext() => Create(_options);
|
|
|
|
public async ValueTask DisposeAsync()
|
|
{
|
|
await _connection.DisposeAsync();
|
|
}
|
|
|
|
private static TvContext Create(DbContextOptions<TvContext> options) =>
|
|
new(
|
|
options,
|
|
NullLoggerFactory.Instance,
|
|
new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
|
|
|
|
private sealed class TestDbContextFactory(DbContextOptions<TvContext> options) : IDbContextFactory<TvContext>
|
|
{
|
|
public TvContext CreateDbContext() => Create(options);
|
|
}
|
|
}
|