diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml index 7dba5873..52020f5b 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -62,6 +62,7 @@ jobs: if: always() with: name: test-results-${{ matrix.database }} + overwrite: true path: TestResults/ if-no-files-found: error - name: Remove test container diff --git a/Migrator.slnx b/Migrator.slnx index 4cddff02..cf4bc016 100644 --- a/Migrator.slnx +++ b/Migrator.slnx @@ -13,6 +13,14 @@ + + + + + + + + diff --git a/src/Migrator.Extensions.DependencyInjection/DotNetProjects.Migrator.Extensions.DependencyInjection.csproj b/src/Migrator.Extensions.DependencyInjection/DotNetProjects.Migrator.Extensions.DependencyInjection.csproj new file mode 100644 index 00000000..e8f1027b --- /dev/null +++ b/src/Migrator.Extensions.DependencyInjection/DotNetProjects.Migrator.Extensions.DependencyInjection.csproj @@ -0,0 +1,9 @@ + + net9.09.0.0MPL-1.1Optional dependency injection, options and logging integration for Migrator.NET. + + + + + + + diff --git a/src/Migrator.Extensions.DependencyInjection/MigrationLogger.cs b/src/Migrator.Extensions.DependencyInjection/MigrationLogger.cs new file mode 100644 index 00000000..cdc63c2d --- /dev/null +++ b/src/Migrator.Extensions.DependencyInjection/MigrationLogger.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using Microsoft.Extensions.Logging; +namespace DotNetProjects.Migrator.Extensions.DependencyInjection; + +/// Logs lifecycle events. SQL and exception messages may contain secrets and are omitted. +public sealed class MigrationLogger(Microsoft.Extensions.Logging.ILogger logger) : Framework.ILogger +{ + public void Started(List currentVersion, long finalVersion) => logger.LogInformation("Migration run started; target {Version}", finalVersion); + public void Finished(List currentVersion, long finalVersion) => logger.LogInformation("Migration run completed; target {Version}", finalVersion); + public void MigrateUp(long version, string migrationName) => logger.LogInformation("Applying migration {Version} ({Name})", version, migrationName); + public void MigrateDown(long version, string migrationName) => logger.LogInformation("Reverting migration {Version} ({Name})", version, migrationName); + public void Skipping(long version) => logger.LogWarning("Skipping migration {Version}", version); + public void RollingBack(long originalVersion) => logger.LogWarning("Rolling back migration {Version}", originalVersion); + public void ApplyingDBChange(string sql) => logger.LogDebug("Executing a database change"); + public void Exception(long version, string migrationName, Exception ex) => logger.LogError("Migration {Version} failed: {ExceptionType}", version, ex.GetType().Name); + public void Exception(string message, Exception ex) => logger.LogError("Migration operation failed: {ExceptionType}", ex.GetType().Name); + public void Log(string format, params object[] args) => logger.LogInformation("Provider informational event"); + public void Warn(string format, params object[] args) => logger.LogWarning("Provider warning event"); + public void Trace(string format, params object[] args) { } // Provider traces commonly contain SQL values. +} diff --git a/src/Migrator.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/src/Migrator.Extensions.DependencyInjection/ServiceCollectionExtensions.cs new file mode 100644 index 00000000..0ae41213 --- /dev/null +++ b/src/Migrator.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -0,0 +1,31 @@ +using System; +using System.Linq; +using System.Reflection; +using DotNetProjects.Migrator.Framework; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +namespace DotNetProjects.Migrator.Extensions.DependencyInjection; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddMigrator(this IServiceCollection services, + Func providerFactory, Assembly migrations, Action configure = null) + { + services.AddOptions(); + if (configure != null) services.Configure(configure); + services.AddScoped(providerFactory); + foreach (var type in MigrationLoader.GetMigrationTypes(migrations)) services.TryAddTransient(type); + services.AddScoped(sp => + { + var provider = sp.GetRequiredService(); + var loader = new MigrationLoader(provider, migrations, false); + var options = sp.GetRequiredService>().Value; + options.Activator ??= type => (IMigration)sp.GetRequiredService(type); + return new Migrator(provider, new MigrationLogger(sp.GetService()?.CreateLogger("Migrator.NET") ?? NullLogger.Instance), loader) { Options = options }; + }); + return services; + } +} diff --git a/src/Migrator.Tests/DatabaseLockTests.cs b/src/Migrator.Tests/DatabaseLockTests.cs new file mode 100644 index 00000000..5a72fc55 --- /dev/null +++ b/src/Migrator.Tests/DatabaseLockTests.cs @@ -0,0 +1,106 @@ +using System; +using System.Data.Common; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Providers; +using Migrator.Tests.Settings; +using NUnit.Framework; +namespace Migrator.Tests; + +[TestFixture(ProviderTypes.SqlServer, Category = "SQLServer")] +[TestFixture(ProviderTypes.PostgreSQL, Category = "PostgreSQL")] +[TestFixture(ProviderTypes.Mysql, Category = "MySQL")] +[TestFixture(ProviderTypes.MariaDB, Category = "MariaDB")] +public class DatabaseLockTests(ProviderTypes type) +{ + private DbConnection Open() + { + DbConnection connection; + if (type == ProviderTypes.SqlServer) + { + var config = new ConfigurationReader().GetDatabaseConnectionConfigById("SQLServer"); + var builder = new Microsoft.Data.SqlClient.SqlConnectionStringBuilder(config.ConnectionString) { InitialCatalog = "master" }; + connection = new Microsoft.Data.SqlClient.SqlConnection(builder.ConnectionString); + } + else if (type == ProviderTypes.PostgreSQL) + connection = new Npgsql.NpgsqlConnection(new ConfigurationReader().GetDatabaseConnectionConfigById("PostgreSQL").ConnectionString); + else + connection = new MySql.Data.MySqlClient.MySqlConnection(Environment.GetEnvironmentVariable(type == ProviderTypes.Mysql ? "MIGRATOR_MYSQL" : "MIGRATOR_MARIADB") + ?? "Server=127.0.0.1;Database=testdb;User ID=root;Password=rootpass;Pooling=false"); + connection.Open(); return connection; + } + private sealed class RunState : IDisposable + { + public int Calls; + public readonly ManualResetEventSlim Entered = new(); + public readonly ManualResetEventSlim Release = new(); + public void Dispose() { Entered.Dispose(); Release.Dispose(); } + } + [Migration(1)] + private sealed class CountMigration(RunState state) : Migration + { + public override void Up() + { + Interlocked.Increment(ref state.Calls); + state.Entered.Set(); + if (!state.Release.Wait(TimeSpan.FromSeconds(20))) throw new TimeoutException("Test migration gate timed out."); + } + public override void Down() { } + } + private sealed class SignallingLock(ManualResetEventSlim attempted) : IMigrationLock + { + public IDisposable Acquire(ITransformationProvider provider, string scope, TimeSpan timeout) + { attempted.Set(); return new DatabaseMigrationLock().Acquire(provider, scope, timeout); } + } + [Test] + public async Task ConcurrentRunnersReloadStaleHistoryAfterAcquiringNativeLock() + { + using var connection1 = Open(); using var connection2 = Open(); + using var p1 = ProviderFactory.Create(type, connection1, null); + using var p2 = ProviderFactory.Create(type, connection2, null); + p1.SchemaInfoTable = p2.SchemaInfoTable = "lockhistory_" + Guid.NewGuid().ToString("N")[..12]; + using var state = new RunState(); using var attempted = new ManualResetEventSlim(); + Assert.That(p2.AppliedMigrations, Is.Empty); // Deliberately seed a stale empty cache. + var first = new DotNetProjects.Migrator.Migrator(p1, false, typeof(CountMigration)); + var second = new DotNetProjects.Migrator.Migrator(p2, false, typeof(CountMigration)); + first.Options.Activator = second.Options.Activator = _ => new CountMigration(state); + first.Options.Lock = new DatabaseMigrationLock(); second.Options.Lock = new SignallingLock(attempted); + first.Options.LockTimeout = second.Options.LockTimeout = TimeSpan.FromSeconds(15); + Task one = null, two = null; + try + { + one = Task.Run(first.MigrateToLastVersion); + Assert.That(state.Entered.Wait(TimeSpan.FromSeconds(10)), Is.True); + two = Task.Run(second.MigrateToLastVersion); + Assert.That(attempted.Wait(TimeSpan.FromSeconds(10)), Is.True); + state.Release.Set(); + await Task.WhenAll(one, two); + Assert.That(state.Calls, Is.EqualTo(1)); + Assert.That(((IMigrationHistory)p2).ReadAppliedMigrations(), Is.EqualTo(new long[] { 1 })); + using var released = new DatabaseMigrationLock().Acquire(p2, ((IMigrationHistory)p2).Scope, TimeSpan.Zero); + } + finally + { + state.Release.Set(); + try { await Task.WhenAll(new[] { one, two }.Where(task => task != null)); } + finally { p1.RemoveTable(p1.SchemaInfoTable); } + } + } + + [Test] public void IndependentSessionsContendAndCanAcquireAfterRelease() + { + using var connection1 = Open(); using var connection2 = Open(); + using var p1 = ProviderFactory.Create(type, connection1, null); + using var p2 = ProviderFactory.Create(type, connection2, null); + var migrationLock = new DatabaseMigrationLock(); var scope = Guid.NewGuid().ToString("N"); + using (migrationLock.Acquire(p1, scope, TimeSpan.FromSeconds(1))) + { + Assert.Throws(() => migrationLock.Acquire(p2, scope, TimeSpan.FromMilliseconds(100))); + using var independentScope = migrationLock.Acquire(p2, scope + "other", TimeSpan.Zero); + } + using var acquiredAfterRelease = migrationLock.Acquire(p2, scope, TimeSpan.FromSeconds(1)); + } +} diff --git a/src/Migrator.Tests/Migrator.Tests.csproj b/src/Migrator.Tests/Migrator.Tests.csproj index b1cc09e6..061f4d0f 100644 --- a/src/Migrator.Tests/Migrator.Tests.csproj +++ b/src/Migrator.Tests/Migrator.Tests.csproj @@ -45,11 +45,13 @@ + + - + diff --git a/src/Migrator.Tests/RunnerFeatureTests.cs b/src/Migrator.Tests/RunnerFeatureTests.cs new file mode 100644 index 00000000..66bff1cd --- /dev/null +++ b/src/Migrator.Tests/RunnerFeatureTests.cs @@ -0,0 +1,191 @@ +using System; +using System.Collections.Generic; +using System.Data; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; +using Microsoft.Data.Sqlite; +using NUnit.Framework; +namespace Migrator.Tests; + +[Category("SQLite")] +public class RunnerFeatureTests +{ + private static readonly List Events = new(); + [Migration(1), Tags("blue", "shared")] + internal class First : Migration + { + public override void Up() { Events.Add("first"); Database.AddTable("First", new Column("Id", DbType.Int32)); } + public override void Down() => Database.RemoveTable("First"); + public override void AfterUp() + { + Assert.That(((TransformationProvider)Database).CurrentMigration, Is.SameAs(this)); + Assert.That(((TransformationProvider)Database).HasActiveTransaction, Is.False); + Events.Add("committed"); + } + } + [Migration(2), Tags("red", "shared")] + internal class Second : Migration + { + public override void Up() { Events.Add("second"); Database.AddTable("Second", new Column("Id", DbType.Int32)); } + public override void Down() => Database.RemoveTable("Second"); + } + [Migration(3)] internal class Failure : Migration + { + public override void Up() => throw new InvalidOperationException("migration failed"); + public override void Down() => throw new NotSupportedException(); + } + [Profile("seed")] internal class Seed : Migration + { + public override void Up() { Events.Add("profile"); Database.Insert("First", new[] { "Id" }, new object[] { 7 }); } + public override void Down() => throw new NotSupportedException(); + } + [Maintenance(MaintenanceStage.BeforeRun)] internal class Before : Migration + { + public override void Up() => Events.Add("before"); + public override void Down() => throw new NotSupportedException(); + } + [Maintenance(MaintenanceStage.AfterRun)] internal class After : Migration + { + public override void Up() => Events.Add("after"); + public override void Down() => throw new NotSupportedException(); + } + [SetUp] public void Reset() => Events.Clear(); + private static ITransformationProvider Provider() + { + // Provider owns this connection, so disposal also closes the in-memory database. + return ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null); + } + [Test] public void ProfilesAndMaintenanceHaveDeterministicOrderAndNoHistory() + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(After), typeof(Seed), typeof(First), typeof(Before)); + runner.Options.Profiles.Add("seed"); + runner.MigrateToLastVersion(); + Assert.That(Events, Is.EqualTo(new[] { "before", "first", "committed", "profile", "after" })); + Assert.That(p.AppliedMigrations, Is.EqualTo(new long[] { 1 })); + Assert.That(Convert.ToInt64(p.ExecuteScalar("SELECT Id FROM First")), Is.EqualTo(7)); + } + [Test] public void AuxiliaryOnlyLatestRunPreservesExistingVersions() + { + using var p = Provider(); + new DotNetProjects.Migrator.Migrator(p, false, typeof(First)).MigrateToLastVersion(); + Events.Clear(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(Before), typeof(Seed), typeof(After)); + runner.Options.Profiles.Add("seed"); + runner.MigrateToLastVersion(); + Assert.That(Events, Is.EqualTo(new[] { "before", "profile", "after" })); + Assert.That(p.AppliedMigrations, Is.EqualTo(new long[] { 1 })); + Assert.That(Convert.ToInt64(p.ExecuteScalar("SELECT Id FROM First")), Is.EqualTo(7)); + } + [TestCase(TagMatchMode.Any, 2)] + [TestCase(TagMatchMode.All, 1)] + public void TagsUseExplicitAnyOrAll(TagMatchMode mode, int expected) + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(Second), typeof(First)); + runner.Options.TagMatch = mode; + runner.Options.Tags.Add("blue"); runner.Options.Tags.Add("shared"); + runner.MigrateTo(2); + Assert.That(p.AppliedMigrations.Count, Is.EqualTo(expected)); + } + [TestCase(MigrationTransactionMode.WholeSession, false)] + [TestCase(MigrationTransactionMode.PerMigration, true)] + [TestCase(MigrationTransactionMode.None, true)] + public void TransactionModeDefinesFailureBoundary(MigrationTransactionMode mode, bool firstRemains) + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(First), typeof(Failure)); + runner.Options.TransactionMode = mode; + Assert.Throws(() => runner.MigrateToLastVersion()); + Assert.That(p.TableExists("First"), Is.EqualTo(firstRemains)); + Assert.That(p.AppliedMigrations.Contains(1), Is.EqualTo(firstRemains)); + Assert.That(Events.Contains("committed"), Is.EqualTo(firstRemains)); + } + [Test] public void SessionCallbacksRunAfterAllMigrationsAndCommit() + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(First), typeof(Second)); + runner.Options.TransactionMode = MigrationTransactionMode.WholeSession; + runner.MigrateToLastVersion(); + Assert.That(Events, Is.EqualTo(new[] { "first", "second", "committed" })); + } + [Test] public void LockPrecedesHistoryAndReleasesOnFailure() + { + using var p = Provider(); var migrationLock = new ProbeLock(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(Failure)); runner.Options.Lock = migrationLock; + Assert.Throws(() => runner.MigrateToLastVersion()); + Assert.That(migrationLock.Disposed, Is.True); + } + [Test] public void LegacyPreviewRequiresOptInAndNeverCreatesHistory() + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(First)); + Assert.Catch(() => runner.PreviewSql(1, ProviderTypes.SQLite)); + Assert.That(Events, Is.Empty); + var sql = runner.PreviewSql(1, ProviderTypes.SQLite, allowLegacyBodies: true); + Assert.That(sql, Does.Contain("CREATE TABLE")); + Assert.That(p.TableExists("First"), Is.False); + Assert.That(p.TableExists(p.SchemaInfoTable), Is.False); + Assert.That(Events, Is.EqualTo(new[] { "first" })); // Opt-in still executes arbitrary C#. + } + [Migration(1)] internal class DirectConnection : Migration + { + public override void Up() => _ = Database.Connection; + public override void Down() => throw new NotSupportedException(); + } + [Test] public void LegacyPreviewRejectsDirectConnectionsAndUnsupportedLocks() + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(DirectConnection)); + Assert.Catch(() => runner.PreviewSql(1, ProviderTypes.SQLite, true)); + runner.Options.Lock = new DatabaseMigrationLock(); + Assert.Catch(() => runner.MigrateTo(1)); + Assert.That(p.TableExists(p.SchemaInfoTable), Is.False); + } + [Migration(4)] internal class RequiresInitialization : First + { + public override void InitializeOnce(string[] args) => throw new Exception("must not execute"); + } + [Test] public void PreviewRejectsInitializationDependentMigrationsBeforeBody() + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(RequiresInitialization)); + Assert.Throws(() => runner.PreviewSql(4, ProviderTypes.SQLite, true)); + Assert.That(Events, Is.Empty); + Assert.That(p.TableExists(p.SchemaInfoTable), Is.False); + } + [Test] public void LifecycleLogArgumentsRemainInitialHistorySnapshots() + { + using var p = Provider(); + var logger = NSubstitute.Substitute.For(); + List started = null, finished = null; + logger.Started(NSubstitute.Arg.Do>(h => started = h), NSubstitute.Arg.Any()); + logger.Finished(NSubstitute.Arg.Do>(h => finished = h), NSubstitute.Arg.Any()); + var runner = new DotNetProjects.Migrator.Migrator(p, false, logger, typeof(First)); + runner.MigrateTo(1); + Assert.That(started, Is.Empty); Assert.That(finished, Is.Empty); + Assert.That(p.AppliedMigrations, Is.EqualTo(new long[] { 1 })); + } + [Test] public void LockReleaseFailureDoesNotMaskMigrationFailure() + { + using var p = Provider(); + var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(Failure)); + runner.Options.Lock = new FailingReleaseLock(); + var error = Assert.Throws(() => runner.MigrateToLastVersion()); + Assert.That(error.Message, Is.EqualTo("migration failed")); + Assert.That(error.Data["LockReleaseException"], Is.TypeOf()); + } + private sealed class FailingReleaseLock : IMigrationLock, IDisposable + { + public IDisposable Acquire(ITransformationProvider p, string scope, TimeSpan timeout) => this; + public void Dispose() => throw new ApplicationException("release failed"); + } + private sealed class ProbeLock : IMigrationLock, IDisposable + { + public bool Disposed { get; private set; } + public IDisposable Acquire(ITransformationProvider p, string scope, TimeSpan timeout) + { Assert.That(p.TableExists(p.SchemaInfoTable), Is.False); return this; } + public void Dispose() => Disposed = true; + } +} diff --git a/src/Migrator.Tests/ToolingTests.cs b/src/Migrator.Tests/ToolingTests.cs new file mode 100644 index 00000000..7a81d65d --- /dev/null +++ b/src/Migrator.Tests/ToolingTests.cs @@ -0,0 +1,136 @@ +using System; +using System.Data; +using System.IO; +using System.Linq; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Extensions.DependencyInjection; +using DotNetProjects.Migrator.Providers; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +using NSubstitute; +namespace Migrator.Tests; + +public class ToolingTests +{ + public sealed class Dependency { public bool Activated { get; set; } } + [Migration(900001, Scope = "tooling-spec")] + public class InjectedMigration(Dependency dependency) : Migration + { + public override void Up() { dependency.Activated = true; Database.AddTable("Injected", new Column("Id", DbType.Int32)); } + public override void Down() => Database.RemoveTable("Injected"); + } + [Test, Category("SQLite")] + public void DependencyInjectionResolvesConstructorAndOptions() + { + var services = new ServiceCollection(); var dependency = new Dependency(); + services.AddSingleton(dependency); + services.AddMigrator(_ => ProviderFactory.Create(ProviderTypes.SQLite, "Data Source=:memory:", null, "tooling-spec"), typeof(ToolingTests).Assembly, + options => options.TransactionMode = MigrationTransactionMode.WholeSession); + using var container = services.BuildServiceProvider(); using var scope = container.CreateScope(); + var runner = scope.ServiceProvider.GetRequiredService(); + runner.MigrateToLastVersion(); + Assert.That(dependency.Activated, Is.True); + Assert.That(scope.ServiceProvider.GetRequiredService().TableExists("Injected"), Is.True); + } + [TestCase(new[] { "bad-command" }, 2)] + [TestCase(new[] { "--help" }, 0)] + [TestCase(new[] { "rollback", "--provider", "SQLite" }, 2)] + public void CliReturnsMeaningfulArgumentExitCodes(string[] args, int exit) + { + using var output = new StringWriter(); using var error = new StringWriter(); + Assert.That(MigratorCommand.Run(args, output, error), Is.EqualTo(exit)); + } + [Migration(900002, Scope = "cli-spec")] + public class CliMigration : DotNetProjects.Migrator.Framework.Fluent.AutoReversingMigration + { + public override void BuildUp(DotNetProjects.Migrator.Framework.Fluent.MigrationBuilder migration) + => migration.Create.Table("CliExample").WithColumn("Id").AsInt32(); + } + [Test, Category("SQLite")] + public void CliMigratesReadsStatusAndRollsBackWithPackagedDriver() + { + var file = Path.Combine(Path.GetTempPath(), "migrator-cli-" + Guid.NewGuid().ToString("N") + ".db"); + var environmentName = "MIGRATOR_TEST_" + Guid.NewGuid().ToString("N"); + Environment.SetEnvironmentVariable(environmentName, "Data Source=" + file + ";Pooling=False"); + try + { + foreach (var command in new[] { "migrate", "status", "rollback" }) + { + using var output = new StringWriter(); using var error = new StringWriter(); + var args = new System.Collections.Generic.List { command, "--assembly", typeof(ToolingTests).Assembly.Location, "--provider", "SQLite", "--scope", "cli-spec", "--connection-env", environmentName }; + if (command == "rollback") args.AddRange(new[] { "--target", "0" }); + Assert.That(MigratorCommand.Run(args.ToArray(), output, error), Is.Zero, error.ToString()); + if (command == "status") Assert.That(output.ToString(), Does.Contain("900002 applied")); + } + using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=" + file + ";Pooling=False"); connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null, "cli-spec"); + Assert.That(provider.TableExists("CliExample"), Is.False); + Assert.That(((IMigrationHistory)provider).ReadAppliedMigrations(), Is.Empty); + } + finally { Environment.SetEnvironmentVariable(environmentName, null); File.Delete(file); } + } + [Test, Category("SQLite")] + public void RollbackCommandRejectsAnUpwardTargetWithoutCreatingUserTables() + { + var file = Path.Combine(Path.GetTempPath(), "migrator-rollback-" + Guid.NewGuid().ToString("N") + ".db"); + var variable = "MIGRATOR_TEST_" + Guid.NewGuid().ToString("N"); + Environment.SetEnvironmentVariable(variable, "Data Source=" + file + ";Pooling=False"); + try + { + using var output = new StringWriter(); using var error = new StringWriter(); + Assert.That(MigratorCommand.Run(new[] { "rollback", "--assembly", typeof(ToolingTests).Assembly.Location, + "--provider", "SQLite", "--scope", "cli-spec", "--connection-env", variable, + "--target", "900002" }, output, error), Is.EqualTo(1)); + using var connection = new Microsoft.Data.Sqlite.SqliteConnection("Data Source=" + file + ";Pooling=False"); connection.Open(); + using var provider = ProviderFactory.Create(ProviderTypes.SQLite, connection, null, "cli-spec"); + Assert.That(provider.TableExists("CliExample"), Is.False); + Assert.That(((IMigrationHistory)provider).ReadAppliedMigrations(), Is.Empty); + } + finally { Environment.SetEnvironmentVariable(variable, null); File.Delete(file); } + } + [Migration(900003, Scope = "cli-errors")] + public class FailingCliMigration : Migration + { + internal static int Kind; + public override void Up() => throw Kind switch + { + 1 => new ArgumentException("SECRET_VALUE"), + 2 => new TimeoutException("SECRET_VALUE"), + _ => new NotSupportedException("SECRET_VALUE") + }; + public override void Down() => throw new NotSupportedException(); + } + [TestCase(1), TestCase(2), TestCase(3), Category("SQLite"), NonParallelizable] + public void CliClassifiesMigrationBodyExceptionsAsExecutionFailure(int kind) + { + var environmentName = "MIGRATOR_TEST_" + Guid.NewGuid().ToString("N"); + Environment.SetEnvironmentVariable(environmentName, "Data Source=:memory:"); + FailingCliMigration.Kind = kind; + try + { + using var output = new StringWriter(); using var error = new StringWriter(); + var exit = MigratorCommand.Run(new[] { "migrate", "--assembly", typeof(ToolingTests).Assembly.Location, "--provider", "SQLite", "--scope", "cli-errors", "--connection-env", environmentName }, output, error); + Assert.That(exit, Is.EqualTo(1)); + Assert.That(error.ToString(), Does.Not.Contain("SECRET_VALUE")); + } + finally { Environment.SetEnvironmentVariable(environmentName, null); } + } + [Test] public void LoggingAdapterOmitsProviderMessagesAndDoesNotFormatSqlBraces() + { + var sink = NSubstitute.Substitute.For(); + var logger = new MigrationLogger(sink); + Assert.DoesNotThrow(() => logger.Log("SECRET_VALUE {")); + logger.Warn("SECRET_VALUE"); logger.Trace("SECRET_VALUE"); logger.ApplyingDBChange("SECRET_VALUE"); + logger.Exception("SECRET_VALUE", new Exception("SECRET_VALUE")); + foreach (var call in sink.ReceivedCalls().Where(c => c.GetMethodInfo().Name == "Log")) + Assert.That(call.GetArguments()[2].ToString(), Does.Not.Contain("SECRET_VALUE")); + } + [Test] public void CliCanListWithoutOpeningDatabase() + { + using var output = new StringWriter(); using var error = new StringWriter(); + var exit = MigratorCommand.Run(new[] { "list", "--assembly", typeof(ToolingTests).Assembly.Location, "--provider", "SQLite", "--scope", "tooling-spec" }, output, error); + Assert.That(exit, Is.Zero, error.ToString()); + Assert.That(output.ToString(), Does.Contain("900001")); + } +} diff --git a/src/Migrator.Tool/DotNetProjects.Migrator.Tool.csproj b/src/Migrator.Tool/DotNetProjects.Migrator.Tool.csproj new file mode 100644 index 00000000..0a0f2501 --- /dev/null +++ b/src/Migrator.Tool/DotNetProjects.Migrator.Tool.csproj @@ -0,0 +1,12 @@ + + Exenet9.0enabletruemigratorDotNetProjects.Migrator.Tool9.0.0MPL-1.1 + + + + + + + + + + diff --git a/src/Migrator.Tool/Program.cs b/src/Migrator.Tool/Program.cs new file mode 100644 index 00000000..93701fe1 --- /dev/null +++ b/src/Migrator.Tool/Program.cs @@ -0,0 +1,127 @@ +using System.Reflection; +using System.Runtime.Loader; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Loggers; +using DotNetProjects.Migrator.Providers; + +return MigratorCommand.Run(args, Console.Out, Console.Error); + +public static class MigratorCommand +{ + public static int Run(string[] args, TextWriter output, TextWriter error) + { + try { return Execute(args, output); } + catch (CliUsageException ex) { error.WriteLine("Invalid arguments: " + ex.Option + ". Use --help."); return 2; } + catch (UnsupportedMigrationFeatureException) { error.WriteLine("The requested operation is unsupported by this provider or preview mode."); return 3; } + catch (MigrationLockTimeoutException) { error.WriteLine("Migration lock acquisition timed out."); return 4; } + catch (Exception ex) { error.WriteLine("Migration command failed (" + ex.GetType().Name + "). Exception details are omitted because they may contain credentials or SQL values."); return 1; } + } + private sealed class CliUsageException(string option) : Exception { public string Option { get; } = option; } + private static int Execute(string[] args, TextWriter output) + { + if (args.Length == 0 || args.Contains("--help")) + { + output.WriteLine("migrator --assembly PATH --provider NAME"); + output.WriteLine("--connection-env NAME (default MIGRATOR_CONNECTION), --scope NAME, --schema NAME, --target VERSION"); + output.WriteLine("--tags a,b --tag-match Any|All --profiles a,b --transaction PerMigration|None|WholeSession"); + output.WriteLine("--timeout SECONDS --lock --lock-timeout SECONDS --output PATH --offline --allow-legacy-preview"); + output.WriteLine("rollback requires --target. Offline SQL assumes empty history. Legacy preview executes trusted arbitrary C#."); + return 0; + } + var command = args[0]; + if (!new[] { "list", "status", "validate", "migrate", "rollback", "plan", "sql" }.Contains(command)) throw new CliUsageException("command"); + var values = new Dictionary(StringComparer.Ordinal); + var flags = new HashSet { "--lock", "--offline", "--allow-legacy-preview" }; + var allowed = new HashSet { "--assembly", "--provider", "--connection-env", "--scope", "--schema", "--target", "--tags", "--tag-match", "--profiles", "--transaction", "--timeout", "--lock-timeout", "--output" }; + for (var i = 1; i < args.Length; i++) + { + var key = args[i]; + if (values.ContainsKey(key)) throw new CliUsageException(key); + if (flags.Contains(key)) values.Add(key, "true"); + else if (allowed.Contains(key) && i + 1 < args.Length && !args[i + 1].StartsWith("--")) values.Add(key, args[++i]); + else throw new CliUsageException(key); + } + string Value(string key, string fallback = null) => values.GetValueOrDefault(key, fallback); + T EnumValue(string key, string fallback) where T : struct, Enum => Enum.TryParse(Value(key, fallback), true, out var result) && Enum.IsDefined(result) ? result : throw new CliUsageException(key); + var providerType = EnumValue("--provider", "none"); + if (providerType == ProviderTypes.none) throw new CliUsageException("--provider"); + var assemblyPath = Path.GetFullPath(Value("--assembly") ?? throw new CliUsageException("--assembly")); + var resolver = new AssemblyDependencyResolver(assemblyPath); + Assembly Resolving(AssemblyLoadContext context, AssemblyName name) + { + var path = resolver.ResolveAssemblyToPath(name); + return path == null ? null : context.LoadFromAssemblyPath(path); + } + AssemblyLoadContext.Default.Resolving += Resolving; + try + { + var assembly = AssemblyLoadContext.Default.LoadFromAssemblyPath(assemblyPath); + var scope = Value("--scope", "default"); + var types = MigrationLoader.GetMigrationTypes(assembly).Where(t => + (t.GetCustomAttribute()?.Scope ?? t.GetCustomAttribute()?.Scope ?? t.GetCustomAttribute()?.Scope) is not string ownScope || ownScope == scope).ToArray(); + var tags = Value("--tags", "").Split(',', StringSplitOptions.RemoveEmptyEntries); + var tagMatch = EnumValue("--tag-match", "Any"); + bool Selected(Type t) + { + var own = t.GetCustomAttribute()?.Tags ?? Array.Empty(); + return tags.Length == 0 || (tagMatch == TagMatchMode.All ? tags.All(own.Contains) : tags.Any(own.Contains)); + } + var versioned = types.Where(t => t.GetCustomAttribute() != null && Selected(t)).OrderBy(MigrationLoader.GetMigrationVersion).ToArray(); + var target = Value("--target") is { } targetString ? long.TryParse(targetString, out var parsed) && parsed >= 0 ? parsed : throw new CliUsageException("--target") : versioned.Select(MigrationLoader.GetMigrationVersion).DefaultIfEmpty(0).Max(); + if (command == "rollback" && !values.ContainsKey("--target")) throw new CliUsageException("--target"); + if (command == "list") + { + foreach (var type in versioned) output.WriteLine(MigrationLoader.GetMigrationVersion(type) + " " + type.FullName); + return 0; + } + if (values.ContainsKey("--offline")) + { + if (command != "sql" || values.ContainsKey("--profiles") || types.Any(t => t.GetCustomAttribute() != null)) throw new UnsupportedMigrationFeatureException("CLI operation is unsupported."); + var plan = MigrationPlanner.Create(versioned.Select(MigrationLoader.GetMigrationVersion), Array.Empty(), target); + var migrations = plan.Select(step => ((IMigration)Activator.CreateInstance(versioned.Single(t => MigrationLoader.GetMigrationVersion(t) == step.Version)), step.IsUp)); + Write(MigrationSqlPreview.Generate(providerType, migrations, values.ContainsKey("--allow-legacy-preview"))); + return 0; + } + var connectionString = Environment.GetEnvironmentVariable(Value("--connection-env", "MIGRATOR_CONNECTION")) ?? throw new CliUsageException("--connection-env"); + var providerName = providerType switch + { + ProviderTypes.SQLite => "Microsoft.Data.Sqlite", ProviderTypes.SqlServer or ProviderTypes.SqlServer2005 => "Microsoft.Data.SqlClient", + ProviderTypes.PostgreSQL or ProviderTypes.PostgreSQL82 => "Npgsql", ProviderTypes.Mysql or ProviderTypes.MariaDB => "MySql.Data.MySqlClient", + ProviderTypes.Oracle => "Oracle.ManagedDataAccess.Client", ProviderTypes.Firebird => "FirebirdSql.Data.FirebirdClient", + _ => throw new UnsupportedMigrationFeatureException("CLI operation is unsupported.") + }; + System.Data.Common.DbProviderFactories.RegisterFactory(providerName, providerType switch + { + ProviderTypes.SQLite => Microsoft.Data.Sqlite.SqliteFactory.Instance, + ProviderTypes.SqlServer or ProviderTypes.SqlServer2005 => Microsoft.Data.SqlClient.SqlClientFactory.Instance, + ProviderTypes.PostgreSQL or ProviderTypes.PostgreSQL82 => Npgsql.NpgsqlFactory.Instance, + ProviderTypes.Mysql or ProviderTypes.MariaDB => MySql.Data.MySqlClient.MySqlClientFactory.Instance, + ProviderTypes.Oracle => Oracle.ManagedDataAccess.Client.OracleClientFactory.Instance, + ProviderTypes.Firebird => FirebirdSql.Data.FirebirdClient.FirebirdClientFactory.Instance, + _ => throw new UnsupportedMigrationFeatureException("CLI operation is unsupported.") + }); + using var provider = ProviderFactory.Create(providerType, connectionString, Value("--schema"), scope, providerName); + if (values.ContainsKey("--timeout")) provider.CommandTimeout = Seconds("--timeout", "30"); + var runner = new Migrator(provider, false, new Logger(false), types); + runner.Options.Tags.UnionWith(tags); runner.Options.TagMatch = tagMatch; + runner.Options.Profiles.UnionWith(Value("--profiles", "").Split(',', StringSplitOptions.RemoveEmptyEntries)); + runner.Options.TransactionMode = EnumValue("--transaction", "PerMigration"); + if (values.ContainsKey("--lock")) runner.Options.Lock = new DatabaseMigrationLock(); + runner.Options.LockTimeout = TimeSpan.FromSeconds(Seconds("--lock-timeout", "30")); + switch (command) + { + case "status": foreach (var applied in ((IMigrationHistory)provider).ReadAppliedMigrations()) output.WriteLine(applied + " applied"); break; + case "validate": _ = runner.Plan(target); output.WriteLine("Migration plan is valid."); break; + case "plan": foreach (var step in runner.Plan(target)) output.WriteLine(step.Version + (step.IsUp ? " up" : " down")); break; + case "sql": Write(runner.PreviewSql(target, providerType, values.ContainsKey("--allow-legacy-preview"))); break; + case "rollback": runner.RollbackTo(target); output.WriteLine("Rollback completed."); break; + default: runner.MigrateTo(target); output.WriteLine("Migration completed."); break; + } + return 0; + int Seconds(string key, string fallback) => int.TryParse(Value(key, fallback), out var seconds) && seconds >= 0 ? seconds : throw new CliUsageException(key); + void Write(string sql) { if (Value("--output") is { } path) File.WriteAllText(path, sql); else output.WriteLine(sql); } + } + finally { AssemblyLoadContext.Default.Resolving -= Resolving; } + } +} diff --git a/src/Migrator/DatabaseMigrationLock.cs b/src/Migrator/DatabaseMigrationLock.cs new file mode 100644 index 00000000..d6e35d90 --- /dev/null +++ b/src/Migrator/DatabaseMigrationLock.cs @@ -0,0 +1,76 @@ +using System; +using System.Buffers.Binary; +using System.Data; +using System.Diagnostics; +using System.Globalization; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers.Impl.Mysql; +using DotNetProjects.Migrator.Providers.Impl.PostgreSQL; +using DotNetProjects.Migrator.Providers.Impl.SqlServer; +namespace DotNetProjects.Migrator; + +/// Session-owned database locks for SQL Server, PostgreSQL and MySQL/MariaDB. +public sealed class DatabaseMigrationLock : IMigrationLock +{ + public IDisposable Acquire(ITransformationProvider provider, string scope, TimeSpan timeout) + { + if (timeout < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(timeout)); + var kind = provider.Dialect switch + { + SqlServerDialect => 0, PostgreSQLDialect => 1, MysqlDialect => 2, + _ => throw new UnsupportedMigrationFeatureException("Database migration locking is supported on SQL Server, PostgreSQL and MySQL/MariaDB.") + }; + var connection = provider.Connection; + if (connection.State != ConnectionState.Open) throw new MigrationException("Migration locking requires an open connection."); + var resource = "Migrator.NET:" + connection.Database + ":" + provider.SchemaInfoTable + ":" + scope; + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(resource)); + object key = kind == 1 ? BinaryPrimitives.ReadInt64BigEndian(hash) : Convert.ToHexString(hash); + var acquire = kind switch + { + 0 => "DECLARE @result int; EXEC @result=sys.sp_getapplock @Resource=@key, @LockMode='Exclusive', @LockOwner='Session', @LockTimeout=0; SELECT @result", + 1 => "SELECT pg_try_advisory_lock(@key)", + _ => "SELECT GET_LOCK(@key, 0)" + }; + var release = kind switch + { + 0 => "DECLARE @result int; EXEC @result=sys.sp_releaseapplock @Resource=@key, @LockOwner='Session'; SELECT @result", + 1 => "SELECT pg_advisory_unlock(@key)", + _ => "SELECT RELEASE_LOCK(@key)" + }; + var watch = Stopwatch.StartNew(); + while (true) + { + var value = Scalar(connection, acquire, key); + if (value == null || value == DBNull.Value) throw new MigrationException("Database lock acquisition returned no result."); + var code = Convert.ToInt32(value, CultureInfo.InvariantCulture); + if (kind == 0 ? code >= 0 : code == 1) return new Lease(connection, release, key, kind); + if (kind == 0 && code != -1) throw new MigrationException("Database lock acquisition failed with code " + code); + if (watch.Elapsed >= timeout) throw new TimeoutException("Timed out acquiring the migration lock."); + Thread.Sleep((int)Math.Min(50, Math.Max(1, (timeout - watch.Elapsed).TotalMilliseconds))); + } + } + private static object Scalar(IDbConnection connection, string sql, object key) + { + using var command = connection.CreateCommand(); + command.CommandText = sql; command.CommandTimeout = 30; + var parameter = command.CreateParameter(); parameter.ParameterName = "@key"; parameter.Value = key; + parameter.DbType = key is long ? DbType.Int64 : DbType.String; + command.Parameters.Add(parameter); + return command.ExecuteScalar(); + } + private sealed class Lease(IDbConnection connection, string release, object key, int kind) : IDisposable + { + private bool disposed; + public void Dispose() + { + if (disposed) return; + var value = Scalar(connection, release, key); + if (value == null || value == DBNull.Value || (kind == 0 ? Convert.ToInt32(value) < 0 : Convert.ToInt32(value) != 1)) + throw new MigrationException("The database did not confirm migration lock release."); + disposed = true; + } + } +} diff --git a/src/Migrator/MigrationExecution.cs b/src/Migrator/MigrationExecution.cs index eb7c148b..9a50aca3 100644 --- a/src/Migrator/MigrationExecution.cs +++ b/src/Migrator/MigrationExecution.cs @@ -7,29 +7,48 @@ namespace DotNetProjects.Migrator; internal static class MigrationExecution { - internal static void Execute(ITransformationProvider provider, IMigration migration, MigrationStep step, ILogger logger) + internal static void Execute(ITransformationProvider provider, IMigration migration, MigrationStep step, ILogger logger, + bool transaction = true, bool inSession = false, bool recordHistory = true, bool callbacks = true) { var concrete = provider as TransformationProvider; - if (concrete?.HasActiveTransaction == true) + try + { + void Body() + { + if (concrete != null) concrete.CurrentMigration = migration; + if (step.IsUp) { logger.MigrateUp(step.Version, migration.Name); migration.Up(); } + else { logger.MigrateDown(step.Version, migration.Name); migration.Down(); } + if (provider is SQLiteTransformationProvider sqlite && !sqlite.CheckForeignKeyIntegrity()) + throw new MigrationException("Migration would leave invalid SQLite foreign keys."); + if (recordHistory) + { + var scope = migration.GetType().GetCustomAttribute()?.Scope ?? (provider as IMigrationHistory)?.Scope; + if (step.IsUp) provider.MigrationApplied(step.Version, scope); + else provider.MigrationUnApplied(step.Version, scope); + } + } + if (inSession) Body(); else InTransaction(provider, transaction, Body); + } + catch (Exception ex) { logger.Exception(step.Version, migration.Name, ex); throw; } + finally { if (concrete != null) concrete.CurrentMigration = null; } + // Session callbacks are deferred until the outer transaction commits. + if (callbacks) After(provider, migration, step.IsUp); + } + + internal static void InTransaction(ITransformationProvider provider, bool transaction, Action body) + { + if ((provider as TransformationProvider)?.HasActiveTransaction == true) throw new MigrationException("The runner cannot take ownership of an existing provider transaction."); var sqlite = provider as SQLiteTransformationProvider; - var foreignKeys = sqlite?.IsPragmaForeignKeysOn() == true; + var foreignKeys = transaction && sqlite?.IsPragmaForeignKeysOn() == true; Exception failure = null; var began = false; try { if (foreignKeys) sqlite.SetPragmaForeignKeys(false); - provider.BeginTransaction(); - began = true; - if (concrete != null) concrete.CurrentMigration = migration; - if (step.IsUp) { logger.MigrateUp(step.Version, migration.Name); migration.Up(); } - else { logger.MigrateDown(step.Version, migration.Name); migration.Down(); } - if (sqlite != null && !sqlite.CheckForeignKeyIntegrity()) - throw new MigrationException("Migration would leave invalid SQLite foreign keys."); - if (step.IsUp) provider.MigrationApplied(step.Version, migration.GetType().GetCustomAttribute()?.Scope ?? (provider as IMigrationHistory)?.Scope); - else provider.MigrationUnApplied(step.Version, migration.GetType().GetCustomAttribute()?.Scope ?? (provider as IMigrationHistory)?.Scope); - provider.Commit(); - began = false; + if (transaction) { provider.BeginTransaction(); began = true; } + body(); + if (transaction) { provider.Commit(); began = false; } } catch (Exception ex) { @@ -39,21 +58,18 @@ internal static void Execute(ITransformationProvider provider, IMigration migrat try { provider.Rollback(); } catch (Exception rollback) { ex.Data["RollbackException"] = rollback; } } - logger.Exception(step.Version, migration.Name, ex); throw; } finally { - if (concrete != null) concrete.CurrentMigration = null; try { if (foreignKeys) sqlite.SetPragmaForeignKeys(true); } catch (Exception restore) { if (failure == null) throw; failure.Data["ConnectionRestoreException"] = restore; } + (provider as IMigrationHistory)?.InvalidateHistory(); } - // These callbacks intentionally run after commit; failure cannot be rolled back. - After(provider, migration, step.IsUp); } internal static void After(ITransformationProvider provider, IMigration migration, bool up) { diff --git a/src/Migrator/MigrationLoader.cs b/src/Migrator/MigrationLoader.cs index 4be11204..932164b3 100644 --- a/src/Migrator/MigrationLoader.cs +++ b/src/Migrator/MigrationLoader.cs @@ -26,7 +26,7 @@ public MigrationLoader(ITransformationProvider provider, Assembly migrationAssem provider.Logger.Trace("Loaded migrations:"); foreach (var t in _migrationsTypes) { - provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name)); + provider.Logger.Trace("{0} {1}", (t.GetCustomAttribute()?.Version.ToString() ?? "aux").PadLeft(5), StringUtils.ToHumanName(t.Name)); } } } @@ -41,7 +41,7 @@ public MigrationLoader(ITransformationProvider provider, bool trace, params Type provider.Logger.Trace("Loaded migrations:"); foreach (var t in _migrationsTypes) { - provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name)); + provider.Logger.Trace("{0} {1}", (t.GetCustomAttribute()?.Version.ToString() ?? "aux").PadLeft(5), StringUtils.ToHumanName(t.Name)); } } } @@ -70,9 +70,14 @@ public virtual long LastVersion } } + public Func Activator { get; set; } + public IEnumerable SelectedTypes => _migrationsTypes.Where(t => - _provider is not IMigrationHistory history || - t.GetCustomAttribute()?.Scope is not string scope || scope == history.Scope); + t.GetCustomAttribute() != null && InScope(t.GetCustomAttribute().Scope)); + + internal bool InScope(string scope) => scope == null || _provider is not IMigrationHistory history || scope == history.Scope; + internal IEnumerable AuxiliaryTypes => _migrationsTypes.Where(t => t.GetCustomAttribute() == null); + public virtual void AddMigrations(Assembly migrationAssembly) { @@ -112,26 +117,13 @@ public static List GetMigrationTypes(Assembly asm) var migrations = new List(); foreach (var t in asm.GetExportedTypes()) { - - -#if NETSTANDARD - var attrib = t.GetTypeInfo().GetCustomAttribute(); - if (attrib != null && typeof(IMigration).GetTypeInfo().IsAssignableFrom(t) && !attrib.Ignore) - { + if (t.IsAbstract || !typeof(IMigration).IsAssignableFrom(t)) continue; + var versioned = t.GetCustomAttribute(); + if (versioned != null ? !versioned.Ignore : + t.GetCustomAttribute() != null || t.GetCustomAttribute() != null) migrations.Add(t); - } -#else - var attrib = (MigrationAttribute)Attribute.GetCustomAttribute(t, typeof(MigrationAttribute)); - if (attrib != null && typeof(IMigration).IsAssignableFrom(t) && !attrib.Ignore) - { - migrations.Add(t); - } -#endif - - } - - migrations.Sort(new MigrationTypeComparer(true)); + migrations = migrations.OrderBy(t => t.GetCustomAttribute()?.Version ?? 0).ThenBy(t => t.FullName, StringComparer.Ordinal).ToList(); return migrations; } @@ -149,8 +141,7 @@ public static long GetMigrationVersion(Type t) public List GetAvailableMigrations() { - _migrationsTypes.Sort(new MigrationTypeComparer(true)); - return SelectedTypes.Select(GetMigrationVersion).ToList(); + return SelectedTypes.Select(GetMigrationVersion).OrderBy(v => v).ToList(); } public virtual IMigration GetMigration(long version) @@ -170,6 +161,6 @@ public virtual IMigration GetMigration(long version) public virtual IMigration CreateInstance(Type migrationType) { - return (IMigration)Activator.CreateInstance(migrationType); + return Activator != null ? Activator(migrationType) ?? throw new MigrationException("Migration activator returned null.") : (IMigration)System.Activator.CreateInstance(migrationType); } } diff --git a/src/Migrator/MigrationSqlPreview.cs b/src/Migrator/MigrationSqlPreview.cs new file mode 100644 index 00000000..46cfbc37 --- /dev/null +++ b/src/Migrator/MigrationSqlPreview.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Framework.Fluent; +using DotNetProjects.Migrator.Providers; +namespace DotNetProjects.Migrator; + +public static class MigrationSqlPreview +{ + /// Generates SQL without a database. C# authoring code still executes and must be trusted. + public static string Generate(ProviderTypes provider, IEnumerable<(IMigration Migration, bool Up)> migrations, + bool allowLegacyBodies = false, Func existingTables = null) + { + var context = new SqlGenerationContext(provider, existingTables); + var sql = new List(); + foreach (var (migration, up) in migrations) + { + var initialization = migration.GetType().GetInterfaceMap(typeof(IMigration)); + var initializeIndex = Array.FindIndex(initialization.InterfaceMethods, m => m.Name == nameof(IMigration.InitializeOnce)); + if (initialization.TargetMethods[initializeIndex].DeclaringType != typeof(Migration)) + throw new UnsupportedMigrationFeatureException("Preview rejects migrations with an InitializeOnce hook because executing initialization would violate read-only preview semantics."); + var original = migration.Database; + var proxy = DispatchProxy.Create(); + var recorder = (PreviewProvider)(object)proxy; + try + { + migration.Database = proxy; + IReadOnlyList operations; + if (migration is FluentMigration fluent) operations = fluent.GetOperations(up); + else + { + if (!allowLegacyBodies) throw new UnsupportedMigrationFeatureException("Imperative SQL preview requires explicit allowLegacyBodies opt-in. Arbitrary C# cannot be sandboxed."); + if (up) migration.Up(); else migration.Down(); + operations = recorder.Operations; + } + foreach (var operation in operations) + { + try { sql.Add(operation.ToSql(context)); } + catch (NotSupportedException ex) { throw new UnsupportedMigrationFeatureException("This operation cannot be previewed.", ex); } + } + } + finally { migration.Database = original; } + } + return string.Join(Environment.NewLine, sql.Where(s => !string.IsNullOrWhiteSpace(s))); + } + + // Every method is denied unless explicitly mapped to a captured operation. No connection is exposed. + public class PreviewProvider : DispatchProxy + { + internal readonly List Operations = new(); + protected override object Invoke(MethodInfo method, object[] args) + { + MigrationOperation operation = method.Name switch + { + "AddTable" when args.Length == 2 && args[1] is IDbField[] fields => new CreateTableOperation((string)args[0], null, fields.Select(Definitions.Copy).ToArray()), + "AddColumn" when args.Length == 2 && args[1] is Column column => new ColumnOperation((string)args[0], Definitions.CopyColumn(column)), + "RemoveTable" => new RemoveOperation(RemoveKind.Table, (string)args[0]), + "RenameTable" => new RenameOperation((string)args[0], (string)args[1]), + "RenameColumn" => new RenameOperation((string)args[0], (string)args[2], (string)args[1]), + "Insert" when args.Length == 3 && args[1] is string[] columns && args[2] is object[] values => new DataOperation(DataKind.Insert, (string)args[0], (string[])columns.Clone(), (object[])values.Clone()), + "ExecuteNonQuery" when args.Length == 1 => new SqlOperation((string)args[0]), + _ => throw new UnsupportedMigrationFeatureException("SQL preview blocks provider member " + method.Name + ". Use a structured operation or an explicit SQL script.") + }; + Operations.Add(operation); + return method.ReturnType == typeof(int) ? 0 : null; + } + } +} diff --git a/src/Migrator/Migrator.cs b/src/Migrator/Migrator.cs index f900dd5c..9d94f03c 100644 --- a/src/Migrator/Migrator.cs +++ b/src/Migrator/Migrator.cs @@ -26,6 +26,7 @@ namespace DotNetProjects.Migrator; /// public class Migrator { + public RunnerOptions Options { get; init; } = new(); private readonly MigrationLoader _migrationLoader; private readonly ITransformationProvider _provider; @@ -182,12 +183,14 @@ public long? LastAppliedMigrationVersion /// public void MigrateToLastVersion() { - if (_migrationLoader.GetAvailableMigrations().Count == 0) + var versions = SelectedMigrationTypes.Select(MigrationLoader.GetMigrationVersion).ToArray(); + if (versions.Length == 0 && Options.Profiles.Count == 0 && + !_migrationLoader.AuxiliaryTypes.Any(t => t.GetCustomAttribute() is { } a && _migrationLoader.InScope(a.Scope))) { Logger.Warn("No migrations found for the effective scope."); return; } - MigrateTo(_migrationLoader.LastVersion); + MigrateTo(versions.DefaultIfEmpty(0).Max(), false, versions.Length == 0); } /// @@ -201,43 +204,143 @@ public void MigrateToLastVersion() /// If dryrun is set, don't write any changes to the database. /// /// The version that must became the current one - public IReadOnlyList Plan(long version) + private IEnumerable SelectedMigrationTypes => _migrationLoader.SelectedTypes.Where(t => + { + if (Options.Tags.Count == 0) return true; + var tags = t.GetCustomAttribute()?.Tags ?? Array.Empty(); + return Options.TagMatch == TagMatchMode.All ? Options.Tags.All(tags.Contains) : Options.Tags.Any(tags.Contains); + }); + + private IReadOnlyList CreatePlan(IEnumerable applied, long version) { _migrationLoader.CheckForDuplicatedVersion(); + var selected = SelectedMigrationTypes.Select(MigrationLoader.GetMigrationVersion).ToHashSet(); + var known = _migrationLoader.GetAvailableMigrations().ToHashSet(); + // Filtered migrations stay applied; unknown history must still fail a downgrade. + return MigrationPlanner.Create(selected, applied.Where(v => selected.Contains(v) || !known.Contains(v)), version); + } + + public IReadOnlyList Plan(long version) + { if (_provider is not IMigrationHistory history) throw new NotSupportedException("Read-only planning requires IMigrationHistory on custom providers."); - return MigrationPlanner.Create(_migrationLoader.GetAvailableMigrations(), history.ReadAppliedMigrations(), version); + return CreatePlan(history.ReadAppliedMigrations(), version); } - public void MigrateTo(long version) + public string PreviewSql(long version, ProviderTypes provider, bool allowLegacyBodies = false) { - _migrationLoader.CheckForDuplicatedVersion(); - var history = DryRun - ? _provider is IMigrationHistory reader ? reader.ReadAppliedMigrations().ToList() - : throw new NotSupportedException("DryRun requires IMigrationHistory on custom providers.") - : new List(_provider.AppliedMigrations); - var plan = MigrationPlanner.Create(_migrationLoader.GetAvailableMigrations(), history, version); - Logger.Started(history, version); - var firstRun = true; + _migrationLoader.Activator = Options.Activator; + var plan = Plan(version); + var migrations = new List<(IMigration, bool)>(); + void AddMaintenance(MaintenanceStage stage) + { + foreach (var type in _migrationLoader.AuxiliaryTypes.Where(t => t.GetCustomAttribute() is { } a && a.Stage == stage && _migrationLoader.InScope(a.Scope)) + .OrderBy(t => t.GetCustomAttribute().Order).ThenBy(t => t.FullName, StringComparer.Ordinal)) + migrations.Add((_migrationLoader.CreateInstance(type), true)); + } + AddMaintenance(MaintenanceStage.BeforeRun); foreach (var step in plan) { - if (DryRun) + AddMaintenance(MaintenanceStage.BeforeMigration); + migrations.Add((_migrationLoader.GetMigration(step.Version), step.IsUp)); + AddMaintenance(MaintenanceStage.AfterMigration); + } + foreach (var name in Options.Profiles) + if (!_migrationLoader.AuxiliaryTypes.Any(t => t.GetCustomAttribute() is { } a && a.Name == name && _migrationLoader.InScope(a.Scope))) + throw new MigrationException("Unknown profile: " + name); + foreach (var type in _migrationLoader.AuxiliaryTypes.Where(t => t.GetCustomAttribute() is { } a && Options.Profiles.Contains(a.Name) && _migrationLoader.InScope(a.Scope)) + .OrderBy(t => t.GetCustomAttribute().Order).ThenBy(t => t.FullName, StringComparer.Ordinal)) + migrations.Add((_migrationLoader.CreateInstance(type), true)); + AddMaintenance(MaintenanceStage.AfterRun); + return MigrationSqlPreview.Generate(provider, migrations, allowLegacyBodies, + table => _provider.TableExists(table) ? _provider.GetColumns(table) : throw new MigrationException("Preview table does not exist: " + table)); + } + + public void MigrateTo(long version) => MigrateTo(version, false); + + /// Run only downward steps; validate the target after acquiring the configured lock. + public void RollbackTo(long version) => MigrateTo(version, true); + + private void MigrateTo(long version, bool downOnly, bool preserveVersion = false) + { + if (DryRun) + { + var preview = preserveVersion ? Array.Empty() : Plan(version); + if (downOnly && preview.Any(step => step.IsUp)) throw new MigrationException("Rollback cannot apply upward migrations."); + foreach (var step in preview) + if (step.IsUp) Logger.MigrateUp(step.Version, "Preview"); else Logger.MigrateDown(step.Version, "Preview"); + return; + } + if (Options.LockTimeout < TimeSpan.Zero) throw new ArgumentOutOfRangeException(nameof(Options.LockTimeout)); + var session = Options.TransactionMode == MigrationTransactionMode.WholeSession; + if (session && _provider.Dialect is not (Providers.Impl.SQLite.SQLiteDialect or Providers.Impl.PostgreSQL.PostgreSQLDialect or Providers.Impl.SqlServer.SqlServerDialect)) + throw new UnsupportedMigrationFeatureException("Whole-session transactions require a verified transactional DDL provider (SQLite, PostgreSQL or SQL Server)."); + _migrationLoader.Activator = Options.Activator; + IDisposable AcquireLock() + { + try { return Options.Lock?.Acquire(_provider, (_provider as IMigrationHistory)?.Scope, Options.LockTimeout); } + catch (TimeoutException ex) { throw new MigrationLockTimeoutException(ex); } + } + var lease = AcquireLock(); + Exception failure = null; + try + { + (_provider as IMigrationHistory)?.InvalidateHistory(); + var history = new List(_provider.AppliedMigrations); + var initialHistory = new List(history); + if (preserveVersion) version = history.DefaultIfEmpty(0).Max(); + var plan = CreatePlan(history, version); + if (downOnly && (version >= history.DefaultIfEmpty(0).Max() || plan.Any(step => step.IsUp))) + throw new MigrationException("Rollback requires a lower target and cannot apply upward migrations."); + var profiles = _migrationLoader.AuxiliaryTypes.Where(t => t.GetCustomAttribute() is { } p && Options.Profiles.Contains(p.Name) && _migrationLoader.InScope(p.Scope)) + .OrderBy(t => t.GetCustomAttribute().Order).ThenBy(t => t.FullName, StringComparer.Ordinal).ToArray(); + foreach (var name in Options.Profiles) + if (!profiles.Any(t => t.GetCustomAttribute().Name == name)) throw new MigrationException("Unknown profile: " + name); + var afterCommit = new List(); + var firstRun = true; + void Execute(IMigration migration, MigrationStep step, bool record) { - if (step.IsUp) Logger.MigrateUp(step.Version, "Preview"); - else Logger.MigrateDown(step.Version, "Preview"); - continue; + migration.Database = _provider; + if (firstRun) { migration.InitializeOnce(_args); firstRun = false; } + MigrationExecution.Execute(_provider, migration, step, Logger, + Options.TransactionMode == MigrationTransactionMode.PerMigration, session, record, !session); + if (session) afterCommit.Add(() => MigrationExecution.After(_provider, migration, step.IsUp)); } - var migration = _migrationLoader.GetMigration(step.Version); - if (firstRun) + void Maintenance(MaintenanceStage stage) { - migration.InitializeOnce(_args); - firstRun = false; + foreach (var type in _migrationLoader.AuxiliaryTypes.Where(t => t.GetCustomAttribute() is { } a && a.Stage == stage && _migrationLoader.InScope(a.Scope)) + .OrderBy(t => t.GetCustomAttribute().Order).ThenBy(t => t.FullName, StringComparer.Ordinal)) + Execute(_migrationLoader.CreateInstance(type), new MigrationStep(0, true), false); + } + void Run() + { + Maintenance(MaintenanceStage.BeforeRun); + foreach (var step in plan) + { + Maintenance(MaintenanceStage.BeforeMigration); + Execute(_migrationLoader.GetMigration(step.Version), step, true); + if (step.IsUp) history.Add(step.Version); else history.Remove(step.Version); + Maintenance(MaintenanceStage.AfterMigration); + } + foreach (var type in profiles) Execute(_migrationLoader.CreateInstance(type), new MigrationStep(0, true), false); + Maintenance(MaintenanceStage.AfterRun); + } + Logger.Started(new List(initialHistory), version); + if (session) MigrationExecution.InTransaction(_provider, true, Run); else Run(); + foreach (var callback in afterCommit) callback(); + history.Sort(); + Logger.Finished(new List(initialHistory), version); + } + catch (Exception ex) { failure = ex; throw; } + finally + { + try { lease?.Dispose(); } + catch (Exception release) + { + if (failure == null) throw; + failure.Data["LockReleaseException"] = release; } - MigrationExecution.Execute(_provider, migration, step, Logger); - if (step.IsUp) history.Add(step.Version); - else history.Remove(step.Version); } - history.Sort(); - Logger.Finished(history, version); } } + diff --git a/src/Migrator/RunnerOptions.cs b/src/Migrator/RunnerOptions.cs new file mode 100644 index 00000000..0666f37d --- /dev/null +++ b/src/Migrator/RunnerOptions.cs @@ -0,0 +1,44 @@ +using System; +using System.Collections.Generic; +using DotNetProjects.Migrator.Framework; +namespace DotNetProjects.Migrator; + +public enum TagMatchMode { Any, All } +public enum MigrationTransactionMode { PerMigration, None, WholeSession } +public enum MaintenanceStage { BeforeRun, BeforeMigration, AfterMigration, AfterRun } + +[AttributeUsage(AttributeTargets.Class, Inherited = true)] +public sealed class TagsAttribute(params string[] tags) : Attribute +{ public IReadOnlyList Tags { get; } = Array.AsReadOnly((string[])tags.Clone()); } +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class ProfileAttribute(string name) : Attribute +{ public string Name { get; } = name; public int Order { get; set; } public string Scope { get; set; } } +[AttributeUsage(AttributeTargets.Class, Inherited = false)] +public sealed class MaintenanceAttribute(MaintenanceStage stage) : Attribute +{ public MaintenanceStage Stage { get; } = stage; public int Order { get; set; } public string Scope { get; set; } } + +public sealed class RunnerOptions +{ + public ISet Tags { get; } = new HashSet(StringComparer.Ordinal); + public TagMatchMode TagMatch { get; set; } = TagMatchMode.Any; + public ISet Profiles { get; } = new HashSet(StringComparer.Ordinal); + public MigrationTransactionMode TransactionMode { get; set; } = MigrationTransactionMode.PerMigration; + public Func Activator { get; set; } + public IMigrationLock Lock { get; set; } + public TimeSpan LockTimeout { get; set; } = TimeSpan.FromSeconds(30); +} + +/// Acquire before any history read. The lease must release its lock in Dispose. +public interface IMigrationLock +{ + IDisposable Acquire(ITransformationProvider provider, string scope, TimeSpan timeout); +} + +public sealed class UnsupportedMigrationFeatureException : NotSupportedException +{ + public UnsupportedMigrationFeatureException(string message, Exception inner = null) : base(message, inner) { } +} +public sealed class MigrationLockTimeoutException : TimeoutException +{ + public MigrationLockTimeoutException(Exception inner) : base("Timed out acquiring the migration lock.", inner) { } +}