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.
97 lines
3.9 KiB
C#
97 lines
3.9 KiB
C#
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;
|
|
}
|
|
}
|