Files
ersatztv/ErsatzTV.Tests/Support/InMemoryTvContext.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

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);
}
}