test(data): assert migrated schema matches EF model (schema-drift guard)
Sibling migrations branched off the same parent have silently dropped each other's columns during a SQLite table rebuild before, and every other Data.Tests fixture uses EnsureCreated, which skips the migration chain entirely and would never catch that.
This commit is contained in:
@@ -0,0 +1,96 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace ClaudeDo.Data.Tests;
|
||||
|
||||
// Sibling migrations branched off the same parent have silently dropped each other's columns
|
||||
// during a SQLite table rebuild before (see docs/explore-notes). Every other Data.Tests fixture
|
||||
// uses EnsureCreated, which skips the migration chain entirely, so nothing else in the suite
|
||||
// would catch that. This compares the schema the migration chain actually produces against the
|
||||
// schema EnsureCreated builds from today's model.
|
||||
public sealed class MigrationSchemaDriftTests : IDisposable
|
||||
{
|
||||
private readonly string _migratedDbPath;
|
||||
private readonly string _modelDbPath;
|
||||
|
||||
public MigrationSchemaDriftTests()
|
||||
{
|
||||
_migratedDbPath = Path.Combine(Path.GetTempPath(), $"claudedo_drift_migrated_{Guid.NewGuid():N}.db");
|
||||
_modelDbPath = Path.Combine(Path.GetTempPath(), $"claudedo_drift_model_{Guid.NewGuid():N}.db");
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
foreach (var path in new[] { _migratedDbPath, _modelDbPath })
|
||||
foreach (var suffix in new[] { "", "-wal", "-shm" })
|
||||
try { File.Delete(path + suffix); } catch { }
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Migrated_schema_has_the_same_columns_as_the_current_model()
|
||||
{
|
||||
using (var migrated = new ClaudeDoDbContext(OptionsFor(_migratedDbPath)))
|
||||
ClaudeDoDbContext.MigrateAndConfigure(migrated);
|
||||
using (var model = new ClaudeDoDbContext(OptionsFor(_modelDbPath)))
|
||||
model.Database.EnsureCreated();
|
||||
|
||||
var migratedColumns = ReadColumnsByTable(_migratedDbPath);
|
||||
var modelColumns = ReadColumnsByTable(_modelDbPath);
|
||||
|
||||
var mismatches = new List<string>();
|
||||
foreach (var (table, expectedColumns) in modelColumns)
|
||||
{
|
||||
if (!migratedColumns.TryGetValue(table, out var actualColumns))
|
||||
{
|
||||
mismatches.Add($"{table}: missing from migrated schema entirely");
|
||||
continue;
|
||||
}
|
||||
|
||||
var missingInMigrated = expectedColumns.Except(actualColumns).ToList();
|
||||
var extraInMigrated = actualColumns.Except(expectedColumns).ToList();
|
||||
if (missingInMigrated.Count > 0 || extraInMigrated.Count > 0)
|
||||
{
|
||||
mismatches.Add(
|
||||
$"{table}: missing-in-migrated=[{string.Join(", ", missingInMigrated)}] " +
|
||||
$"extra-in-migrated=[{string.Join(", ", extraInMigrated)}]");
|
||||
}
|
||||
}
|
||||
|
||||
Assert.True(mismatches.Count == 0, "Schema drift between migrated DB and current model:\n" + string.Join("\n", mismatches));
|
||||
}
|
||||
|
||||
private static DbContextOptions<ClaudeDoDbContext> OptionsFor(string dbPath) =>
|
||||
new DbContextOptionsBuilder<ClaudeDoDbContext>()
|
||||
.UseSqlite($"Data Source={dbPath}")
|
||||
.Options;
|
||||
|
||||
private static Dictionary<string, HashSet<string>> ReadColumnsByTable(string dbPath)
|
||||
{
|
||||
using var conn = new SqliteConnection($"Data Source={dbPath}");
|
||||
conn.Open();
|
||||
|
||||
var tables = new List<string>();
|
||||
using (var cmd = conn.CreateCommand())
|
||||
{
|
||||
cmd.CommandText = "SELECT name FROM sqlite_master WHERE type='table' AND name != '__EFMigrationsHistory' AND name NOT LIKE 'sqlite_%'";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
tables.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
var result = new Dictionary<string, HashSet<string>>();
|
||||
foreach (var table in tables)
|
||||
{
|
||||
var columns = new HashSet<string>();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = $"PRAGMA table_info(\"{table}\")";
|
||||
using var reader = cmd.ExecuteReader();
|
||||
while (reader.Read())
|
||||
columns.Add(reader.GetString(reader.GetOrdinal("name")));
|
||||
result[table] = columns;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user