This commit is contained in:
StellaOps Bot
2025-11-29 02:19:50 +02:00
parent 2548abc56f
commit b34f13dc03
86 changed files with 9625 additions and 640 deletions

View File

@@ -1,3 +1,4 @@
using System.Reflection;
using Microsoft.Extensions.Logging;
using Npgsql;
@@ -110,6 +111,85 @@ public sealed class MigrationRunner
return appliedCount;
}
/// <summary>
/// Runs all pending migrations from embedded resources in an assembly.
/// </summary>
/// <param name="assembly">Assembly containing embedded migration resources.</param>
/// <param name="resourcePrefix">Optional prefix to filter resources (e.g., "Migrations").</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>Number of migrations applied.</returns>
public async Task<int> RunFromAssemblyAsync(
Assembly assembly,
string? resourcePrefix = null,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(assembly);
var resourceNames = assembly.GetManifestResourceNames()
.Where(name => name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
.Where(name => string.IsNullOrEmpty(resourcePrefix) || name.StartsWith(resourcePrefix, StringComparison.OrdinalIgnoreCase))
.OrderBy(name => name)
.ToList();
if (resourceNames.Count == 0)
{
_logger.LogInformation("No embedded migration resources found in assembly {Assembly} for module {Module}.",
assembly.GetName().Name, _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 resourceName in resourceNames)
{
// Extract just the filename from the resource name
var fileName = ExtractMigrationFileName(resourceName);
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 ApplyMigrationFromResourceAsync(connection, assembly, resourceName, fileName, cancellationToken)
.ConfigureAwait(false);
appliedCount++;
_logger.LogInformation("Migration {Migration} applied successfully for module {Module}.",
fileName, _moduleName);
}
if (appliedCount > 0)
{
_logger.LogInformation("Applied {Count} embedded 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>
@@ -270,6 +350,63 @@ public sealed class MigrationRunner
}
}
private async Task ApplyMigrationFromResourceAsync(
NpgsqlConnection connection,
Assembly assembly,
string resourceName,
string fileName,
CancellationToken cancellationToken)
{
await using var stream = assembly.GetManifestResourceStream(resourceName)
?? throw new InvalidOperationException($"Could not load embedded resource: {resourceName}");
using var reader = new StreamReader(stream);
var sql = await reader.ReadToEndAsync(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 ExtractMigrationFileName(string resourceName)
{
// Resource names use the LogicalName from .csproj which is just the filename
// e.g., "001_initial.sql" or might have path prefix like "Migrations/001_initial.sql"
var lastSlash = resourceName.LastIndexOf('/');
return lastSlash >= 0 ? resourceName[(lastSlash + 1)..] : resourceName;
}
private static string ComputeChecksum(string content)
{
var bytes = System.Text.Encoding.UTF8.GetBytes(content);