From 281628047df688a5d46c223a2076f12bc89ef2f3 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 14:52:28 +0200 Subject: [PATCH 01/10] Add runner filtering, lifecycle stages and transaction modes Introduce additive runner options for tag any/all matching, ordered unversioned profiles, maintenance stages, activation and lock leases. Acquire locks before history refresh and keep version history isolated from auxiliary migrations. Preserve per-migration defaults; add no-transaction and verified-provider whole-session execution with callbacks deferred until commit. Validation: rebuilt solution; SQLite 156 tests and Unit 73 tests passed. Behavioral coverage checks rollback boundaries, post-commit ordering, profile history, tag matching and lock release. Native lock implementations and tooling follow separately. --- src/Migrator.Tests/RunnerFeatureTests.cs | 110 ++++++++ src/Migrator/MigrationExecution.cs | 64 +++-- src/Migrator/MigrationLoader.cs | 341 +++++++++++------------ src/Migrator/Migrator.cs | 95 +++++-- src/Migrator/RunnerOptions.cs | 35 +++ 5 files changed, 414 insertions(+), 231 deletions(-) create mode 100644 src/Migrator.Tests/RunnerFeatureTests.cs create mode 100644 src/Migrator/RunnerOptions.cs diff --git a/src/Migrator.Tests/RunnerFeatureTests.cs b/src/Migrator.Tests/RunnerFeatureTests.cs new file mode 100644 index 00000000..ddd3fb30 --- /dev/null +++ b/src/Migrator.Tests/RunnerFeatureTests.cs @@ -0,0 +1,110 @@ +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() => 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)); + } + [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); + } + 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/MigrationExecution.cs b/src/Migrator/MigrationExecution.cs index eb7c148b..58e6b1eb 100644 --- a/src/Migrator/MigrationExecution.cs +++ b/src/Migrator/MigrationExecution.cs @@ -7,29 +7,51 @@ 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(migration, step.IsUp); + } + + internal static void After(IMigration migration, bool up) + { if (up) migration.AfterUp(); else migration.AfterDown(); } + + 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,29 +61,17 @@ 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) - { - var concrete = provider as TransformationProvider; - var previous = concrete?.CurrentMigration; - if (concrete != null) concrete.CurrentMigration = migration; - try { if (up) migration.AfterUp(); else migration.AfterDown(); } - finally { if (concrete != null) concrete.CurrentMigration = previous; } } - } diff --git a/src/Migrator/MigrationLoader.cs b/src/Migrator/MigrationLoader.cs index 4be11204..70a220e7 100644 --- a/src/Migrator/MigrationLoader.cs +++ b/src/Migrator/MigrationLoader.cs @@ -1,175 +1,166 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Linq; -using DotNetProjects.Migrator.Framework; -using DotNetProjects.Migrator.Providers; - -namespace DotNetProjects.Migrator; - -/// -/// Handles inspecting code to find all of the Migrations in assemblies and reading -/// other metadata such as the last revision, etc. -/// -public class MigrationLoader -{ - private readonly List _migrationsTypes = new List(); - private readonly ITransformationProvider _provider; - - public MigrationLoader(ITransformationProvider provider, Assembly migrationAssembly, bool trace) - { - _provider = provider; - AddMigrations(migrationAssembly); - - if (trace) - { - provider.Logger.Trace("Loaded migrations:"); - foreach (var t in _migrationsTypes) - { - provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name)); - } - } - } - - public MigrationLoader(ITransformationProvider provider, bool trace, params Type[] migrationTypes) - { - _provider = provider; - _migrationsTypes.AddRange(migrationTypes); - - if (trace) - { - provider.Logger.Trace("Loaded migrations:"); - foreach (var t in _migrationsTypes) - { - provider.Logger.Trace("{0} {1}", GetMigrationVersion(t).ToString().PadLeft(5), StringUtils.ToHumanName(t.Name)); - } - } - } - - /// - /// Returns registered migration types. - /// - public virtual List MigrationsTypes - { - get { return _migrationsTypes; } - } - - /// - /// Returns the last version of the migrations. - /// - public virtual long LastVersion - { - get - { - if (_migrationsTypes.Count == 0) - { - return 0; - } - - return SelectedTypes.Select(GetMigrationVersion).DefaultIfEmpty(0).Max(); - } - } - - public IEnumerable SelectedTypes => _migrationsTypes.Where(t => - _provider is not IMigrationHistory history || - t.GetCustomAttribute()?.Scope is not string scope || scope == history.Scope); - - public virtual void AddMigrations(Assembly migrationAssembly) - { - if (migrationAssembly != null) - { - _migrationsTypes.AddRange(GetMigrationTypes(migrationAssembly)); - } - } - - /// - /// Check for duplicated version in migrations. - /// - /// CheckForDuplicatedVersion - public virtual void CheckForDuplicatedVersion() - { - var versions = new List(); - foreach (var t in SelectedTypes) - { - var version = GetMigrationVersion(t); - - if (versions.Contains(version)) - { - throw new DuplicatedVersionException(version); - } - - versions.Add(version); - } - } - - /// - /// Collect migrations in one Assembly. - /// - /// The Assembly to browse. - /// The migrations collection - 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) - { - 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)); - return migrations; - } - - /// - /// Returns the version of the migration - /// MigrationAttribute. - /// - /// Migration type. - /// Version number sepcified in the attribute - public static long GetMigrationVersion(Type t) - { - var attrib = (MigrationAttribute)Attribute.GetCustomAttribute(t, typeof(MigrationAttribute)); - return attrib?.Version ?? throw new ArgumentException($"{t.FullName} has no Migration attribute."); - } - - public List GetAvailableMigrations() - { - _migrationsTypes.Sort(new MigrationTypeComparer(true)); - return SelectedTypes.Select(GetMigrationVersion).ToList(); - } - - public virtual IMigration GetMigration(long version) - { - foreach (var t in SelectedTypes) - { - if (GetMigrationVersion(t) == version) - { - var migration = CreateInstance(t); - migration.Database = _provider; - return migration; - } - } - - return null; - } - - public virtual IMigration CreateInstance(Type migrationType) - { - return (IMigration)Activator.CreateInstance(migrationType); - } -} +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; + +namespace DotNetProjects.Migrator; + +/// +/// Handles inspecting code to find all of the Migrations in assemblies and reading +/// other metadata such as the last revision, etc. +/// +public class MigrationLoader +{ + private readonly List _migrationsTypes = new List(); + private readonly ITransformationProvider _provider; + + public MigrationLoader(ITransformationProvider provider, Assembly migrationAssembly, bool trace) + { + _provider = provider; + AddMigrations(migrationAssembly); + + if (trace) + { + provider.Logger.Trace("Loaded migrations:"); + foreach (var t in _migrationsTypes) + { + provider.Logger.Trace("{0} {1}", (t.GetCustomAttribute()?.Version.ToString() ?? "aux").PadLeft(5), StringUtils.ToHumanName(t.Name)); + } + } + } + + public MigrationLoader(ITransformationProvider provider, bool trace, params Type[] migrationTypes) + { + _provider = provider; + _migrationsTypes.AddRange(migrationTypes); + + if (trace) + { + provider.Logger.Trace("Loaded migrations:"); + foreach (var t in _migrationsTypes) + { + provider.Logger.Trace("{0} {1}", (t.GetCustomAttribute()?.Version.ToString() ?? "aux").PadLeft(5), StringUtils.ToHumanName(t.Name)); + } + } + } + + /// + /// Returns registered migration types. + /// + public virtual List MigrationsTypes + { + get { return _migrationsTypes; } + } + + /// + /// Returns the last version of the migrations. + /// + public virtual long LastVersion + { + get + { + if (_migrationsTypes.Count == 0) + { + return 0; + } + + return SelectedTypes.Select(GetMigrationVersion).DefaultIfEmpty(0).Max(); + } + } + + public Func Activator { get; set; } + + public IEnumerable SelectedTypes => _migrationsTypes.Where(t => + 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) + { + if (migrationAssembly != null) + { + _migrationsTypes.AddRange(GetMigrationTypes(migrationAssembly)); + } + } + + /// + /// Check for duplicated version in migrations. + /// + /// CheckForDuplicatedVersion + public virtual void CheckForDuplicatedVersion() + { + var versions = new List(); + foreach (var t in SelectedTypes) + { + var version = GetMigrationVersion(t); + + if (versions.Contains(version)) + { + throw new DuplicatedVersionException(version); + } + + versions.Add(version); + } + } + + /// + /// Collect migrations in one Assembly. + /// + /// The Assembly to browse. + /// The migrations collection + public static List GetMigrationTypes(Assembly asm) + { + var migrations = new List(); + foreach (var t in asm.GetExportedTypes()) + { + 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); + } + migrations = migrations.OrderBy(t => t.GetCustomAttribute()?.Version ?? 0).ThenBy(t => t.FullName, StringComparer.Ordinal).ToList(); + return migrations; + } + + /// + /// Returns the version of the migration + /// MigrationAttribute. + /// + /// Migration type. + /// Version number sepcified in the attribute + public static long GetMigrationVersion(Type t) + { + var attrib = (MigrationAttribute)Attribute.GetCustomAttribute(t, typeof(MigrationAttribute)); + return attrib?.Version ?? throw new ArgumentException($"{t.FullName} has no Migration attribute."); + } + + public List GetAvailableMigrations() + { + return SelectedTypes.Select(GetMigrationVersion).OrderBy(v => v).ToList(); + } + + public virtual IMigration GetMigration(long version) + { + foreach (var t in SelectedTypes) + { + if (GetMigrationVersion(t) == version) + { + var migration = CreateInstance(t); + migration.Database = _provider; + return migration; + } + } + + return null; + } + + public virtual IMigration CreateInstance(Type migrationType) + { + return Activator != null ? Activator(migrationType) ?? throw new MigrationException("Migration activator returned null.") : (IMigration)System.Activator.CreateInstance(migrationType); + } +} diff --git a/src/Migrator/Migrator.cs b/src/Migrator/Migrator.cs index f900dd5c..1a2fe0fb 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; } = new(); private readonly MigrationLoader _migrationLoader; private readonly ITransformationProvider _provider; @@ -182,12 +183,7 @@ public long? LastAppliedMigrationVersion /// public void MigrateToLastVersion() { - if (_migrationLoader.GetAvailableMigrations().Count == 0) - { - Logger.Warn("No migrations found for the effective scope."); - return; - } - MigrateTo(_migrationLoader.LastVersion); + MigrateTo(SelectedMigrationTypes.Select(MigrationLoader.GetMigrationVersion).DefaultIfEmpty(0).Max()); } /// @@ -201,43 +197,84 @@ 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) { - _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); + if (DryRun) + { + foreach (var step in Plan(version)) + 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 NotSupportedException("Whole-session transactions require a verified transactional DDL provider (SQLite, PostgreSQL or SQL Server)."); + _migrationLoader.Activator = Options.Activator; + using var lease = Options.Lock?.Acquire(_provider, (_provider as IMigrationHistory)?.Scope, Options.LockTimeout); + (_provider as IMigrationHistory)?.InvalidateHistory(); + var history = new List(_provider.AppliedMigrations); + var plan = CreatePlan(history, version); + 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; - foreach (var step in plan) + void Execute(IMigration migration, MigrationStep step, bool record) { - if (DryRun) - { - if (step.IsUp) Logger.MigrateUp(step.Version, "Preview"); - else Logger.MigrateDown(step.Version, "Preview"); - continue; - } - var migration = _migrationLoader.GetMigration(step.Version); - if (firstRun) + 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(migration, step.IsUp)); + } + void Maintenance(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)) + Execute(_migrationLoader.CreateInstance(type), new MigrationStep(0, true), false); + } + void Run() + { + Maintenance(MaintenanceStage.BeforeRun); + foreach (var step in plan) { - migration.InitializeOnce(_args); - firstRun = false; + 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); } - MigrationExecution.Execute(_provider, migration, step, Logger); - if (step.IsUp) history.Add(step.Version); - else history.Remove(step.Version); + foreach (var type in profiles) Execute(_migrationLoader.CreateInstance(type), new MigrationStep(0, true), false); + Maintenance(MaintenanceStage.AfterRun); } + Logger.Started(history, version); + if (session) MigrationExecution.InTransaction(_provider, true, Run); else Run(); + foreach (var callback in afterCommit) callback(); history.Sort(); Logger.Finished(history, version); } } + diff --git a/src/Migrator/RunnerOptions.cs b/src/Migrator/RunnerOptions.cs new file mode 100644 index 00000000..b473ea5a --- /dev/null +++ b/src/Migrator/RunnerOptions.cs @@ -0,0 +1,35 @@ +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); +} From 55b5811b4486a9f3efc33b85d5acb639b82ebd22 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:01:18 +0200 Subject: [PATCH 02/10] Add guarded SQL preview, native locks, CLI and DI integration Expose offline and connected SQL generation with opt-in legacy migration capture and explicit rejection of connection access. Add session-owned SQL Server, PostgreSQL and MySQL/MariaDB locks with timeout and release leases. Package a .NET tool for list, status, validation, migrate, rollback, plan and SQL output. Keep Microsoft dependency injection/options/logging dependencies in an optional package and omit sensitive exception/SQL details in tooling output. Validation: solution rebuilt; Unit 77 and SQLite 159 passed. Added DI activation and CLI argument/list checks, legacy preview opt-in/read-only checks, and live independent-session locking tests for the database CI matrix. --- Migrator.slnx | 8 ++ ...ator.Extensions.DependencyInjection.csproj | 9 ++ .../MigrationLogger.cs | 22 ++++ .../ServiceCollectionExtensions.cs | 31 +++++ src/Migrator.Tests/DatabaseLockTests.cs | 44 +++++++ src/Migrator.Tests/Migrator.Tests.csproj | 2 + src/Migrator.Tests/RunnerFeatureTests.cs | 26 ++++ src/Migrator.Tests/ToolingTests.cs | 49 ++++++++ .../DotNetProjects.Migrator.Tool.csproj | 12 ++ src/Migrator.Tool/Program.cs | 115 ++++++++++++++++++ src/Migrator/DatabaseMigrationLock.cs | 76 ++++++++++++ src/Migrator/MigrationSqlPreview.cs | 62 ++++++++++ src/Migrator/Migrator.cs | 31 ++++- 13 files changed, 486 insertions(+), 1 deletion(-) create mode 100644 src/Migrator.Extensions.DependencyInjection/DotNetProjects.Migrator.Extensions.DependencyInjection.csproj create mode 100644 src/Migrator.Extensions.DependencyInjection/MigrationLogger.cs create mode 100644 src/Migrator.Extensions.DependencyInjection/ServiceCollectionExtensions.cs create mode 100644 src/Migrator.Tests/DatabaseLockTests.cs create mode 100644 src/Migrator.Tests/ToolingTests.cs create mode 100644 src/Migrator.Tool/DotNetProjects.Migrator.Tool.csproj create mode 100644 src/Migrator.Tool/Program.cs create mode 100644 src/Migrator/DatabaseMigrationLock.cs create mode 100644 src/Migrator/MigrationSqlPreview.cs 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..df2c56d4 --- /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("{Message}", string.Format(CultureInfo.InvariantCulture, format, args)); + public void Warn(string format, params object[] args) => logger.LogWarning("{Message}", string.Format(CultureInfo.InvariantCulture, format, args)); + 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..834761a9 --- /dev/null +++ b/src/Migrator.Tests/DatabaseLockTests.cs @@ -0,0 +1,44 @@ +using System; +using System.Data.Common; +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; + } + [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..c6c1926f 100644 --- a/src/Migrator.Tests/Migrator.Tests.csproj +++ b/src/Migrator.Tests/Migrator.Tests.csproj @@ -45,6 +45,8 @@ + + diff --git a/src/Migrator.Tests/RunnerFeatureTests.cs b/src/Migrator.Tests/RunnerFeatureTests.cs index ddd3fb30..6f165835 100644 --- a/src/Migrator.Tests/RunnerFeatureTests.cs +++ b/src/Migrator.Tests/RunnerFeatureTests.cs @@ -100,6 +100,32 @@ [Test] public void LockPrecedesHistoryAndReleasesOnFailure() 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.Throws(() => 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.Throws(() => runner.PreviewSql(1, ProviderTypes.SQLite, true)); + runner.Options.Lock = new DatabaseMigrationLock(); + Assert.Throws(() => runner.MigrateTo(1)); + Assert.That(p.TableExists(p.SchemaInfoTable), Is.False); + } private sealed class ProbeLock : IMigrationLock, IDisposable { public bool Disposed { get; private set; } diff --git a/src/Migrator.Tests/ToolingTests.cs b/src/Migrator.Tests/ToolingTests.cs new file mode 100644 index 00000000..fe3a9190 --- /dev/null +++ b/src/Migrator.Tests/ToolingTests.cs @@ -0,0 +1,49 @@ +using System; +using System.Data; +using System.IO; +using DotNetProjects.Migrator; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Extensions.DependencyInjection; +using DotNetProjects.Migrator.Providers; +using Microsoft.Extensions.DependencyInjection; +using NUnit.Framework; +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)); + } + [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..540ace85 --- /dev/null +++ b/src/Migrator.Tool/Program.cs @@ -0,0 +1,115 @@ +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 (ArgumentException ex) { error.WriteLine("Invalid arguments: " + ex.ParamName + ". Use --help."); return 2; } + catch (NotSupportedException) { error.WriteLine("The requested operation is unsupported by this provider or preview mode."); return 3; } + catch (TimeoutException) { 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 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 ArgumentException(null, "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 ArgumentException(null, 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 ArgumentException(null, 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 ArgumentException(null, key); + var providerType = EnumValue("--provider", "none"); + if (providerType == ProviderTypes.none) throw new ArgumentException(null, "--provider"); + var assemblyPath = Path.GetFullPath(Value("--assembly") ?? throw new ArgumentException(null, "--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 ArgumentException(null, "--target") : versioned.Select(MigrationLoader.GetMigrationVersion).DefaultIfEmpty(0).Max(); + if (command == "rollback" && !values.ContainsKey("--target")) throw new ArgumentException(null, "--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 NotSupportedException(); + 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 ArgumentException(null, "--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 NotSupportedException() + }; + 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; + 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 ArgumentException(null, 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..d2ae7bd3 --- /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 NotSupportedException("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/MigrationSqlPreview.cs b/src/Migrator/MigrationSqlPreview.cs new file mode 100644 index 00000000..cf32793b --- /dev/null +++ b/src/Migrator/MigrationSqlPreview.cs @@ -0,0 +1,62 @@ +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 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 NotSupportedException("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) sql.Add(operation.ToSql(context)); + } + 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 NotSupportedException("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 1a2fe0fb..1c52760a 100644 --- a/src/Migrator/Migrator.cs +++ b/src/Migrator/Migrator.cs @@ -26,7 +26,7 @@ namespace DotNetProjects.Migrator; /// public class Migrator { - public RunnerOptions Options { get; } = new(); + public RunnerOptions Options { get; init; } = new(); private readonly MigrationLoader _migrationLoader; private readonly ITransformationProvider _provider; @@ -220,6 +220,35 @@ public IReadOnlyList Plan(long version) return CreatePlan(history.ReadAppliedMigrations(), version); } + public string PreviewSql(long version, ProviderTypes provider, bool allowLegacyBodies = false) + { + _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) + { + 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) { if (DryRun) From 9730079a2d4c9a6117022b98ab2004b1313ea532 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:14:28 +0200 Subject: [PATCH 03/10] Register packaged CLI database factories before connecting A locally installed CLI could list and generate offline SQL but failed to connect because the core factory fallback selected its historical driver assembly. Register each bundled driver's factory explicitly before creating a provider. Validation: rebuilt solution; connected SQLite CLI migrate/status/rollback smoke passed. Added a CLI regression with a disposable file database that verifies version history and final table removal; Unit 83 and SQLite 161 passed. --- src/Migrator.Tests/ToolingTests.cs | 29 +++++++++++++++++++++++++++++ src/Migrator.Tool/Program.cs | 10 ++++++++++ 2 files changed, 39 insertions(+) diff --git a/src/Migrator.Tests/ToolingTests.cs b/src/Migrator.Tests/ToolingTests.cs index fe3a9190..27d1a6de 100644 --- a/src/Migrator.Tests/ToolingTests.cs +++ b/src/Migrator.Tests/ToolingTests.cs @@ -39,6 +39,35 @@ 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] public void CliCanListWithoutOpeningDatabase() { using var output = new StringWriter(); using var error = new StringWriter(); diff --git a/src/Migrator.Tool/Program.cs b/src/Migrator.Tool/Program.cs index 540ace85..2fad5d83 100644 --- a/src/Migrator.Tool/Program.cs +++ b/src/Migrator.Tool/Program.cs @@ -90,6 +90,16 @@ bool Selected(Type t) ProviderTypes.Oracle => "Oracle.ManagedDataAccess.Client", ProviderTypes.Firebird => "FirebirdSql.Data.FirebirdClient", _ => throw new NotSupportedException() }; + 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 NotSupportedException() + }); 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); From fedf79ff9c1326d2ad4ae08dda5bc71c14b82956 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:29:02 +0200 Subject: [PATCH 04/10] Harden tooling error boundaries, preview lifecycle and logging Omit free-form provider Log/Warn text so SQL and secrets cannot escape through the optional logging adapter. Distinguish CLI parser and known capability/lock errors from arbitrary migration-body failures. Reject InitializeOnce overrides in preview rather than executing initialization hooks. Pass independent initial-history snapshots to Started/Finished logging. Validation: rebuilt solution; Unit 86 and SQLite 167 passed. Added regressions for three migration exception types returning execution exit code 1, secret/brace logging, initialization-dependent preview rejection and stable lifecycle history arguments. Addresses all four initial PR 177 review findings. --- .../MigrationLogger.cs | 4 +- src/Migrator.Tests/RunnerFeatureTests.cs | 30 ++++++++++++-- src/Migrator.Tests/ToolingTests.cs | 39 +++++++++++++++++++ src/Migrator.Tool/Program.cs | 33 ++++++++-------- src/Migrator/DatabaseMigrationLock.cs | 2 +- src/Migrator/MigrationSqlPreview.cs | 14 +++++-- src/Migrator/Migrator.cs | 14 +++++-- src/Migrator/RunnerOptions.cs | 9 +++++ 8 files changed, 116 insertions(+), 29 deletions(-) diff --git a/src/Migrator.Extensions.DependencyInjection/MigrationLogger.cs b/src/Migrator.Extensions.DependencyInjection/MigrationLogger.cs index df2c56d4..cdc63c2d 100644 --- a/src/Migrator.Extensions.DependencyInjection/MigrationLogger.cs +++ b/src/Migrator.Extensions.DependencyInjection/MigrationLogger.cs @@ -16,7 +16,7 @@ public sealed class MigrationLogger(Microsoft.Extensions.Logging.ILogger logger) 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("{Message}", string.Format(CultureInfo.InvariantCulture, format, args)); - public void Warn(string format, params object[] args) => logger.LogWarning("{Message}", string.Format(CultureInfo.InvariantCulture, format, args)); + 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.Tests/RunnerFeatureTests.cs b/src/Migrator.Tests/RunnerFeatureTests.cs index 6f165835..d31c4620 100644 --- a/src/Migrator.Tests/RunnerFeatureTests.cs +++ b/src/Migrator.Tests/RunnerFeatureTests.cs @@ -104,7 +104,7 @@ [Test] public void LegacyPreviewRequiresOptInAndNeverCreatesHistory() { using var p = Provider(); var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(First)); - Assert.Throws(() => runner.PreviewSql(1, ProviderTypes.SQLite)); + 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")); @@ -121,11 +121,35 @@ [Test] public void LegacyPreviewRejectsDirectConnectionsAndUnsupportedLocks() { using var p = Provider(); var runner = new DotNetProjects.Migrator.Migrator(p, false, typeof(DirectConnection)); - Assert.Throws(() => runner.PreviewSql(1, ProviderTypes.SQLite, true)); + Assert.Catch(() => runner.PreviewSql(1, ProviderTypes.SQLite, true)); runner.Options.Lock = new DatabaseMigrationLock(); - Assert.Throws(() => runner.MigrateTo(1)); + 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 })); + } private sealed class ProbeLock : IMigrationLock, IDisposable { public bool Disposed { get; private set; } diff --git a/src/Migrator.Tests/ToolingTests.cs b/src/Migrator.Tests/ToolingTests.cs index 27d1a6de..b1da945b 100644 --- a/src/Migrator.Tests/ToolingTests.cs +++ b/src/Migrator.Tests/ToolingTests.cs @@ -1,12 +1,14 @@ 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 @@ -68,6 +70,43 @@ public void CliMigratesReadsStatusAndRollsBackWithPackagedDriver() } finally { Environment.SetEnvironmentVariable(environmentName, 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(); diff --git a/src/Migrator.Tool/Program.cs b/src/Migrator.Tool/Program.cs index 2fad5d83..a99c177a 100644 --- a/src/Migrator.Tool/Program.cs +++ b/src/Migrator.Tool/Program.cs @@ -12,11 +12,12 @@ public static class MigratorCommand public static int Run(string[] args, TextWriter output, TextWriter error) { try { return Execute(args, output); } - catch (ArgumentException ex) { error.WriteLine("Invalid arguments: " + ex.ParamName + ". Use --help."); return 2; } - catch (NotSupportedException) { error.WriteLine("The requested operation is unsupported by this provider or preview mode."); return 3; } - catch (TimeoutException) { error.WriteLine("Migration lock acquisition timed out."); return 4; } + 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")) @@ -29,23 +30,23 @@ private static int Execute(string[] args, TextWriter output) return 0; } var command = args[0]; - if (!new[] { "list", "status", "validate", "migrate", "rollback", "plan", "sql" }.Contains(command)) throw new ArgumentException(null, "command"); + 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 ArgumentException(null, key); + 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 ArgumentException(null, key); + 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 ArgumentException(null, key); + 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 ArgumentException(null, "--provider"); - var assemblyPath = Path.GetFullPath(Value("--assembly") ?? throw new ArgumentException(null, "--assembly")); + 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) { @@ -67,8 +68,8 @@ bool Selected(Type t) 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 ArgumentException(null, "--target") : versioned.Select(MigrationLoader.GetMigrationVersion).DefaultIfEmpty(0).Max(); - if (command == "rollback" && !values.ContainsKey("--target")) throw new ArgumentException(null, "--target"); + 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); @@ -76,19 +77,19 @@ bool Selected(Type t) } if (values.ContainsKey("--offline")) { - if (command != "sql" || values.ContainsKey("--profiles") || types.Any(t => t.GetCustomAttribute() != null)) throw new NotSupportedException(); + 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 ArgumentException(null, "--connection-env"); + 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 NotSupportedException() + _ => throw new UnsupportedMigrationFeatureException("CLI operation is unsupported.") }; System.Data.Common.DbProviderFactories.RegisterFactory(providerName, providerType switch { @@ -98,7 +99,7 @@ bool Selected(Type t) 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 NotSupportedException() + _ => 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"); @@ -117,7 +118,7 @@ bool Selected(Type t) 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 ArgumentException(null, key); + 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 index d2ae7bd3..d6e35d90 100644 --- a/src/Migrator/DatabaseMigrationLock.cs +++ b/src/Migrator/DatabaseMigrationLock.cs @@ -21,7 +21,7 @@ public IDisposable Acquire(ITransformationProvider provider, string scope, TimeS var kind = provider.Dialect switch { SqlServerDialect => 0, PostgreSQLDialect => 1, MysqlDialect => 2, - _ => throw new NotSupportedException("Database migration locking is supported on SQL Server, PostgreSQL and MySQL/MariaDB.") + _ => 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."); diff --git a/src/Migrator/MigrationSqlPreview.cs b/src/Migrator/MigrationSqlPreview.cs index cf32793b..46cfbc37 100644 --- a/src/Migrator/MigrationSqlPreview.cs +++ b/src/Migrator/MigrationSqlPreview.cs @@ -17,6 +17,10 @@ public static string Generate(ProviderTypes provider, IEnumerable<(IMigration Mi 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; @@ -27,11 +31,15 @@ public static string Generate(ProviderTypes provider, IEnumerable<(IMigration Mi if (migration is FluentMigration fluent) operations = fluent.GetOperations(up); else { - if (!allowLegacyBodies) throw new NotSupportedException("Imperative SQL preview requires explicit allowLegacyBodies opt-in. Arbitrary C# cannot be sandboxed."); + 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) sql.Add(operation.ToSql(context)); + 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; } } @@ -53,7 +61,7 @@ protected override object Invoke(MethodInfo method, object[] args) "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 NotSupportedException("SQL preview blocks provider member " + method.Name + ". Use a structured operation or an explicit SQL script.") + _ => 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 1c52760a..d0822786 100644 --- a/src/Migrator/Migrator.cs +++ b/src/Migrator/Migrator.cs @@ -260,11 +260,17 @@ public void MigrateTo(long version) 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 NotSupportedException("Whole-session transactions require a verified transactional DDL provider (SQLite, PostgreSQL or SQL Server)."); + throw new UnsupportedMigrationFeatureException("Whole-session transactions require a verified transactional DDL provider (SQLite, PostgreSQL or SQL Server)."); _migrationLoader.Activator = Options.Activator; - using var lease = Options.Lock?.Acquire(_provider, (_provider as IMigrationHistory)?.Scope, Options.LockTimeout); + IDisposable AcquireLock() + { + try { return Options.Lock?.Acquire(_provider, (_provider as IMigrationHistory)?.Scope, Options.LockTimeout); } + catch (TimeoutException ex) { throw new MigrationLockTimeoutException(ex); } + } + using var lease = AcquireLock(); (_provider as IMigrationHistory)?.InvalidateHistory(); var history = new List(_provider.AppliedMigrations); + var initialHistory = new List(history); var plan = CreatePlan(history, version); 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(); @@ -299,11 +305,11 @@ void Run() foreach (var type in profiles) Execute(_migrationLoader.CreateInstance(type), new MigrationStep(0, true), false); Maintenance(MaintenanceStage.AfterRun); } - Logger.Started(history, version); + 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(history, version); + Logger.Finished(new List(initialHistory), version); } } diff --git a/src/Migrator/RunnerOptions.cs b/src/Migrator/RunnerOptions.cs index b473ea5a..0666f37d 100644 --- a/src/Migrator/RunnerOptions.cs +++ b/src/Migrator/RunnerOptions.cs @@ -33,3 +33,12 @@ 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) { } +} From 90d81cae870358aa7fc285bda990f23a963b8bbf Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:39:46 +0200 Subject: [PATCH 05/10] Preserve migration failures when releasing a deployment lock fails Release the lock in a finally block and retain a secondary release exception in the original failure Data. A release failure after successful execution still propagates. Add a regression covering a failed migration and failed lease disposal together. Validation: solution build; Unit 89 passed; SQLite 172 passed. --- src/Migrator.Tests/RunnerFeatureTests.cs | 14 ++++ src/Migrator/Migrator.cs | 88 ++++++++++++++---------- 2 files changed, 65 insertions(+), 37 deletions(-) diff --git a/src/Migrator.Tests/RunnerFeatureTests.cs b/src/Migrator.Tests/RunnerFeatureTests.cs index d31c4620..cd7889b6 100644 --- a/src/Migrator.Tests/RunnerFeatureTests.cs +++ b/src/Migrator.Tests/RunnerFeatureTests.cs @@ -150,6 +150,20 @@ [Test] public void LifecycleLogArgumentsRemainInitialHistorySnapshots() 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; } diff --git a/src/Migrator/Migrator.cs b/src/Migrator/Migrator.cs index d0822786..620fdd18 100644 --- a/src/Migrator/Migrator.cs +++ b/src/Migrator/Migrator.cs @@ -267,49 +267,63 @@ IDisposable AcquireLock() try { return Options.Lock?.Acquire(_provider, (_provider as IMigrationHistory)?.Scope, Options.LockTimeout); } catch (TimeoutException ex) { throw new MigrationLockTimeoutException(ex); } } - using var lease = AcquireLock(); - (_provider as IMigrationHistory)?.InvalidateHistory(); - var history = new List(_provider.AppliedMigrations); - var initialHistory = new List(history); - var plan = CreatePlan(history, version); - 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) - { - 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(migration, step.IsUp)); - } - void Maintenance(MaintenanceStage stage) + var lease = AcquireLock(); + Exception failure = null; + try { - 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); + (_provider as IMigrationHistory)?.InvalidateHistory(); + var history = new List(_provider.AppliedMigrations); + var initialHistory = new List(history); + var plan = CreatePlan(history, version); + 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) + { + 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(migration, step.IsUp)); + } + void Maintenance(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)) + 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); } - void Run() + catch (Exception ex) { failure = ex; throw; } + finally { - Maintenance(MaintenanceStage.BeforeRun); - foreach (var step in plan) + try { lease?.Dispose(); } + catch (Exception release) { - 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); + if (failure == null) throw; + failure.Data["LockReleaseException"] = release; } - 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); } } From 3747c8d58a4dae2aed98dafeed1c8a4a4f5b6244 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:41:25 +0200 Subject: [PATCH 06/10] Make CLI rollback reject upward migration targets under the lock Add an additive RollbackTo entry point that validates the refreshed execution plan after acquiring the deployment lock. Route the CLI rollback command through it so an empty database and a higher target cannot execute Up migrations. Validation: solution build, Unit 89 passed, SQLite 173 passed; the new connected CLI regression verifies no user table or version is applied. Addresses review 4072146578. --- src/Migrator.Tests/ToolingTests.cs | 19 +++++++++++++++++++ src/Migrator.Tool/Program.cs | 1 + src/Migrator/Migrator.cs | 13 +++++++++++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/Migrator.Tests/ToolingTests.cs b/src/Migrator.Tests/ToolingTests.cs index b1da945b..7a81d65d 100644 --- a/src/Migrator.Tests/ToolingTests.cs +++ b/src/Migrator.Tests/ToolingTests.cs @@ -70,6 +70,25 @@ public void CliMigratesReadsStatusAndRollsBackWithPackagedDriver() } 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 { diff --git a/src/Migrator.Tool/Program.cs b/src/Migrator.Tool/Program.cs index a99c177a..93701fe1 100644 --- a/src/Migrator.Tool/Program.cs +++ b/src/Migrator.Tool/Program.cs @@ -115,6 +115,7 @@ bool Selected(Type t) 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; diff --git a/src/Migrator/Migrator.cs b/src/Migrator/Migrator.cs index 620fdd18..1c854002 100644 --- a/src/Migrator/Migrator.cs +++ b/src/Migrator/Migrator.cs @@ -249,11 +249,18 @@ void AddMaintenance(MaintenanceStage stage) table => _provider.TableExists(table) ? _provider.GetColumns(table) : throw new MigrationException("Preview table does not exist: " + table)); } - public void MigrateTo(long version) + 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) { if (DryRun) { - foreach (var step in Plan(version)) + var preview = 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; } @@ -275,6 +282,8 @@ IDisposable AcquireLock() var history = new List(_provider.AppliedMigrations); var initialHistory = new List(history); 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) From bde0a054188a167b5f07159b0d9cda09841a31f1 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 15:59:53 +0200 Subject: [PATCH 07/10] Replace stale matrix artifacts when failed CI jobs are rerun The documentation PR reproduced an Informix registry timeout followed by a successful retry, but coverage downloaded an empty startup-only artifact left by the first attempt. Enable upload-artifact overwrite for each uniquely named database suite so the gate consumes the current attempt's test results. Validation: inspected run 35735397572 attempt 2: all database jobs passed, while duplicate test-results-Informix artifacts (277 and 16046 bytes) caused the missing-suite failure. Fresh CI validates the complete artifact flow. --- .github/workflows/dotnetpull.yml | 169 ++++++++++++++++--------------- 1 file changed, 85 insertions(+), 84 deletions(-) diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml index 7dba5873..410f736a 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -1,84 +1,85 @@ -name: .NET Pull Request -on: - push: - branches: [master] - pull_request: - branches: [master, "codex/**"] - workflow_dispatch: -permissions: - contents: read -concurrency: - group: live-databases-${{ github.ref }} - cancel-in-progress: true -jobs: - test: - name: Test (${{ matrix.database }}) - runs-on: ubuntu-22.04 - timeout-minutes: 35 - strategy: - fail-fast: false - matrix: - database: [Unit, SQLite, SQLServer, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix, Sybase] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - name: Start database - shell: bash - run: | - mkdir -p TestResults - bash .github/scripts/start-database.sh "${{ matrix.database }}" 2>&1 | tee TestResults/startup.log - timeout-minutes: 15 - - name: Build - run: dotnet build Migrator.slnx -p:LiveDatabase=${{ matrix.database }} - - name: Configure native IBM drivers - if: matrix.database == 'Db2' || matrix.database == 'Informix' - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y libaio1 libxml2 unixodbc libncurses5 - output="$GITHUB_WORKSPACE/src/Migrator.Tests/bin/Debug/net9.0" - if [ "${{ matrix.database }}" = Db2 ]; then - echo "DB2_CLI_DRIVER_INSTALL_PATH=$output/clidriver" >> "$GITHUB_ENV" - echo "LD_LIBRARY_PATH=$output/clidriver/lib" >> "$GITHUB_ENV" - else - echo "DELIMIDENT=y" >> "$GITHUB_ENV" - echo "INFORMIXDIR=$output/native" >> "$GITHUB_ENV" - echo "LD_LIBRARY_PATH=$output/native/lib:$output/native/lib/cli:$output/native/lib/esql" >> "$GITHUB_ENV" - fi - - name: Test - shell: pwsh - run: ./.github/scripts/test.ps1 -Database ${{ matrix.database }} - - name: Collect database logs - if: always() - run: | - mkdir -p TestResults - if docker inspect migrator-db >/dev/null 2>&1; then - docker logs migrator-db > TestResults/database.log 2>&1 - docker inspect migrator-db > TestResults/container.json - fi - - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results-${{ matrix.database }} - path: TestResults/ - if-no-files-found: error - - name: Remove test container - if: always() - run: | - if docker inspect migrator-db >/dev/null 2>&1; then - docker rm -fv migrator-db - fi - coverage: - name: Verify complete test coverage - needs: test - runs-on: ubuntu-22.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - pattern: test-results-* - path: TestResults - - run: python3 .github/scripts/verify-test-coverage.py TestResults +name: .NET Pull Request +on: + push: + branches: [master] + pull_request: + branches: [master, "codex/**"] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: live-databases-${{ github.ref }} + cancel-in-progress: true +jobs: + test: + name: Test (${{ matrix.database }}) + runs-on: ubuntu-22.04 + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + database: [Unit, SQLite, SQLServer, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix, Sybase] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Start database + shell: bash + run: | + mkdir -p TestResults + bash .github/scripts/start-database.sh "${{ matrix.database }}" 2>&1 | tee TestResults/startup.log + timeout-minutes: 15 + - name: Build + run: dotnet build Migrator.slnx -p:LiveDatabase=${{ matrix.database }} + - name: Configure native IBM drivers + if: matrix.database == 'Db2' || matrix.database == 'Informix' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libaio1 libxml2 unixodbc libncurses5 + output="$GITHUB_WORKSPACE/src/Migrator.Tests/bin/Debug/net9.0" + if [ "${{ matrix.database }}" = Db2 ]; then + echo "DB2_CLI_DRIVER_INSTALL_PATH=$output/clidriver" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$output/clidriver/lib" >> "$GITHUB_ENV" + else + echo "DELIMIDENT=y" >> "$GITHUB_ENV" + echo "INFORMIXDIR=$output/native" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$output/native/lib:$output/native/lib/cli:$output/native/lib/esql" >> "$GITHUB_ENV" + fi + - name: Test + shell: pwsh + run: ./.github/scripts/test.ps1 -Database ${{ matrix.database }} + - name: Collect database logs + if: always() + run: | + mkdir -p TestResults + if docker inspect migrator-db >/dev/null 2>&1; then + docker logs migrator-db > TestResults/database.log 2>&1 + docker inspect migrator-db > TestResults/container.json + fi + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ matrix.database }} + overwrite: true + path: TestResults/ + if-no-files-found: error + - name: Remove test container + if: always() + run: | + if docker inspect migrator-db >/dev/null 2>&1; then + docker rm -fv migrator-db + fi + coverage: + name: Verify complete test coverage + needs: test + runs-on: ubuntu-22.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: test-results-* + path: TestResults + - run: python3 .github/scripts/verify-test-coverage.py TestResults From b4a6edda8f8119e2ece297394e6af378959a8e41 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 16:21:23 +0200 Subject: [PATCH 08/10] Exercise concurrent runners and stale history under native deployment locks Add a two-connection runner regression on SQL Server, PostgreSQL, MySQL and MariaDB. Seed the second runner with stale empty history, hold the first inside its migration, start the second acquisition, and verify only one Up executes after history reload. Confirm the final version and that the lock can be acquired again. Coordination uses events instead of timing sleeps. Validation: solution build and Unit 96 passed. The new concurrency cases require their four live provider CI jobs before claiming coverage. --- src/Migrator.Tests/DatabaseLockTests.cs | 61 +++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/src/Migrator.Tests/DatabaseLockTests.cs b/src/Migrator.Tests/DatabaseLockTests.cs index 834761a9..1d6018e0 100644 --- a/src/Migrator.Tests/DatabaseLockTests.cs +++ b/src/Migrator.Tests/DatabaseLockTests.cs @@ -1,5 +1,8 @@ using System; using System.Data.Common; +using System.Threading; +using System.Threading.Tasks; +using DotNetProjects.Migrator.Framework; using DotNetProjects.Migrator; using DotNetProjects.Migrator.Providers; using Migrator.Tests.Settings; @@ -28,6 +31,64 @@ private DbConnection Open() ?? "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 { if (one != null) await one; if (two != null) await two; } + finally { p1.RemoveTable(p1.SchemaInfoTable); } + } + } + [Test] public void IndependentSessionsContendAndCanAcquireAfterRelease() { using var connection1 = Open(); using var connection2 = Open(); From 5203ae28b847dac4cc3c4575293a187d7874d155 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 16:22:15 +0200 Subject: [PATCH 09/10] Await both concurrent runner tasks before test database cleanup Even when one worker faults, await the remaining worker before dropping the temporary history table or disposing its connection. This keeps the failure path of the concurrency regression deterministic. --- src/Migrator.Tests/DatabaseLockTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Migrator.Tests/DatabaseLockTests.cs b/src/Migrator.Tests/DatabaseLockTests.cs index 1d6018e0..5a72fc55 100644 --- a/src/Migrator.Tests/DatabaseLockTests.cs +++ b/src/Migrator.Tests/DatabaseLockTests.cs @@ -1,5 +1,6 @@ using System; using System.Data.Common; +using System.Linq; using System.Threading; using System.Threading.Tasks; using DotNetProjects.Migrator.Framework; @@ -84,7 +85,7 @@ public async Task ConcurrentRunnersReloadStaleHistoryAfterAcquiringNativeLock() finally { state.Release.Set(); - try { if (one != null) await one; if (two != null) await two; } + try { await Task.WhenAll(new[] { one, two }.Where(task => task != null)); } finally { p1.RemoveTable(p1.SchemaInfoTable); } } } From 9804fef63c95311992451fd3ffa6db74cda6e983 Mon Sep 17 00:00:00 2001 From: jogibear9988 Date: Tue, 22 Sep 2026 20:42:32 +0200 Subject: [PATCH 10/10] Preserve runner review fixes while aligning the PR stack with master Retain the already-validated callback CurrentMigration context, auxiliary-only history preservation and regression tests when replaying the tooling branch on the updated runner/provider bases. These fixes had equivalent patches earlier in the old stack; rebase patch deduplication otherwise omitted their final tooling integration. Preserve the original file encodings and matrix settings. The resulting source tree matches the previously tested bc35e0e exactly; only the comparison and homepage additions already merged on master are new here. --- .github/workflows/dotnetpull.yml | 170 ++++++------ src/Migrator.Tests/Migrator.Tests.csproj | 2 +- src/Migrator.Tests/RunnerFeatureTests.cs | 19 +- src/Migrator/MigrationExecution.cs | 14 +- src/Migrator/MigrationLoader.cs | 332 +++++++++++------------ src/Migrator/Migrator.cs | 16 +- 6 files changed, 292 insertions(+), 261 deletions(-) diff --git a/.github/workflows/dotnetpull.yml b/.github/workflows/dotnetpull.yml index 410f736a..52020f5b 100644 --- a/.github/workflows/dotnetpull.yml +++ b/.github/workflows/dotnetpull.yml @@ -1,85 +1,85 @@ -name: .NET Pull Request -on: - push: - branches: [master] - pull_request: - branches: [master, "codex/**"] - workflow_dispatch: -permissions: - contents: read -concurrency: - group: live-databases-${{ github.ref }} - cancel-in-progress: true -jobs: - test: - name: Test (${{ matrix.database }}) - runs-on: ubuntu-22.04 - timeout-minutes: 35 - strategy: - fail-fast: false - matrix: - database: [Unit, SQLite, SQLServer, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix, Sybase] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: 9.0.x - - name: Start database - shell: bash - run: | - mkdir -p TestResults - bash .github/scripts/start-database.sh "${{ matrix.database }}" 2>&1 | tee TestResults/startup.log - timeout-minutes: 15 - - name: Build - run: dotnet build Migrator.slnx -p:LiveDatabase=${{ matrix.database }} - - name: Configure native IBM drivers - if: matrix.database == 'Db2' || matrix.database == 'Informix' - shell: bash - run: | - sudo apt-get update - sudo apt-get install -y libaio1 libxml2 unixodbc libncurses5 - output="$GITHUB_WORKSPACE/src/Migrator.Tests/bin/Debug/net9.0" - if [ "${{ matrix.database }}" = Db2 ]; then - echo "DB2_CLI_DRIVER_INSTALL_PATH=$output/clidriver" >> "$GITHUB_ENV" - echo "LD_LIBRARY_PATH=$output/clidriver/lib" >> "$GITHUB_ENV" - else - echo "DELIMIDENT=y" >> "$GITHUB_ENV" - echo "INFORMIXDIR=$output/native" >> "$GITHUB_ENV" - echo "LD_LIBRARY_PATH=$output/native/lib:$output/native/lib/cli:$output/native/lib/esql" >> "$GITHUB_ENV" - fi - - name: Test - shell: pwsh - run: ./.github/scripts/test.ps1 -Database ${{ matrix.database }} - - name: Collect database logs - if: always() - run: | - mkdir -p TestResults - if docker inspect migrator-db >/dev/null 2>&1; then - docker logs migrator-db > TestResults/database.log 2>&1 - docker inspect migrator-db > TestResults/container.json - fi - - uses: actions/upload-artifact@v4 - if: always() - with: - name: test-results-${{ matrix.database }} - overwrite: true - path: TestResults/ - if-no-files-found: error - - name: Remove test container - if: always() - run: | - if docker inspect migrator-db >/dev/null 2>&1; then - docker rm -fv migrator-db - fi - coverage: - name: Verify complete test coverage - needs: test - runs-on: ubuntu-22.04 - timeout-minutes: 5 - steps: - - uses: actions/checkout@v4 - - uses: actions/download-artifact@v4 - with: - pattern: test-results-* - path: TestResults - - run: python3 .github/scripts/verify-test-coverage.py TestResults +name: .NET Pull Request +on: + push: + branches: [master] + pull_request: + branches: [master, "codex/**"] + workflow_dispatch: +permissions: + contents: read +concurrency: + group: live-databases-${{ github.ref }} + cancel-in-progress: true +jobs: + test: + name: Test (${{ matrix.database }}) + runs-on: ubuntu-22.04 + timeout-minutes: 35 + strategy: + fail-fast: false + matrix: + database: [Unit, SQLite, SQLServer, PostgreSQL, Oracle, MySQL, MariaDB, Firebird, Db2, Informix, Sybase] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.0.x + - name: Start database + shell: bash + run: | + mkdir -p TestResults + bash .github/scripts/start-database.sh "${{ matrix.database }}" 2>&1 | tee TestResults/startup.log + timeout-minutes: 15 + - name: Build + run: dotnet build Migrator.slnx -p:LiveDatabase=${{ matrix.database }} + - name: Configure native IBM drivers + if: matrix.database == 'Db2' || matrix.database == 'Informix' + shell: bash + run: | + sudo apt-get update + sudo apt-get install -y libaio1 libxml2 unixodbc libncurses5 + output="$GITHUB_WORKSPACE/src/Migrator.Tests/bin/Debug/net9.0" + if [ "${{ matrix.database }}" = Db2 ]; then + echo "DB2_CLI_DRIVER_INSTALL_PATH=$output/clidriver" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$output/clidriver/lib" >> "$GITHUB_ENV" + else + echo "DELIMIDENT=y" >> "$GITHUB_ENV" + echo "INFORMIXDIR=$output/native" >> "$GITHUB_ENV" + echo "LD_LIBRARY_PATH=$output/native/lib:$output/native/lib/cli:$output/native/lib/esql" >> "$GITHUB_ENV" + fi + - name: Test + shell: pwsh + run: ./.github/scripts/test.ps1 -Database ${{ matrix.database }} + - name: Collect database logs + if: always() + run: | + mkdir -p TestResults + if docker inspect migrator-db >/dev/null 2>&1; then + docker logs migrator-db > TestResults/database.log 2>&1 + docker inspect migrator-db > TestResults/container.json + fi + - uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results-${{ matrix.database }} + overwrite: true + path: TestResults/ + if-no-files-found: error + - name: Remove test container + if: always() + run: | + if docker inspect migrator-db >/dev/null 2>&1; then + docker rm -fv migrator-db + fi + coverage: + name: Verify complete test coverage + needs: test + runs-on: ubuntu-22.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + pattern: test-results-* + path: TestResults + - run: python3 .github/scripts/verify-test-coverage.py TestResults diff --git a/src/Migrator.Tests/Migrator.Tests.csproj b/src/Migrator.Tests/Migrator.Tests.csproj index c6c1926f..061f4d0f 100644 --- a/src/Migrator.Tests/Migrator.Tests.csproj +++ b/src/Migrator.Tests/Migrator.Tests.csproj @@ -50,8 +50,8 @@ - + diff --git a/src/Migrator.Tests/RunnerFeatureTests.cs b/src/Migrator.Tests/RunnerFeatureTests.cs index cd7889b6..66bff1cd 100644 --- a/src/Migrator.Tests/RunnerFeatureTests.cs +++ b/src/Migrator.Tests/RunnerFeatureTests.cs @@ -17,7 +17,12 @@ 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() => Events.Add("committed"); + 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 @@ -61,6 +66,18 @@ [Test] public void ProfilesAndMaintenanceHaveDeterministicOrderAndNoHistory() 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) diff --git a/src/Migrator/MigrationExecution.cs b/src/Migrator/MigrationExecution.cs index 58e6b1eb..9a50aca3 100644 --- a/src/Migrator/MigrationExecution.cs +++ b/src/Migrator/MigrationExecution.cs @@ -32,12 +32,9 @@ void 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(migration, step.IsUp); + if (callbacks) After(provider, migration, step.IsUp); } - internal static void After(IMigration migration, bool up) - { if (up) migration.AfterUp(); else migration.AfterDown(); } - internal static void InTransaction(ITransformationProvider provider, bool transaction, Action body) { if ((provider as TransformationProvider)?.HasActiveTransaction == true) @@ -74,4 +71,13 @@ internal static void InTransaction(ITransformationProvider provider, bool transa (provider as IMigrationHistory)?.InvalidateHistory(); } } + internal static void After(ITransformationProvider provider, IMigration migration, bool up) + { + var concrete = provider as TransformationProvider; + var previous = concrete?.CurrentMigration; + if (concrete != null) concrete.CurrentMigration = migration; + try { if (up) migration.AfterUp(); else migration.AfterDown(); } + finally { if (concrete != null) concrete.CurrentMigration = previous; } + } + } diff --git a/src/Migrator/MigrationLoader.cs b/src/Migrator/MigrationLoader.cs index 70a220e7..932164b3 100644 --- a/src/Migrator/MigrationLoader.cs +++ b/src/Migrator/MigrationLoader.cs @@ -1,166 +1,166 @@ -using System; -using System.Collections.Generic; -using System.Reflection; -using System.Linq; -using DotNetProjects.Migrator.Framework; -using DotNetProjects.Migrator.Providers; - -namespace DotNetProjects.Migrator; - -/// -/// Handles inspecting code to find all of the Migrations in assemblies and reading -/// other metadata such as the last revision, etc. -/// -public class MigrationLoader -{ - private readonly List _migrationsTypes = new List(); - private readonly ITransformationProvider _provider; - - public MigrationLoader(ITransformationProvider provider, Assembly migrationAssembly, bool trace) - { - _provider = provider; - AddMigrations(migrationAssembly); - - if (trace) - { - provider.Logger.Trace("Loaded migrations:"); - foreach (var t in _migrationsTypes) - { - provider.Logger.Trace("{0} {1}", (t.GetCustomAttribute()?.Version.ToString() ?? "aux").PadLeft(5), StringUtils.ToHumanName(t.Name)); - } - } - } - - public MigrationLoader(ITransformationProvider provider, bool trace, params Type[] migrationTypes) - { - _provider = provider; - _migrationsTypes.AddRange(migrationTypes); - - if (trace) - { - provider.Logger.Trace("Loaded migrations:"); - foreach (var t in _migrationsTypes) - { - provider.Logger.Trace("{0} {1}", (t.GetCustomAttribute()?.Version.ToString() ?? "aux").PadLeft(5), StringUtils.ToHumanName(t.Name)); - } - } - } - - /// - /// Returns registered migration types. - /// - public virtual List MigrationsTypes - { - get { return _migrationsTypes; } - } - - /// - /// Returns the last version of the migrations. - /// - public virtual long LastVersion - { - get - { - if (_migrationsTypes.Count == 0) - { - return 0; - } - - return SelectedTypes.Select(GetMigrationVersion).DefaultIfEmpty(0).Max(); - } - } - - public Func Activator { get; set; } - - public IEnumerable SelectedTypes => _migrationsTypes.Where(t => - 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) - { - if (migrationAssembly != null) - { - _migrationsTypes.AddRange(GetMigrationTypes(migrationAssembly)); - } - } - - /// - /// Check for duplicated version in migrations. - /// - /// CheckForDuplicatedVersion - public virtual void CheckForDuplicatedVersion() - { - var versions = new List(); - foreach (var t in SelectedTypes) - { - var version = GetMigrationVersion(t); - - if (versions.Contains(version)) - { - throw new DuplicatedVersionException(version); - } - - versions.Add(version); - } - } - - /// - /// Collect migrations in one Assembly. - /// - /// The Assembly to browse. - /// The migrations collection - public static List GetMigrationTypes(Assembly asm) - { - var migrations = new List(); - foreach (var t in asm.GetExportedTypes()) - { - 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); - } - migrations = migrations.OrderBy(t => t.GetCustomAttribute()?.Version ?? 0).ThenBy(t => t.FullName, StringComparer.Ordinal).ToList(); - return migrations; - } - - /// - /// Returns the version of the migration - /// MigrationAttribute. - /// - /// Migration type. - /// Version number sepcified in the attribute - public static long GetMigrationVersion(Type t) - { - var attrib = (MigrationAttribute)Attribute.GetCustomAttribute(t, typeof(MigrationAttribute)); - return attrib?.Version ?? throw new ArgumentException($"{t.FullName} has no Migration attribute."); - } - - public List GetAvailableMigrations() - { - return SelectedTypes.Select(GetMigrationVersion).OrderBy(v => v).ToList(); - } - - public virtual IMigration GetMigration(long version) - { - foreach (var t in SelectedTypes) - { - if (GetMigrationVersion(t) == version) - { - var migration = CreateInstance(t); - migration.Database = _provider; - return migration; - } - } - - return null; - } - - public virtual IMigration CreateInstance(Type migrationType) - { - return Activator != null ? Activator(migrationType) ?? throw new MigrationException("Migration activator returned null.") : (IMigration)System.Activator.CreateInstance(migrationType); - } -} +using System; +using System.Collections.Generic; +using System.Reflection; +using System.Linq; +using DotNetProjects.Migrator.Framework; +using DotNetProjects.Migrator.Providers; + +namespace DotNetProjects.Migrator; + +/// +/// Handles inspecting code to find all of the Migrations in assemblies and reading +/// other metadata such as the last revision, etc. +/// +public class MigrationLoader +{ + private readonly List _migrationsTypes = new List(); + private readonly ITransformationProvider _provider; + + public MigrationLoader(ITransformationProvider provider, Assembly migrationAssembly, bool trace) + { + _provider = provider; + AddMigrations(migrationAssembly); + + if (trace) + { + provider.Logger.Trace("Loaded migrations:"); + foreach (var t in _migrationsTypes) + { + provider.Logger.Trace("{0} {1}", (t.GetCustomAttribute()?.Version.ToString() ?? "aux").PadLeft(5), StringUtils.ToHumanName(t.Name)); + } + } + } + + public MigrationLoader(ITransformationProvider provider, bool trace, params Type[] migrationTypes) + { + _provider = provider; + _migrationsTypes.AddRange(migrationTypes); + + if (trace) + { + provider.Logger.Trace("Loaded migrations:"); + foreach (var t in _migrationsTypes) + { + provider.Logger.Trace("{0} {1}", (t.GetCustomAttribute()?.Version.ToString() ?? "aux").PadLeft(5), StringUtils.ToHumanName(t.Name)); + } + } + } + + /// + /// Returns registered migration types. + /// + public virtual List MigrationsTypes + { + get { return _migrationsTypes; } + } + + /// + /// Returns the last version of the migrations. + /// + public virtual long LastVersion + { + get + { + if (_migrationsTypes.Count == 0) + { + return 0; + } + + return SelectedTypes.Select(GetMigrationVersion).DefaultIfEmpty(0).Max(); + } + } + + public Func Activator { get; set; } + + public IEnumerable SelectedTypes => _migrationsTypes.Where(t => + 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) + { + if (migrationAssembly != null) + { + _migrationsTypes.AddRange(GetMigrationTypes(migrationAssembly)); + } + } + + /// + /// Check for duplicated version in migrations. + /// + /// CheckForDuplicatedVersion + public virtual void CheckForDuplicatedVersion() + { + var versions = new List(); + foreach (var t in SelectedTypes) + { + var version = GetMigrationVersion(t); + + if (versions.Contains(version)) + { + throw new DuplicatedVersionException(version); + } + + versions.Add(version); + } + } + + /// + /// Collect migrations in one Assembly. + /// + /// The Assembly to browse. + /// The migrations collection + public static List GetMigrationTypes(Assembly asm) + { + var migrations = new List(); + foreach (var t in asm.GetExportedTypes()) + { + 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); + } + migrations = migrations.OrderBy(t => t.GetCustomAttribute()?.Version ?? 0).ThenBy(t => t.FullName, StringComparer.Ordinal).ToList(); + return migrations; + } + + /// + /// Returns the version of the migration + /// MigrationAttribute. + /// + /// Migration type. + /// Version number sepcified in the attribute + public static long GetMigrationVersion(Type t) + { + var attrib = (MigrationAttribute)Attribute.GetCustomAttribute(t, typeof(MigrationAttribute)); + return attrib?.Version ?? throw new ArgumentException($"{t.FullName} has no Migration attribute."); + } + + public List GetAvailableMigrations() + { + return SelectedTypes.Select(GetMigrationVersion).OrderBy(v => v).ToList(); + } + + public virtual IMigration GetMigration(long version) + { + foreach (var t in SelectedTypes) + { + if (GetMigrationVersion(t) == version) + { + var migration = CreateInstance(t); + migration.Database = _provider; + return migration; + } + } + + return null; + } + + public virtual IMigration CreateInstance(Type migrationType) + { + return Activator != null ? Activator(migrationType) ?? throw new MigrationException("Migration activator returned null.") : (IMigration)System.Activator.CreateInstance(migrationType); + } +} diff --git a/src/Migrator/Migrator.cs b/src/Migrator/Migrator.cs index 1c854002..9d94f03c 100644 --- a/src/Migrator/Migrator.cs +++ b/src/Migrator/Migrator.cs @@ -183,7 +183,14 @@ public long? LastAppliedMigrationVersion /// public void MigrateToLastVersion() { - MigrateTo(SelectedMigrationTypes.Select(MigrationLoader.GetMigrationVersion).DefaultIfEmpty(0).Max()); + 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(versions.DefaultIfEmpty(0).Max(), false, versions.Length == 0); } /// @@ -254,11 +261,11 @@ void AddMaintenance(MaintenanceStage stage) /// 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) + private void MigrateTo(long version, bool downOnly, bool preserveVersion = false) { if (DryRun) { - var preview = Plan(version); + 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"); @@ -281,6 +288,7 @@ IDisposable AcquireLock() (_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."); @@ -296,7 +304,7 @@ void Execute(IMigration migration, MigrationStep step, bool record) 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(migration, step.IsUp)); + if (session) afterCommit.Add(() => MigrationExecution.After(_provider, migration, step.IsUp)); } void Maintenance(MaintenanceStage stage) {