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) { }
+}