Second CI failure of this fixture, and my previous fix caused it: collapsing to one shared database traded isolation for a wipe that has to succeed, and when it silently did not, the second test seeded onto the first's rows and failed with 'Duplicate entry 1 for key LibraryPath.PRIMARY' in 539ms - too fast to have re-run the migration chain, i.e. MigrateAsync no-opped against an already-current __EFMigrationsHistory. The coordinator's read was right and I verified it rather than assuming: the leak came from never clearing pools, not from names being unique. Harness against a real 8.4 server, unique database name per iteration WITH ClearPoolAsync on that connection string: 0 leaked threads over 30 iterations. So isolation costs nothing and the shared name was solving a problem pool-clearing already solved. Restore a fresh etv491_<guid> database per test, never created out of band (the test's own MigrateAsync(PreviousMigration) creates it, keeping EF the single owner of the schema), dropped in TearDown via the guarded EnsureDeletedAsync and followed by ClearPoolAsync on that exact connection string. The stale-session hazard from the first failure needs the connection string to be REUSED after the drop, which a never-repeated name makes impossible; clearing the pool is the belt to that brace and closes the leak. Verified: 10 consecutive runs 10/10 green, server threads flat at 2 and zero leftover schemas throughout; each test run ALONE twice; both tests with the order REVERSED - order-independence being the evidence this failure would have needed, since contamination is invisible when a test runs first. Negative control re-confirmed after the change: restoring COLLATE utf8mb4_bin still fails with survivors [1,4,5,6,7,9] vs [1,4,5,6,7,9,10], then restored and green again. No Retry anywhere. One shared fixture body across providers; still fail-closed under ETV_REQUIRE_MYSQL_TESTS. Refs #488 #308 fix #491
446 lines
22 KiB
C#
446 lines
22 KiB
C#
using Dapper;
|
|
using ErsatzTV.Infrastructure;
|
|
using ErsatzTV.Infrastructure.Data;
|
|
using ErsatzTV.Infrastructure.MySql.Data;
|
|
using ErsatzTV.Infrastructure.Sqlite.Data;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using MySqlConnector;
|
|
using NUnit.Framework;
|
|
using Shouldly;
|
|
|
|
namespace ErsatzTV.Tests.Integration;
|
|
|
|
public enum TestProvider
|
|
{
|
|
Sqlite,
|
|
MySql
|
|
}
|
|
|
|
/// <summary>
|
|
/// ersatztv#491: the unique index on <c>LibraryFolder(LibraryPathId, PathHash)</c> ships with an
|
|
/// audit/cleanup of pre-existing duplicate rows (reachable before #488, when the folder lookup read a
|
|
/// scan-start in-memory snapshot and could not see a folder created earlier in the same scan). This
|
|
/// drives the REAL migration against a database seeded at the previous migration, so the cleanup SQL
|
|
/// is exercised rather than restated.
|
|
/// <para>
|
|
/// Runs against BOTH providers from ONE fixture body. The dedupe deletes rows irreversibly and its
|
|
/// correctness turns on string-comparison semantics that differ per provider — two MySQL-only
|
|
/// collation defects escaped review in the #491 session (a case-insensitive grouping, then a PAD
|
|
/// SPACE one), and neither was reachable from a SQLite-only test, nor from CI's MySQL job, which
|
|
/// only applies migrations to a fresh EMPTY database and so executes no dedupe rows at all.
|
|
/// Parameterizing one fixture is what makes "the two providers agree" a checked property rather
|
|
/// than an assumption; a separate MySQL-only copy would drift and recreate the gap.
|
|
/// </para>
|
|
/// <para>
|
|
/// MySQL needs a live server, supplied via <c>ETV_TEST_MYSQL_CONNECTION</c>. Without it the MySQL
|
|
/// fixture <b>ignores</b> — a visible skip, never a silent pass — so local runs need no MySQL. CI
|
|
/// sets <c>ETV_REQUIRE_MYSQL_TESTS=1</c>, which turns that skip into a hard failure, so the gate
|
|
/// cannot quietly degrade into "connected to nothing and passed".
|
|
/// </para>
|
|
/// </summary>
|
|
[TestFixture(TestProvider.Sqlite)]
|
|
[TestFixture(TestProvider.MySql)]
|
|
[NonParallelizable]
|
|
public class LibraryFolderDedupeMigrationTests(TestProvider provider)
|
|
{
|
|
// the migration immediately preceding Add_LibraryFolder_PathHash_UniqueIndex
|
|
private const string PreviousMigration = "Add_Channel_Origin";
|
|
|
|
private const string MySqlConnectionVariable = "ETV_TEST_MYSQL_CONNECTION";
|
|
private const string MySqlRequiredVariable = "ETV_REQUIRE_MYSQL_TESTS";
|
|
|
|
private string _databasePath = null!;
|
|
private string? _mySqlConnectionString;
|
|
|
|
// Seeding writes deliberately partial object graphs (a LibraryPath with no Library, a MediaFile with
|
|
// no MediaVersion), so it runs with foreign keys OFF.
|
|
private DbContextOptions<TvContext> _seedOptions = null!;
|
|
|
|
// The migration itself runs with foreign keys ON, matching production. This matters: the single most
|
|
// dangerous statement in the #491 migration is DELETE FROM LibraryFolder against two Restrict foreign
|
|
// keys (MediaFile.LibraryFolderId, LibraryFolder.ParentId). With enforcement off, a wrong repoint
|
|
// order would still pass; with it on, the delete fails loudly.
|
|
private DbContextOptions<TvContext> _migrateOptions = null!;
|
|
|
|
[SetUp]
|
|
public async Task SetUp()
|
|
{
|
|
if (provider is TestProvider.Sqlite)
|
|
{
|
|
TvContext.IsSqlite = true;
|
|
TvContext.LastInsertedRowId = "last_insert_rowid()";
|
|
TvContext.CaseInsensitiveCollation = "NOCASE";
|
|
TvContext.IsUniqueConstraintViolation = SqliteErrorClassifier.IsUniqueConstraintViolation;
|
|
|
|
_databasePath = Path.Combine(Path.GetTempPath(), $"etv491-{Guid.NewGuid():N}.sqlite3");
|
|
_seedOptions = SqliteOptions(foreignKeys: false);
|
|
_migrateOptions = SqliteOptions(foreignKeys: true);
|
|
return;
|
|
}
|
|
|
|
string? baseConnectionString = Environment.GetEnvironmentVariable(MySqlConnectionVariable);
|
|
if (string.IsNullOrWhiteSpace(baseConnectionString))
|
|
{
|
|
string message =
|
|
$"{MySqlConnectionVariable} is not set, so the MySql half of the #491 dedupe fixture cannot "
|
|
+ "run. The dedupe deletes rows irreversibly and its correctness is provider-specific, so "
|
|
+ "this coverage is not optional in CI.";
|
|
|
|
// A skip is fine locally; in CI it is the very failure mode this fixture exists to prevent.
|
|
if (IsTrue(Environment.GetEnvironmentVariable(MySqlRequiredVariable)))
|
|
{
|
|
Assert.Fail($"{message} {MySqlRequiredVariable} is set, so this is a failure, not a skip.");
|
|
}
|
|
|
|
Assert.Ignore($"{message} Set it to run this locally.");
|
|
}
|
|
|
|
// A database of our own, NOT the one the surrounding CI step migrates, and a FRESH one per test:
|
|
// isolation by construction. A name that has never been used cannot contain another test's rows,
|
|
// so no wipe has to succeed for the fixture to be correct. It is not created here — the test's own
|
|
// MigrateAsync(PreviousMigration) creates it, which keeps EF the single owner of the schema.
|
|
_mySqlConnectionString =
|
|
new MySqlConnectionStringBuilder(baseConnectionString) { Database = $"etv491_{Guid.NewGuid():N}" }
|
|
.ConnectionString;
|
|
|
|
TvContext.IsSqlite = false;
|
|
TvContext.LastInsertedRowId = "last_insert_id()";
|
|
TvContext.CaseInsensitiveCollation = "utf8mb4_general_ci";
|
|
TvContext.IsUniqueConstraintViolation = MySqlErrorClassifier.IsUniqueConstraintViolation;
|
|
|
|
// AutoDetect opens its own connection, so resolve the version once and share it between the two
|
|
// option sets instead of connecting twice per test.
|
|
ServerVersion serverVersion = ServerVersion.AutoDetect(_mySqlConnectionString);
|
|
_seedOptions = MySqlOptions(serverVersion);
|
|
_migrateOptions = MySqlOptions(serverVersion);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Drop this test's database and clear the connection pool that was keyed to it.
|
|
/// <para>
|
|
/// Both halves are required, and earlier revisions of this fixture each got one wrong. Measured
|
|
/// against a real 8.4 server:
|
|
/// </para>
|
|
/// <list type="number">
|
|
/// <item>
|
|
/// <b>A pooled session outlives <c>DROP DATABASE</c>.</b> Reopening the dropped database's
|
|
/// connection string succeeds — MySqlConnector hands back the still-alive session whose
|
|
/// default schema is gone — so whether a later caller sees success or <c>Unknown database</c>
|
|
/// depends on whether the pool reuses that session or opens a fresh one (a fresh handshake
|
|
/// names the dropped schema and fails 1049). <c>ClearPool</c> after the drop removes it.
|
|
/// Note this hazard needs the connection string to be REUSED after the drop, which a
|
|
/// never-repeated database name already makes impossible; clearing the pool is the belt to
|
|
/// that brace, and closes the leak below.
|
|
/// </item>
|
|
/// <item>
|
|
/// <b>An uncleared pool leaks a server connection per test.</b> MySqlConnector keys pools by
|
|
/// connection string, so a fresh database name means a fresh pool; left uncleared it leaked
|
|
/// ~1 server thread per iteration and eventually exhausted <c>max_connections</c>.
|
|
/// <c>ClearPoolAsync</c> on that exact connection string fixes it completely — measured at
|
|
/// 0 leaked threads over 30 iterations — so per-test isolation costs nothing. A previous
|
|
/// revision instead collapsed to one shared database to stop the leak; that traded isolation
|
|
/// for a wipe that has to succeed, and when it silently did not, the second test seeded on
|
|
/// top of the first's rows and failed with a duplicate primary key.
|
|
/// </item>
|
|
/// </list>
|
|
/// <para>
|
|
/// EF owns the drop: <c>EnsureDeletedAsync</c> is guarded (a no-op when the database is absent,
|
|
/// unlike a raw <c>DROP DATABASE</c>) and uses the same connection string EF migrated with.
|
|
/// </para>
|
|
/// </summary>
|
|
private async Task DropMySqlDatabase()
|
|
{
|
|
await using (TvContext context = MigrateContext())
|
|
{
|
|
await context.Database.EnsureDeletedAsync();
|
|
}
|
|
|
|
await using var probe = new MySqlConnection(_mySqlConnectionString);
|
|
await MySqlConnection.ClearPoolAsync(probe);
|
|
}
|
|
|
|
[TearDown]
|
|
public async Task TearDown()
|
|
{
|
|
if (provider is TestProvider.Sqlite)
|
|
{
|
|
Microsoft.Data.Sqlite.SqliteConnection.ClearAllPools();
|
|
foreach (string path in new[] { _databasePath, $"{_databasePath}-wal", $"{_databasePath}-shm" })
|
|
{
|
|
if (File.Exists(path))
|
|
{
|
|
File.Delete(path);
|
|
}
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (_mySqlConnectionString is not null)
|
|
{
|
|
await DropMySqlDatabase();
|
|
_mySqlConnectionString = null;
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task Migration_Collapses_Duplicate_Folders_And_Repoints_Their_Dependents()
|
|
{
|
|
await using (TvContext context = SeedContext())
|
|
{
|
|
await context.Database.MigrateAsync(PreviousMigration);
|
|
await using SeedSession seed = await SeedSession.OpenAsync(context, provider);
|
|
|
|
// one library path with the SAME folder recorded three times (ids 1, 2, 3), an unrelated
|
|
// folder (4), a child parented on one of the duplicates (5), a case-differing sibling (6),
|
|
// a folder parented on its own duplicate (7/8 — the cycle the ParentId null-out guards), and
|
|
// a sibling differing only by a TRAILING SPACE (9/10). The last two pairs are distinct legal
|
|
// directories on Linux that hash differently, so the unique index accepts both and the dedupe
|
|
// must not collapse them. On MySql a case-insensitive grouping deletes 6, and a PAD SPACE one
|
|
// deletes 10 — utf8mb4_bin, the obvious fix for the first, is itself PAD SPACE, which is why
|
|
// the migration groups on CONVERT(Path USING binary) instead.
|
|
await seed.ExecuteAsync(
|
|
"INSERT INTO LibraryPath (Id, LibraryId, Path) VALUES (1, 1, '/data/music')");
|
|
await seed.ExecuteAsync(
|
|
"""
|
|
INSERT INTO LibraryFolder (Id, LibraryPathId, Path, ParentId, Etag) VALUES
|
|
(1, 1, '/data/music/artist1', NULL, 'etag-keeper'),
|
|
(2, 1, '/data/music/artist1', NULL, 'etag-dupe-a'),
|
|
(3, 1, '/data/music/artist1', NULL, 'etag-dupe-b'),
|
|
(4, 1, '/data/music/artist2', NULL, NULL),
|
|
(5, 1, '/data/music/artist1/album', 3, NULL),
|
|
(6, 1, '/data/music/ARTIST2', NULL, NULL),
|
|
(7, 1, '/data/music/artist3', 8, NULL),
|
|
(8, 1, '/data/music/artist3', NULL, NULL),
|
|
(9, 1, '/data/music/pad', NULL, NULL),
|
|
(10, 1, '/data/music/pad ', NULL, NULL)
|
|
""");
|
|
|
|
// a media file on each duplicate and on each half of the trailing-space pair, plus an
|
|
// image-folder-duration on duplicates only
|
|
await seed.ExecuteAsync(
|
|
"""
|
|
INSERT INTO MediaFile (Id, Path, PathHash, MediaVersionId, LibraryFolderId) VALUES
|
|
(1, '/data/music/artist1/a.mkv', 'hash-a', 1, 1),
|
|
(2, '/data/music/artist1/b.mkv', 'hash-b', 2, 2),
|
|
(3, '/data/music/artist1/c.mkv', 'hash-c', 3, 3),
|
|
(4, '/data/music/pad/d.mkv', 'hash-d', 4, 9),
|
|
(5, '/data/music/pad /e.mkv', 'hash-e', 5, 10)
|
|
""");
|
|
await seed.ExecuteAsync(
|
|
"""
|
|
INSERT INTO ImageFolderDuration (Id, LibraryFolderId, DurationSeconds) VALUES
|
|
(1, 2, 30.0),
|
|
(2, 3, 45.0)
|
|
""");
|
|
}
|
|
|
|
await using (TvContext context = MigrateContext())
|
|
{
|
|
await AssertForeignKeysEnforced(context);
|
|
await context.Database.MigrateAsync();
|
|
}
|
|
|
|
await using (TvContext context = MigrateContext())
|
|
{
|
|
// Read the surviving rows once and assert in memory. Deliberately NOT `WHERE Path = '...'`:
|
|
// that predicate is itself collation-dependent (on MySQL it would also match the
|
|
// case-differing and trailing-space siblings), so an assertion written that way would quietly
|
|
// mean something different on each provider — the exact class of bug this fixture guards.
|
|
List<FolderRow> folders = (await context.Connection.QueryAsync<FolderRow>(
|
|
"SELECT Id, Path, ParentId, Etag FROM LibraryFolder ORDER BY Id")).ToList();
|
|
|
|
// survivors: keeper 1, artist2 (4), the child (5), the case-differing sibling (6), artist3's
|
|
// keeper (7), and BOTH halves of the trailing-space pair (9, 10). Losers 2, 3 and 8 are gone.
|
|
folders.Select(f => f.Id).ToList().ShouldBe([1, 4, 5, 6, 7, 9, 10]);
|
|
|
|
// and their paths survive BYTE-exactly (ordinal comparison here, matching the hash)
|
|
folders.Select(f => f.Path).ToList().ShouldBe(
|
|
[
|
|
"/data/music/artist1",
|
|
"/data/music/artist2",
|
|
"/data/music/artist1/album",
|
|
"/data/music/ARTIST2",
|
|
"/data/music/artist3",
|
|
"/data/music/pad",
|
|
"/data/music/pad "
|
|
]);
|
|
|
|
// the keeper's etag is cleared: which duplicate the scanner was writing to was arbitrary, so
|
|
// MIN(Id)'s etag could suppress the rescan that repairs the collapsed folder
|
|
folders.Single(f => f.Id == 1).Etag.ShouldBeNull();
|
|
|
|
// the child folder is reparented off the deleted duplicate onto the keeper
|
|
folders.Single(f => f.Id == 5).ParentId.ShouldBe(1);
|
|
|
|
// the folder parented on its own duplicate did not become its own parent
|
|
folders.Single(f => f.Id == 7).ParentId.ShouldBeNull();
|
|
|
|
// every media file follows the keeper — nothing orphaned, nothing deleted — while the
|
|
// trailing-space pair's dependents stay attached to their OWN folder
|
|
List<int> mediaFolderIds = (await context.Connection.QueryAsync<int>(
|
|
"SELECT LibraryFolderId FROM MediaFile ORDER BY Id")).ToList();
|
|
mediaFolderIds.ShouldBe([1, 1, 1, 9, 10]);
|
|
|
|
// the keeper had no ImageFolderDuration, so exactly one duplicate's setting is promoted to it
|
|
// (the lowest id) and the rest are dropped — the 1:1 unique index cannot hold both
|
|
List<int> durationFolderIds = (await context.Connection.QueryAsync<int>(
|
|
"SELECT LibraryFolderId FROM ImageFolderDuration ORDER BY Id")).ToList();
|
|
durationFolderIds.ShouldBe([1]);
|
|
|
|
await AssertHelperTablesDropped(context);
|
|
await AssertUniqueIndexExists(context);
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public async Task Migration_Leaves_A_Database_Without_Duplicates_Alone()
|
|
{
|
|
// positive control: the cleanup must not touch rows that were already unique
|
|
await using (TvContext context = SeedContext())
|
|
{
|
|
await context.Database.MigrateAsync(PreviousMigration);
|
|
await using SeedSession seed = await SeedSession.OpenAsync(context, provider);
|
|
|
|
await seed.ExecuteAsync(
|
|
"INSERT INTO LibraryPath (Id, LibraryId, Path) VALUES (1, 1, '/data/music')");
|
|
await seed.ExecuteAsync(
|
|
"""
|
|
INSERT INTO LibraryFolder (Id, LibraryPathId, Path, ParentId, Etag) VALUES
|
|
(1, 1, '/data/music/artist1', NULL, 'etag-1'),
|
|
(2, 1, '/data/music/artist2', 1, 'etag-2')
|
|
""");
|
|
await seed.ExecuteAsync(
|
|
"INSERT INTO ImageFolderDuration (Id, LibraryFolderId, DurationSeconds) VALUES (1, 2, 30.0)");
|
|
}
|
|
|
|
await using (TvContext context = MigrateContext())
|
|
{
|
|
await AssertForeignKeysEnforced(context);
|
|
await context.Database.MigrateAsync();
|
|
}
|
|
|
|
await using (TvContext context = MigrateContext())
|
|
{
|
|
List<FolderRow> folders = (await context.Connection.QueryAsync<FolderRow>(
|
|
"SELECT Id, Path, ParentId, Etag FROM LibraryFolder ORDER BY Id")).ToList();
|
|
|
|
folders.Select(f => f.Id).ToList().ShouldBe([1, 2]);
|
|
folders.Single(f => f.Id == 1).Etag.ShouldBe("etag-1");
|
|
folders.Single(f => f.Id == 2).Etag.ShouldBe("etag-2");
|
|
folders.Single(f => f.Id == 2).ParentId.ShouldBe(1);
|
|
|
|
(await context.Connection.ExecuteScalarAsync<int>(
|
|
"SELECT LibraryFolderId FROM ImageFolderDuration WHERE Id = 1")).ShouldBe(2);
|
|
|
|
// existing rows keep a null hash — the index applies because nulls are distinct
|
|
(await context.Connection.ExecuteScalarAsync<int>(
|
|
"SELECT COUNT(*) FROM LibraryFolder WHERE PathHash IS NULL")).ShouldBe(2);
|
|
}
|
|
}
|
|
|
|
// Foreign-key enforcement is the whole point of splitting the seed and migrate contexts, so prove it
|
|
// took effect rather than trusting a connection-string keyword: a typo, a pragma reset or a leaked
|
|
// SET FOREIGN_KEY_CHECKS=0 would silently revert this to its weaker form and still pass everything.
|
|
private async Task AssertForeignKeysEnforced(TvContext context)
|
|
{
|
|
string sql = provider is TestProvider.Sqlite
|
|
? "PRAGMA foreign_keys"
|
|
: "SELECT @@SESSION.foreign_key_checks";
|
|
|
|
(await context.Connection.ExecuteScalarAsync<long>(sql)).ShouldBe(
|
|
1,
|
|
"the migration must run with foreign keys ENFORCED — otherwise DELETE FROM LibraryFolder is "
|
|
+ "not actually tested against the Restrict constraints it must not violate");
|
|
}
|
|
|
|
private async Task AssertHelperTablesDropped(TvContext context)
|
|
{
|
|
string sql = provider is TestProvider.Sqlite
|
|
? "SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name LIKE '__LibraryFolderDedupe%'"
|
|
: "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() "
|
|
+ "AND TABLE_NAME LIKE '\\_\\_LibraryFolderDedupe%'";
|
|
|
|
(await context.Connection.ExecuteScalarAsync<int>(sql)).ShouldBe(0, "helper tables were left behind");
|
|
}
|
|
|
|
private async Task AssertUniqueIndexExists(TvContext context)
|
|
{
|
|
string sql = provider is TestProvider.Sqlite
|
|
? "SELECT COUNT(*) FROM sqlite_master WHERE type = 'index' "
|
|
+ "AND name = 'IX_LibraryFolder_LibraryPathId_PathHash'"
|
|
: "SELECT COUNT(DISTINCT INDEX_NAME) FROM information_schema.STATISTICS "
|
|
+ "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'LibraryFolder' "
|
|
+ "AND INDEX_NAME = 'IX_LibraryFolder_LibraryPathId_PathHash'";
|
|
|
|
(await context.Connection.ExecuteScalarAsync<int>(sql)).ShouldBe(1, "the unique index was not created");
|
|
}
|
|
|
|
private static bool IsTrue(string? value) =>
|
|
value is "1" || string.Equals(value, "true", StringComparison.OrdinalIgnoreCase);
|
|
|
|
private DbContextOptions<TvContext> SqliteOptions(bool foreignKeys) =>
|
|
new DbContextOptionsBuilder<TvContext>()
|
|
.UseSqlite(
|
|
$"Data Source={_databasePath};Foreign Keys={foreignKeys}",
|
|
o => o.MigrationsAssembly("ErsatzTV.Infrastructure.Sqlite"))
|
|
.Options;
|
|
|
|
private DbContextOptions<TvContext> MySqlOptions(ServerVersion serverVersion) =>
|
|
new DbContextOptionsBuilder<TvContext>()
|
|
.UseMySql(
|
|
_mySqlConnectionString,
|
|
serverVersion,
|
|
o => o.MigrationsAssembly("ErsatzTV.Infrastructure.MySql"))
|
|
.Options;
|
|
|
|
private TvContext SeedContext() => Create(_seedOptions);
|
|
|
|
private TvContext MigrateContext() => Create(_migrateOptions);
|
|
|
|
private static TvContext Create(DbContextOptions<TvContext> options) =>
|
|
new(
|
|
options,
|
|
NullLoggerFactory.Instance,
|
|
new SlowQueryInterceptor(NullLogger<SlowQueryInterceptor>.Instance));
|
|
|
|
// Settable properties rather than a positional record: SQLite hands back INTEGER as Int64 while MySQL
|
|
// hands back INT as Int32, and Dapper only narrows for property setters, not constructor matching.
|
|
private sealed class FolderRow
|
|
{
|
|
public int Id { get; set; }
|
|
public string Path { get; set; } = null!;
|
|
public int? ParentId { get; set; }
|
|
public string Etag { get; set; } = null!;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Holds one connection open for the whole seeding block. SQLite disables foreign keys through a
|
|
/// connection-string keyword, but MySQL's <c>foreign_key_checks</c> is a SESSION variable — and
|
|
/// Dapper closes a connection it had to open itself, which would reset it between statements.
|
|
/// Opening explicitly keeps the session, and therefore the setting, alive across every insert.
|
|
/// </summary>
|
|
private sealed class SeedSession : IAsyncDisposable
|
|
{
|
|
private readonly TvContext _context;
|
|
|
|
private SeedSession(TvContext context) => _context = context;
|
|
|
|
public static async Task<SeedSession> OpenAsync(TvContext context, TestProvider provider)
|
|
{
|
|
await context.Database.OpenConnectionAsync();
|
|
if (provider is TestProvider.MySql)
|
|
{
|
|
await context.Connection.ExecuteAsync("SET SESSION foreign_key_checks = 0");
|
|
}
|
|
|
|
return new SeedSession(context);
|
|
}
|
|
|
|
public Task ExecuteAsync(string sql) => _context.Connection.ExecuteAsync(sql);
|
|
|
|
public async ValueTask DisposeAsync() => await _context.Database.CloseConnectionAsync();
|
|
}
|
|
}
|