Use DbUp for schema migrations with custom journal for compatibility
TaxBaik CI/CD / build-and-deploy (push) Successful in 56s

This commit is contained in:
2026-07-11 17:31:49 +09:00
parent fb3ee4aad6
commit dce8e23676
2 changed files with 154 additions and 119 deletions
+145 -111
View File
@@ -1,6 +1,8 @@
using System.Data;
using System.Reflection; using System.Reflection;
using System.Text; using System.Text.RegularExpressions;
using Npgsql; using DbUp;
using DbUp.Engine;
using TaxBaik.Domain.Interfaces; using TaxBaik.Domain.Interfaces;
namespace TaxBaik.Infrastructure.Data; namespace TaxBaik.Infrastructure.Data;
@@ -18,159 +20,191 @@ public class MigrationRunner
public async Task RunAsync() public async Task RunAsync()
{ {
await EnsureMigrationTableAsync(); await Task.Run(() => RunMigrations());
await ExecutePendingMigrationsAsync();
} }
private async Task EnsureMigrationTableAsync() private void RunMigrations()
{ {
using var conn = new NpgsqlConnection(_connectionString); // Try file system first (for deployment), then embedded resources
await conn.OpenAsync(); var migrationDirs = new[]
{
"./migrations", // relative
"/home/kjh2064/taxbaik_active/migrations", // deployment
"./db/migrations",
"../db/migrations"
};
using var cmd = conn.CreateCommand(); var migrationPath = migrationDirs.FirstOrDefault(Directory.Exists);
var upgradeEngineBuilder = DeployChanges.To
.PostgresqlDatabase(_connectionString)
.JournalTo(new DbUpCustomJournal(_connectionString))
.LogToConsole();
if (migrationPath != null && Directory.Exists(migrationPath))
{
Console.WriteLine($"[DbUp] Loading migration scripts from filesystem path: {migrationPath}");
upgradeEngineBuilder = upgradeEngineBuilder.WithScriptsFromFileSystem(migrationPath);
}
else
{
Console.WriteLine("[DbUp] Loading migration scripts embedded in Assembly.");
upgradeEngineBuilder = upgradeEngineBuilder.WithScriptsEmbeddedInAssembly(
Assembly.GetExecutingAssembly(),
name => name.Contains(".Migrations.V") && name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)
);
}
var upgradeEngine = upgradeEngineBuilder.Build();
if (upgradeEngine.IsUpgradeRequired())
{
Console.WriteLine("[DbUp] Database upgrade is required. Running migrations...");
var result = upgradeEngine.PerformUpgrade();
if (!result.Successful)
{
Console.WriteLine($"[DbUp] Database migration failed: {result.Error}");
throw result.Error;
}
Console.WriteLine("[DbUp] Database migration completed successfully.");
}
else
{
Console.WriteLine("[DbUp] Database is up-to-date. No migrations applied.");
}
}
}
public class DbUpCustomJournal : IJournal
{
private readonly string _connectionString;
public DbUpCustomJournal(string connectionString)
{
_connectionString = connectionString;
}
public string[] GetExecutedScripts()
{
var executedVersions = new HashSet<string>();
using (var conn = new Npgsql.NpgsqlConnection(_connectionString))
{
conn.Open();
using (var cmd = conn.CreateCommand())
{
cmd.CommandText = @" cmd.CommandText = @"
CREATE TABLE IF NOT EXISTS schema_migrations ( CREATE TABLE IF NOT EXISTS schema_migrations (
version VARCHAR(50) PRIMARY KEY, version VARCHAR(50) PRIMARY KEY,
description VARCHAR(500), description VARCHAR(500),
installed_on TIMESTAMPTZ DEFAULT NOW() installed_on TIMESTAMPTZ DEFAULT NOW()
);"; );";
await cmd.ExecuteNonQueryAsync(); cmd.ExecuteNonQuery();
} }
private async Task ExecutePendingMigrationsAsync() using (var cmd = conn.CreateCommand())
{ {
var executedMigrations = await GetExecutedMigrationsAsync(); cmd.CommandText = "SELECT version FROM schema_migrations;";
var migrations = GetAvailableMigrations(); using (var reader = cmd.ExecuteReader())
foreach (var migration in migrations.OrderBy(x => x.Version))
{ {
if (!executedMigrations.Contains(migration.Version)) while (reader.Read())
{ {
await ExecuteMigrationAsync(migration); executedVersions.Add(reader.GetString(0));
}
} }
} }
} }
private async Task<HashSet<string>> GetExecutedMigrationsAsync() // Collect all possible script names from both embedded resources and filesystem
{ var allScriptNames = new List<string>();
var executed = new HashSet<string>();
using var conn = new NpgsqlConnection(_connectionString);
await conn.OpenAsync();
using var cmd = conn.CreateCommand(); // 1. Assembly resources
cmd.CommandText = "SELECT version FROM schema_migrations ORDER BY version;"; var assembly = Assembly.GetExecutingAssembly();
var resourceNames = assembly.GetManifestResourceNames()
.Where(x => x.Contains(".Migrations.V") && x.EndsWith(".sql", StringComparison.OrdinalIgnoreCase));
allScriptNames.AddRange(resourceNames);
using var reader = await cmd.ExecuteReaderAsync(); // 2. Filesystem
while (await reader.ReadAsync())
{
executed.Add(reader.GetString(0));
}
return executed;
}
private List<Migration> GetAvailableMigrations()
{
var migrations = new List<Migration>();
// Try file system first (for deployment), then embedded resources
var migrationDirs = new[] var migrationDirs = new[]
{ {
"./migrations", // relative "./migrations",
"/home/kjh2064/taxbaik_active/migrations" // deployment "/home/kjh2064/taxbaik_active/migrations",
"./db/migrations",
"../db/migrations"
}; };
var migrationPath = migrationDirs.FirstOrDefault(Directory.Exists); var migrationPath = migrationDirs.FirstOrDefault(Directory.Exists);
if (migrationPath != null)
{
var files = Directory.GetFiles(migrationPath, "V*.sql");
allScriptNames.AddRange(files.Select(Path.GetFileName).Where(x => x != null)!);
}
if (migrationPath != null && Directory.Exists(migrationPath)) var executedScriptNames = new List<string>();
foreach (var scriptName in allScriptNames.Distinct())
{ {
var files = Directory.GetFiles(migrationPath, "V*.sql").OrderBy(x => x); var match = Regex.Match(scriptName, @"[Vv](?<version>\d+)__");
foreach (var file in files) if (match.Success)
{ {
var fileName = Path.GetFileNameWithoutExtension(file); var ver = match.Groups["version"].Value;
if (fileName.StartsWith("V")) if (executedVersions.Contains(ver))
{ {
var version = fileName.Substring(1, fileName.IndexOf('_') - 1); executedScriptNames.Add(scriptName);
var description = fileName.Substring(fileName.IndexOf('_') + 2);
var sql = File.ReadAllText(file);
migrations.Add(new Migration { Version = version, Description = description, Sql = sql });
} }
} }
} }
return executedScriptNames.ToArray();
}
public void StoreExecutedScript(SqlScript script, Func<IDbCommand> dbCommandFactory)
{
var match = Regex.Match(script.Name, @"[Vv](?<version>\d+)__(?<desc>.*)$");
string version;
string description;
if (match.Success)
{
version = match.Groups["version"].Value;
description = match.Groups["desc"].Value.Replace(".sql", "").Replace("_", " ");
}
else else
{ {
var assembly = Assembly.GetExecutingAssembly(); version = script.Name;
var resourceNames = assembly.GetManifestResourceNames() description = "Executed via DbUp";
.Where(x => x.Contains(".Migrations.V") && x.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)) }
.OrderBy(x => x);
foreach (var resourceName in resourceNames) using (var cmd = dbCommandFactory())
{ {
using var stream = assembly.GetManifestResourceStream(resourceName); cmd.CommandText = @"
if (stream == null) INSERT INTO schema_migrations (version, description)
continue; VALUES (@version, @description)
ON CONFLICT (version) DO NOTHING;";
using var reader = new StreamReader(stream); var pVersion = cmd.CreateParameter();
var sql = reader.ReadToEnd(); pVersion.ParameterName = "@version";
var fileName = Path.GetFileNameWithoutExtension(resourceName); pVersion.Value = version;
var versionStart = fileName.IndexOf('V'); cmd.Parameters.Add(pVersion);
var versionEnd = fileName.IndexOf('_', versionStart + 1);
if (versionStart < 0 || versionEnd < 0)
continue;
var version = fileName.Substring(versionStart + 1, versionEnd - versionStart - 1); var pDesc = cmd.CreateParameter();
var description = fileName.Substring(versionEnd + 1); pDesc.ParameterName = "@description";
pDesc.Value = description;
cmd.Parameters.Add(pDesc);
migrations.Add(new Migration { Version = version, Description = description, Sql = sql }); cmd.ExecuteNonQuery();
} }
} }
return migrations; public void EnsureTableExistsAndIsLatestVersion(Func<IDbCommand> dbCommandFactory)
}
private async Task ExecuteMigrationAsync(Migration migration)
{ {
using var conn = new NpgsqlConnection(_connectionString); using (var cmd = dbCommandFactory())
await conn.OpenAsync();
try
{ {
using var cmd = conn.CreateCommand(); cmd.CommandText = @"
cmd.CommandText = migration.Sql; CREATE TABLE IF NOT EXISTS schema_migrations (
await cmd.ExecuteNonQueryAsync(); version VARCHAR(50) PRIMARY KEY,
description VARCHAR(500),
using var insertCmd = conn.CreateCommand(); installed_on TIMESTAMPTZ DEFAULT NOW()
insertCmd.CommandText = );";
"INSERT INTO schema_migrations (version, description) VALUES (@version, @description);"; cmd.ExecuteNonQuery();
insertCmd.Parameters.AddWithValue("@version", migration.Version);
insertCmd.Parameters.AddWithValue("@description", migration.Description);
await insertCmd.ExecuteNonQueryAsync();
Console.WriteLine($"✓ Migration {migration.Version} executed");
} }
catch (Npgsql.PostgresException pgEx) when (pgEx.SqlState == "42P07") // relation already exists
{
// Already executed previously; mark as done
Console.WriteLine($" Migration {migration.Version} already applied");
using var insertCmd = conn.CreateCommand();
insertCmd.CommandText =
"INSERT INTO schema_migrations (version, description) VALUES (@version, @description) ON CONFLICT (version) DO NOTHING;";
insertCmd.Parameters.AddWithValue("@version", migration.Version);
insertCmd.Parameters.AddWithValue("@description", migration.Description);
await insertCmd.ExecuteNonQueryAsync();
}
catch (Exception ex)
{
Console.WriteLine($"✗ Migration {migration.Version} failed: {ex.Message}");
throw;
}
}
private class Migration
{
public required string Version { get; set; }
public required string Description { get; set; }
public required string Sql { get; set; }
} }
} }
@@ -4,6 +4,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Dapper" Version="2.1.15" /> <PackageReference Include="Dapper" Version="2.1.15" />
<PackageReference Include="dbup-postgresql" Version="7.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Npgsql" Version="10.0.3" /> <PackageReference Include="Npgsql" Version="10.0.3" />