diff --git a/src/TaxBaik.Infrastructure/Data/MigrationRunner.cs b/src/TaxBaik.Infrastructure/Data/MigrationRunner.cs index 9156935..b98284f 100644 --- a/src/TaxBaik.Infrastructure/Data/MigrationRunner.cs +++ b/src/TaxBaik.Infrastructure/Data/MigrationRunner.cs @@ -1,6 +1,8 @@ +using System.Data; using System.Reflection; -using System.Text; -using Npgsql; +using System.Text.RegularExpressions; +using DbUp; +using DbUp.Engine; using TaxBaik.Domain.Interfaces; namespace TaxBaik.Infrastructure.Data; @@ -18,159 +20,191 @@ public class MigrationRunner public async Task RunAsync() { - await EnsureMigrationTableAsync(); - await ExecutePendingMigrationsAsync(); + await Task.Run(() => RunMigrations()); } - 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> GetExecutedMigrationsAsync() - { - var executed = new HashSet(); - 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 GetAvailableMigrations() - { - var migrations = new List(); - // Try file system first (for deployment), then embedded resources var migrationDirs = new[] { "./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 upgradeEngineBuilder = DeployChanges.To + .PostgresqlDatabase(_connectionString) + .JournalTo(new DbUpCustomJournal(_connectionString)) + .LogToConsole(); + if (migrationPath != null && Directory.Exists(migrationPath)) { - var files = Directory.GetFiles(migrationPath, "V*.sql").OrderBy(x => x); - foreach (var file in files) - { - 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 }); - } - } + Console.WriteLine($"[DbUp] Loading migration scripts from filesystem path: {migrationPath}"); + upgradeEngineBuilder = upgradeEngineBuilder.WithScriptsFromFileSystem(migrationPath); } else { - var assembly = Assembly.GetExecutingAssembly(); - var resourceNames = assembly.GetManifestResourceNames() - .Where(x => x.Contains(".Migrations.V") && x.EndsWith(".sql", StringComparison.OrdinalIgnoreCase)) - .OrderBy(x => x); + Console.WriteLine("[DbUp] Loading migration scripts embedded in Assembly."); + upgradeEngineBuilder = upgradeEngineBuilder.WithScriptsEmbeddedInAssembly( + Assembly.GetExecutingAssembly(), + 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); - if (stream == null) - continue; + 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."); + } + } +} - using var reader = new StreamReader(stream); - var sql = reader.ReadToEnd(); - var fileName = Path.GetFileNameWithoutExtension(resourceName); - var versionStart = fileName.IndexOf('V'); - var versionEnd = fileName.IndexOf('_', versionStart + 1); - if (versionStart < 0 || versionEnd < 0) - continue; +public class DbUpCustomJournal : IJournal +{ + private readonly string _connectionString; - var version = fileName.Substring(versionStart + 1, versionEnd - versionStart - 1); - var description = fileName.Substring(versionEnd + 1); + public DbUpCustomJournal(string connectionString) + { + _connectionString = connectionString; + } - migrations.Add(new Migration { Version = version, Description = description, Sql = sql }); + public string[] GetExecutedScripts() + { + var executedVersions = new HashSet(); + + 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(); + + // 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(); + foreach (var scriptName in allScriptNames.Distinct()) + { + var match = Regex.Match(scriptName, @"[Vv](?\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 dbCommandFactory) { - using var conn = new NpgsqlConnection(_connectionString); - await conn.OpenAsync(); + var match = Regex.Match(script.Name, @"[Vv](?\d+)__(?.*)$"); + string version; + string description; - try + if (match.Success) { - using var cmd = conn.CreateCommand(); - cmd.CommandText = migration.Sql; - 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"); + version = match.Groups["version"].Value; + description = match.Groups["desc"].Value.Replace(".sql", "").Replace("_", " "); } - catch (Npgsql.PostgresException pgEx) when (pgEx.SqlState == "42P07") // relation already exists + else { - // 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(); + version = script.Name; + description = "Executed via DbUp"; } - catch (Exception ex) + + using (var cmd = dbCommandFactory()) { - Console.WriteLine($"✗ Migration {migration.Version} failed: {ex.Message}"); - throw; + cmd.CommandText = @" + 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 dbCommandFactory) { - public required string Version { get; set; } - public required string Description { get; set; } - public required string Sql { get; set; } + using (var cmd = dbCommandFactory()) + { + cmd.CommandText = @" + CREATE TABLE IF NOT EXISTS schema_migrations ( + version VARCHAR(50) PRIMARY KEY, + description VARCHAR(500), + installed_on TIMESTAMPTZ DEFAULT NOW() + );"; + cmd.ExecuteNonQuery(); + } } } diff --git a/src/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj b/src/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj index 826412d..6ca05db 100644 --- a/src/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj +++ b/src/TaxBaik.Infrastructure/TaxBaik.Infrastructure.csproj @@ -4,6 +4,7 @@ +