femto-backend/Femto.Database/Migrator.cs
2025-05-03 15:38:57 +02:00

122 lines
4 KiB
C#

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<IList<MigrationScript>> 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<MigrationScript>();
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<IList<string>> GetExecutedMigrations()
{
await using var command = dataSource.CreateCommand(
"""SELECT migration FROM __migrations.__migration_history"""
);
var reader = await command.ExecuteReaderAsync();
var migrations = new List<string>();
while (await reader.ReadAsync())
{
migrations.Add(reader.GetString(0));
}
return migrations;
}
private void EnsureConsistentMigrationHistory(
IList<MigrationScript> migrationScripts,
IList<string> 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<MigrationScript> 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();
}
}