Use DbUp for schema migrations with custom journal for compatibility
TaxBaik CI/CD / build-and-deploy (push) Successful in 56s
TaxBaik CI/CD / build-and-deploy (push) Successful in 56s
This commit is contained in:
@@ -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);
|
|
||||||
await conn.OpenAsync();
|
|
||||||
|
|
||||||
using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = @"
|
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
|
||||||
version VARCHAR(50) PRIMARY KEY,
|
|
||||||
description VARCHAR(500),
|
|
||||||
installed_on TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);";
|
|
||||||
await cmd.ExecuteNonQueryAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ExecutePendingMigrationsAsync()
|
|
||||||
{
|
|
||||||
var executedMigrations = await GetExecutedMigrationsAsync();
|
|
||||||
var migrations = GetAvailableMigrations();
|
|
||||||
|
|
||||||
foreach (var migration in migrations.OrderBy(x => x.Version))
|
|
||||||
{
|
|
||||||
if (!executedMigrations.Contains(migration.Version))
|
|
||||||
{
|
|
||||||
await ExecuteMigrationAsync(migration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<HashSet<string>> GetExecutedMigrationsAsync()
|
|
||||||
{
|
|
||||||
var executed = new HashSet<string>();
|
|
||||||
using var conn = new NpgsqlConnection(_connectionString);
|
|
||||||
await conn.OpenAsync();
|
|
||||||
|
|
||||||
using var cmd = conn.CreateCommand();
|
|
||||||
cmd.CommandText = "SELECT version FROM schema_migrations ORDER BY version;";
|
|
||||||
|
|
||||||
using var reader = await cmd.ExecuteReaderAsync();
|
|
||||||
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
|
// Try file system first (for deployment), then embedded resources
|
||||||
var migrationDirs = new[]
|
var migrationDirs = new[]
|
||||||
{
|
{
|
||||||
"./migrations", // relative
|
"./migrations", // relative
|
||||||
"/home/kjh2064/taxbaik_active/migrations" // deployment
|
"/home/kjh2064/taxbaik_active/migrations", // deployment
|
||||||
|
"./db/migrations",
|
||||||
|
"../db/migrations"
|
||||||
};
|
};
|
||||||
|
|
||||||
var migrationPath = migrationDirs.FirstOrDefault(Directory.Exists);
|
var migrationPath = migrationDirs.FirstOrDefault(Directory.Exists);
|
||||||
|
|
||||||
|
var upgradeEngineBuilder = DeployChanges.To
|
||||||
|
.PostgresqlDatabase(_connectionString)
|
||||||
|
.JournalTo(new DbUpCustomJournal(_connectionString))
|
||||||
|
.LogToConsole();
|
||||||
|
|
||||||
if (migrationPath != null && Directory.Exists(migrationPath))
|
if (migrationPath != null && Directory.Exists(migrationPath))
|
||||||
{
|
{
|
||||||
var files = Directory.GetFiles(migrationPath, "V*.sql").OrderBy(x => x);
|
Console.WriteLine($"[DbUp] Loading migration scripts from filesystem path: {migrationPath}");
|
||||||
foreach (var file in files)
|
upgradeEngineBuilder = upgradeEngineBuilder.WithScriptsFromFileSystem(migrationPath);
|
||||||
{
|
|
||||||
var fileName = Path.GetFileNameWithoutExtension(file);
|
|
||||||
if (fileName.StartsWith("V"))
|
|
||||||
{
|
|
||||||
var version = fileName.Substring(1, fileName.IndexOf('_') - 1);
|
|
||||||
var description = fileName.Substring(fileName.IndexOf('_') + 2);
|
|
||||||
var sql = File.ReadAllText(file);
|
|
||||||
|
|
||||||
migrations.Add(new Migration { Version = version, Description = description, Sql = sql });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var assembly = Assembly.GetExecutingAssembly();
|
Console.WriteLine("[DbUp] Loading migration scripts embedded in Assembly.");
|
||||||
var resourceNames = assembly.GetManifestResourceNames()
|
upgradeEngineBuilder = upgradeEngineBuilder.WithScriptsEmbeddedInAssembly(
|
||||||
.Where(x => x.Contains(".Migrations.V") && x.EndsWith(".sql", StringComparison.OrdinalIgnoreCase))
|
Assembly.GetExecutingAssembly(),
|
||||||
.OrderBy(x => x);
|
name => name.Contains(".Migrations.V") && name.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var resourceName in resourceNames)
|
var upgradeEngine = upgradeEngineBuilder.Build();
|
||||||
|
|
||||||
|
if (upgradeEngine.IsUpgradeRequired())
|
||||||
|
{
|
||||||
|
Console.WriteLine("[DbUp] Database upgrade is required. Running migrations...");
|
||||||
|
var result = upgradeEngine.PerformUpgrade();
|
||||||
|
if (!result.Successful)
|
||||||
{
|
{
|
||||||
using var stream = assembly.GetManifestResourceStream(resourceName);
|
Console.WriteLine($"[DbUp] Database migration failed: {result.Error}");
|
||||||
if (stream == null)
|
throw result.Error;
|
||||||
continue;
|
}
|
||||||
|
Console.WriteLine("[DbUp] Database migration completed successfully.");
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Console.WriteLine("[DbUp] Database is up-to-date. No migrations applied.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
using var reader = new StreamReader(stream);
|
public class DbUpCustomJournal : IJournal
|
||||||
var sql = reader.ReadToEnd();
|
{
|
||||||
var fileName = Path.GetFileNameWithoutExtension(resourceName);
|
private readonly string _connectionString;
|
||||||
var versionStart = fileName.IndexOf('V');
|
|
||||||
var versionEnd = fileName.IndexOf('_', versionStart + 1);
|
|
||||||
if (versionStart < 0 || versionEnd < 0)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
var version = fileName.Substring(versionStart + 1, versionEnd - versionStart - 1);
|
public DbUpCustomJournal(string connectionString)
|
||||||
var description = fileName.Substring(versionEnd + 1);
|
{
|
||||||
|
_connectionString = connectionString;
|
||||||
|
}
|
||||||
|
|
||||||
migrations.Add(new Migration { Version = version, Description = description, Sql = sql });
|
public string[] GetExecutedScripts()
|
||||||
|
{
|
||||||
|
var executedVersions = new HashSet<string>();
|
||||||
|
|
||||||
|
using (var conn = new Npgsql.NpgsqlConnection(_connectionString))
|
||||||
|
{
|
||||||
|
conn.Open();
|
||||||
|
using (var cmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = @"
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version VARCHAR(50) PRIMARY KEY,
|
||||||
|
description VARCHAR(500),
|
||||||
|
installed_on TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
using (var cmd = conn.CreateCommand())
|
||||||
|
{
|
||||||
|
cmd.CommandText = "SELECT version FROM schema_migrations;";
|
||||||
|
using (var reader = cmd.ExecuteReader())
|
||||||
|
{
|
||||||
|
while (reader.Read())
|
||||||
|
{
|
||||||
|
executedVersions.Add(reader.GetString(0));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return migrations;
|
// Collect all possible script names from both embedded resources and filesystem
|
||||||
|
var allScriptNames = new List<string>();
|
||||||
|
|
||||||
|
// 1. Assembly resources
|
||||||
|
var assembly = Assembly.GetExecutingAssembly();
|
||||||
|
var resourceNames = assembly.GetManifestResourceNames()
|
||||||
|
.Where(x => x.Contains(".Migrations.V") && x.EndsWith(".sql", StringComparison.OrdinalIgnoreCase));
|
||||||
|
allScriptNames.AddRange(resourceNames);
|
||||||
|
|
||||||
|
// 2. Filesystem
|
||||||
|
var migrationDirs = new[]
|
||||||
|
{
|
||||||
|
"./migrations",
|
||||||
|
"/home/kjh2064/taxbaik_active/migrations",
|
||||||
|
"./db/migrations",
|
||||||
|
"../db/migrations"
|
||||||
|
};
|
||||||
|
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)!);
|
||||||
|
}
|
||||||
|
|
||||||
|
var executedScriptNames = new List<string>();
|
||||||
|
foreach (var scriptName in allScriptNames.Distinct())
|
||||||
|
{
|
||||||
|
var match = Regex.Match(scriptName, @"[Vv](?<version>\d+)__");
|
||||||
|
if (match.Success)
|
||||||
|
{
|
||||||
|
var ver = match.Groups["version"].Value;
|
||||||
|
if (executedVersions.Contains(ver))
|
||||||
|
{
|
||||||
|
executedScriptNames.Add(scriptName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return executedScriptNames.ToArray();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ExecuteMigrationAsync(Migration migration)
|
public void StoreExecutedScript(SqlScript script, Func<IDbCommand> dbCommandFactory)
|
||||||
{
|
{
|
||||||
using var conn = new NpgsqlConnection(_connectionString);
|
var match = Regex.Match(script.Name, @"[Vv](?<version>\d+)__(?<desc>.*)$");
|
||||||
await conn.OpenAsync();
|
string version;
|
||||||
|
string description;
|
||||||
|
|
||||||
try
|
if (match.Success)
|
||||||
{
|
{
|
||||||
using var cmd = conn.CreateCommand();
|
version = match.Groups["version"].Value;
|
||||||
cmd.CommandText = migration.Sql;
|
description = match.Groups["desc"].Value.Replace(".sql", "").Replace("_", " ");
|
||||||
await cmd.ExecuteNonQueryAsync();
|
|
||||||
|
|
||||||
using var insertCmd = conn.CreateCommand();
|
|
||||||
insertCmd.CommandText =
|
|
||||||
"INSERT INTO schema_migrations (version, description) VALUES (@version, @description);";
|
|
||||||
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
|
else
|
||||||
{
|
{
|
||||||
// Already executed previously; mark as done
|
version = script.Name;
|
||||||
Console.WriteLine($"ℹ Migration {migration.Version} already applied");
|
description = "Executed via DbUp";
|
||||||
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)
|
|
||||||
|
using (var cmd = dbCommandFactory())
|
||||||
{
|
{
|
||||||
Console.WriteLine($"✗ Migration {migration.Version} failed: {ex.Message}");
|
cmd.CommandText = @"
|
||||||
throw;
|
INSERT INTO schema_migrations (version, description)
|
||||||
|
VALUES (@version, @description)
|
||||||
|
ON CONFLICT (version) DO NOTHING;";
|
||||||
|
|
||||||
|
var pVersion = cmd.CreateParameter();
|
||||||
|
pVersion.ParameterName = "@version";
|
||||||
|
pVersion.Value = version;
|
||||||
|
cmd.Parameters.Add(pVersion);
|
||||||
|
|
||||||
|
var pDesc = cmd.CreateParameter();
|
||||||
|
pDesc.ParameterName = "@description";
|
||||||
|
pDesc.Value = description;
|
||||||
|
cmd.Parameters.Add(pDesc);
|
||||||
|
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class Migration
|
public void EnsureTableExistsAndIsLatestVersion(Func<IDbCommand> dbCommandFactory)
|
||||||
{
|
{
|
||||||
public required string Version { get; set; }
|
using (var cmd = dbCommandFactory())
|
||||||
public required string Description { get; set; }
|
{
|
||||||
public required string Sql { get; set; }
|
cmd.CommandText = @"
|
||||||
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
|
version VARCHAR(50) PRIMARY KEY,
|
||||||
|
description VARCHAR(500),
|
||||||
|
installed_on TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
);";
|
||||||
|
cmd.ExecuteNonQuery();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user