using Npgsql; namespace Femto.Database; internal class Migrator(string migrationDirectory, NpgsqlDataSource dataSource) : IAsyncDisposable { private record MigrationScript(string Id, string Script); public async Task Migrate() { var migrations = await LoadMigrationScripts(); await this.EnsureMigrationsTableExists(); var executedMigrations = await GetExecutedMigrations(); EnsureConsistentMigrationHistory(migrations, executedMigrations); if (executedMigrations.Count == migrations.Count) { Console.WriteLine("up to date"); return; } await DoMigrations(migrations.Skip(executedMigrations.Count)); } private async Task> LoadMigrationScripts() { if (!Directory.Exists(migrationDirectory)) throw new DirectoryNotFoundException($"Migration directory not found: {migrationDirectory}"); var scriptFiles = Directory.EnumerateFiles(migrationDirectory, "*.sql") .OrderBy(Path.GetFileName) .ToList(); if (!scriptFiles.Any()) throw new Exception("No migration scripts found in the specified directory."); var migrationScripts = new List(); foreach (var file in scriptFiles) { var scriptContent = await File.ReadAllTextAsync(file); var scriptId = Path.GetFileName(file); migrationScripts.Add(new MigrationScript(scriptId, scriptContent)); } return migrationScripts; } private async Task EnsureMigrationsTableExists() { await using var createSchemaCommand = dataSource.CreateCommand( """CREATE SCHEMA IF NOT EXISTS __migrations;""" ); await createSchemaCommand.ExecuteNonQueryAsync(); await using var createTableCommand = dataSource.CreateCommand( """ CREATE TABLE IF NOT EXISTS __migrations.__migration_history ( migration VARCHAR(127) PRIMARY KEY ); """ ); await createTableCommand.ExecuteNonQueryAsync(); } private async Task> GetExecutedMigrations() { await using var command = dataSource.CreateCommand( """SELECT migration FROM __migrations.__migration_history""" ); var reader = await command.ExecuteReaderAsync(); var migrations = new List(); while (await reader.ReadAsync()) { migrations.Add(reader.GetString(0)); } return migrations; } private void EnsureConsistentMigrationHistory( IList migrationScripts, IList executedMigrationIds ) { if (executedMigrationIds.Count > migrationScripts.Count) throw new Exception("inconsistent migration history"); for (var i = 0; i < executedMigrationIds.Count; i++) { var migration = migrationScripts[i]; var executedMigrationId = executedMigrationIds[i]; if (migration.Id != executedMigrationId) throw new Exception($"unexpected migration in history {executedMigrationId}"); } } private async Task DoMigrations(IEnumerable migrationScripts) { foreach (var migration in migrationScripts) { Console.WriteLine($"applying migration {migration.Id}"); await using var command = dataSource.CreateCommand(migration.Script); await command.ExecuteNonQueryAsync(); await using var addToHistoryCommand = dataSource.CreateCommand( """INSERT INTO __migrations.__migration_history (migration) VALUES (@migration)""" ); addToHistoryCommand.Parameters.AddWithValue("@migration", migration.Id); await addToHistoryCommand.ExecuteNonQueryAsync(); } } public ValueTask DisposeAsync() { return dataSource.DisposeAsync(); } }