using Microsoft.EntityFrameworkCore.Migrations; #nullable disable namespace ErsatzTV.Infrastructure.Sqlite.Migrations { /// public partial class Add_LibraryFolder_PathHash_UniqueIndex : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) { // ersatztv#491 — audit/clean pre-existing duplicate LibraryFolder rows before the unique index. // Duplicates were reachable before #488 (the folder lookup read a scan-start in-memory snapshot, // so a folder created earlier in the SAME scan was invisible and inserted again) and via the // check-then-insert race the index now closes. Keep the lowest Id per (LibraryPathId, Path) and // repoint every dependent row at it before deleting the losers. The helper tables keep the // statements readable; DROP TABLE IF EXISTS makes a retry after a partial failure safe. migrationBuilder.Sql("DROP TABLE IF EXISTS __LibraryFolderDedupe"); migrationBuilder.Sql( """ CREATE TABLE __LibraryFolderDedupe ( LoserId INTEGER NOT NULL PRIMARY KEY, KeeperId INTEGER NOT NULL ) """); migrationBuilder.Sql( """ INSERT INTO __LibraryFolderDedupe (LoserId, KeeperId) SELECT l.Id, k.KeeperId FROM LibraryFolder l INNER JOIN ( SELECT LibraryPathId, Path, MIN(Id) AS KeeperId FROM LibraryFolder GROUP BY LibraryPathId, Path ) k ON k.LibraryPathId = l.LibraryPathId AND k.Path = l.Path WHERE l.Id <> k.KeeperId """); // media files recorded against a duplicate folder follow the keeper (MediaFile.LibraryFolderId // is Restrict, so the delete below would fail otherwise) migrationBuilder.Sql( """ UPDATE MediaFile SET LibraryFolderId = ( SELECT KeeperId FROM __LibraryFolderDedupe WHERE LoserId = MediaFile.LibraryFolderId) WHERE LibraryFolderId IN (SELECT LoserId FROM __LibraryFolderDedupe) """); // child folders parented on a duplicate follow the keeper (ParentId is Restrict as well) migrationBuilder.Sql( """ UPDATE LibraryFolder SET ParentId = ( SELECT KeeperId FROM __LibraryFolderDedupe WHERE LoserId = LibraryFolder.ParentId) WHERE ParentId IN (SELECT LoserId FROM __LibraryFolderDedupe) """); // A folder parented on its OWN duplicate would become its own parent above. Unreachable from // any code path today, but this is a tree the scanner walks, so remove the cycle class rather // than reason about it. migrationBuilder.Sql("UPDATE LibraryFolder SET ParentId = NULL WHERE ParentId = Id"); // Clear the survivor's etag. Which duplicate the scanner was actually writing to was // arbitrary, so MIN(Id)'s etag may describe a stale view of the folder and would suppress the // next rescan. A null etag costs exactly one rescan and cannot be wrong. migrationBuilder.Sql( "UPDATE LibraryFolder SET Etag = NULL WHERE Id IN (SELECT KeeperId FROM __LibraryFolderDedupe)"); // ImageFolderDuration is 1:1 with LibraryFolder (unique index on LibraryFolderId), so the // duplicates' rows cannot all be repointed. Keep the keeper's own setting when it has one; // otherwise promote exactly one loser's (lowest Id) and drop the rest. migrationBuilder.Sql("DROP TABLE IF EXISTS __LibraryFolderDedupeIfd"); migrationBuilder.Sql( """ CREATE TABLE __LibraryFolderDedupeIfd ( KeeperId INTEGER NOT NULL PRIMARY KEY, IfdId INTEGER NOT NULL ) """); migrationBuilder.Sql( """ INSERT INTO __LibraryFolderDedupeIfd (KeeperId, IfdId) SELECT d.KeeperId, MIN(i.Id) FROM __LibraryFolderDedupe d INNER JOIN ImageFolderDuration i ON i.LibraryFolderId = d.LoserId WHERE NOT EXISTS ( SELECT 1 FROM ImageFolderDuration ki WHERE ki.LibraryFolderId = d.KeeperId) GROUP BY d.KeeperId """); migrationBuilder.Sql( """ DELETE FROM ImageFolderDuration WHERE LibraryFolderId IN (SELECT LoserId FROM __LibraryFolderDedupe) AND Id NOT IN (SELECT IfdId FROM __LibraryFolderDedupeIfd) """); migrationBuilder.Sql( """ UPDATE ImageFolderDuration SET LibraryFolderId = ( SELECT KeeperId FROM __LibraryFolderDedupeIfd WHERE IfdId = ImageFolderDuration.Id) WHERE Id IN (SELECT IfdId FROM __LibraryFolderDedupeIfd) """); migrationBuilder.Sql( "DELETE FROM LibraryFolder WHERE Id IN (SELECT LoserId FROM __LibraryFolderDedupe)"); migrationBuilder.Sql("DROP TABLE __LibraryFolderDedupeIfd"); migrationBuilder.Sql("DROP TABLE __LibraryFolderDedupe"); migrationBuilder.AddColumn( name: "PathHash", table: "LibraryFolder", type: "TEXT", maxLength: 64, nullable: true); // Existing rows keep a null hash on purpose: a unique index treats nulls as distinct, so the // index applies cleanly to any database, and LibraryRepository.GetOrAddFolder heals each row // (SHA-256 of Path) the first time a scan touches it. Those rows are deduplicated above and are // still found by the Path lookup, so no insert can race them in the meantime. // // Create-then-drop rather than EF's scaffolded drop-then-create, matching the MySql copy, where // InnoDB refuses to drop the foreign key's only backing index. migrationBuilder.CreateIndex( name: "IX_LibraryFolder_LibraryPathId_PathHash", table: "LibraryFolder", columns: new[] { "LibraryPathId", "PathHash" }, unique: true); migrationBuilder.DropIndex( name: "IX_LibraryFolder_LibraryPathId", table: "LibraryFolder"); } /// protected override void Down(MigrationBuilder migrationBuilder) { migrationBuilder.CreateIndex( name: "IX_LibraryFolder_LibraryPathId", table: "LibraryFolder", column: "LibraryPathId"); migrationBuilder.DropIndex( name: "IX_LibraryFolder_LibraryPathId_PathHash", table: "LibraryFolder"); migrationBuilder.DropColumn( name: "PathHash", table: "LibraryFolder"); } } }