up
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Npgsql;
|
||||
|
||||
namespace StellaOps.Infrastructure.Postgres.Migrations;
|
||||
|
||||
/// <summary>
|
||||
/// Runs SQL migrations for a PostgreSQL schema.
|
||||
/// Migrations are idempotent and tracked in a schema_migrations table.
|
||||
/// </summary>
|
||||
public sealed class MigrationRunner
|
||||
{
|
||||
private readonly string _connectionString;
|
||||
private readonly string _schemaName;
|
||||
private readonly string _moduleName;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new migration runner.
|
||||
/// </summary>
|
||||
/// <param name="connectionString">PostgreSQL connection string.</param>
|
||||
/// <param name="schemaName">Schema name for the module.</param>
|
||||
/// <param name="moduleName">Module name for logging.</param>
|
||||
/// <param name="logger">Logger instance.</param>
|
||||
public MigrationRunner(
|
||||
string connectionString,
|
||||
string schemaName,
|
||||
string moduleName,
|
||||
ILogger logger)
|
||||
{
|
||||
_connectionString = connectionString ?? throw new ArgumentNullException(nameof(connectionString));
|
||||
_schemaName = schemaName ?? throw new ArgumentNullException(nameof(schemaName));
|
||||
_moduleName = moduleName ?? throw new ArgumentNullException(nameof(moduleName));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs all pending migrations from the specified path.
|
||||
/// </summary>
|
||||
/// <param name="migrationsPath">Path to directory containing SQL migration files.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>Number of migrations applied.</returns>
|
||||
public async Task<int> RunAsync(string migrationsPath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(migrationsPath);
|
||||
|
||||
if (!Directory.Exists(migrationsPath))
|
||||
{
|
||||
throw new DirectoryNotFoundException($"Migrations directory not found: {migrationsPath}");
|
||||
}
|
||||
|
||||
var migrationFiles = Directory.GetFiles(migrationsPath, "*.sql")
|
||||
.OrderBy(f => Path.GetFileName(f))
|
||||
.ToList();
|
||||
|
||||
if (migrationFiles.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No migration files found in {Path} for module {Module}.",
|
||||
migrationsPath, _moduleName);
|
||||
return 0;
|
||||
}
|
||||
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Ensure schema exists
|
||||
await EnsureSchemaAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Ensure migrations table exists
|
||||
await EnsureMigrationsTableAsync(connection, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
// Get applied migrations
|
||||
var appliedMigrations = await GetAppliedMigrationsAsync(connection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
var appliedCount = 0;
|
||||
|
||||
foreach (var file in migrationFiles)
|
||||
{
|
||||
var fileName = Path.GetFileName(file);
|
||||
|
||||
if (appliedMigrations.Contains(fileName))
|
||||
{
|
||||
_logger.LogDebug("Migration {Migration} already applied for module {Module}.",
|
||||
fileName, _moduleName);
|
||||
continue;
|
||||
}
|
||||
|
||||
_logger.LogInformation("Applying migration {Migration} for module {Module}...",
|
||||
fileName, _moduleName);
|
||||
|
||||
await ApplyMigrationAsync(connection, file, fileName, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
appliedCount++;
|
||||
|
||||
_logger.LogInformation("Migration {Migration} applied successfully for module {Module}.",
|
||||
fileName, _moduleName);
|
||||
}
|
||||
|
||||
if (appliedCount > 0)
|
||||
{
|
||||
_logger.LogInformation("Applied {Count} migration(s) for module {Module}.",
|
||||
appliedCount, _moduleName);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation("Database is up to date for module {Module}.", _moduleName);
|
||||
}
|
||||
|
||||
return appliedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the current migration version (latest applied migration).
|
||||
/// </summary>
|
||||
public async Task<string?> GetCurrentVersionAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tableExists = await CheckMigrationsTableExistsAsync(connection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!tableExists) return null;
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
$"SELECT migration_name FROM {_schemaName}.schema_migrations ORDER BY applied_at DESC LIMIT 1",
|
||||
connection);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result as string;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all applied migrations.
|
||||
/// </summary>
|
||||
public async Task<IReadOnlyList<MigrationInfo>> GetAppliedMigrationInfoAsync(
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
await using var connection = new NpgsqlConnection(_connectionString);
|
||||
await connection.OpenAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var tableExists = await CheckMigrationsTableExistsAsync(connection, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!tableExists) return [];
|
||||
|
||||
await using var command = new NpgsqlCommand(
|
||||
$"""
|
||||
SELECT migration_name, applied_at, checksum
|
||||
FROM {_schemaName}.schema_migrations
|
||||
ORDER BY applied_at
|
||||
""",
|
||||
connection);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var migrations = new List<MigrationInfo>();
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
migrations.Add(new MigrationInfo(
|
||||
Name: reader.GetString(0),
|
||||
AppliedAt: reader.GetFieldValue<DateTimeOffset>(1),
|
||||
Checksum: reader.GetString(2)));
|
||||
}
|
||||
|
||||
return migrations;
|
||||
}
|
||||
|
||||
private async Task EnsureSchemaAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
$"CREATE SCHEMA IF NOT EXISTS {_schemaName};", connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task EnsureMigrationsTableAsync(NpgsqlConnection connection, CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
$"""
|
||||
CREATE TABLE IF NOT EXISTS {_schemaName}.schema_migrations (
|
||||
migration_name TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
checksum TEXT NOT NULL
|
||||
);
|
||||
""",
|
||||
connection);
|
||||
await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task<bool> CheckMigrationsTableExistsAsync(
|
||||
NpgsqlConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = @schema
|
||||
AND table_name = 'schema_migrations'
|
||||
);
|
||||
""",
|
||||
connection);
|
||||
command.Parameters.AddWithValue("schema", _schemaName);
|
||||
|
||||
var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false);
|
||||
return result is true;
|
||||
}
|
||||
|
||||
private async Task<HashSet<string>> GetAppliedMigrationsAsync(
|
||||
NpgsqlConnection connection,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var command = new NpgsqlCommand(
|
||||
$"SELECT migration_name FROM {_schemaName}.schema_migrations;",
|
||||
connection);
|
||||
|
||||
await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false);
|
||||
var migrations = new HashSet<string>(StringComparer.Ordinal);
|
||||
|
||||
while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
migrations.Add(reader.GetString(0));
|
||||
}
|
||||
|
||||
return migrations;
|
||||
}
|
||||
|
||||
private async Task ApplyMigrationAsync(
|
||||
NpgsqlConnection connection,
|
||||
string filePath,
|
||||
string fileName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var sql = await File.ReadAllTextAsync(filePath, cancellationToken).ConfigureAwait(false);
|
||||
var checksum = ComputeChecksum(sql);
|
||||
|
||||
await using var transaction = await connection.BeginTransactionAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
try
|
||||
{
|
||||
// Run migration SQL
|
||||
await using (var migrationCommand = new NpgsqlCommand(sql, connection, transaction))
|
||||
{
|
||||
migrationCommand.CommandTimeout = 300; // 5 minute timeout for migrations
|
||||
await migrationCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Record migration
|
||||
await using (var recordCommand = new NpgsqlCommand(
|
||||
$"""
|
||||
INSERT INTO {_schemaName}.schema_migrations (migration_name, checksum)
|
||||
VALUES (@name, @checksum);
|
||||
""",
|
||||
connection,
|
||||
transaction))
|
||||
{
|
||||
recordCommand.Parameters.AddWithValue("name", fileName);
|
||||
recordCommand.Parameters.AddWithValue("checksum", checksum);
|
||||
await recordCommand.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ComputeChecksum(string content)
|
||||
{
|
||||
var bytes = System.Text.Encoding.UTF8.GetBytes(content);
|
||||
var hash = System.Security.Cryptography.SHA256.HashData(bytes);
|
||||
return Convert.ToHexStringLower(hash);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Information about an applied migration.
|
||||
/// </summary>
|
||||
public readonly record struct MigrationInfo(string Name, DateTimeOffset AppliedAt, string Checksum);
|
||||
Reference in New Issue
Block a user