diff --git a/Directory.Build.props b/Directory.Build.props index b4a880a..9719520 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ Luís Amorim - 0.5.0-preview.1 + 0.5.0-preview.2 Apache-2.0 https://github.com/lgamorim/sqlbound https://github.com/lgamorim/sqlbound diff --git a/README.md b/README.md index fabfa10..a571c8c 100644 --- a/README.md +++ b/README.md @@ -68,9 +68,11 @@ and how SQL Server introspection works today. ## Migrations Schema changes are ordered SQL-file migrations — paired `{version}_{name}.up.sql` / `.down.sql` -files with a timestamp version — tracked in a `_sqlbound_migrations` ledger. `dotnet sqlbound -migrate add` scaffolds a migration and `dotnet sqlbound database create`/`drop` manage the target -database. Applying and reverting migrations land in M14. See [docs/migrations.md](docs/migrations.md). +files with a timestamp version — tracked in a `_sqlbound_migrations` ledger. `migrate add` scaffolds +a migration, `migrate run` applies pending ones (each in its own transaction, refusing on checksum +drift or out-of-order files), `migrate revert` rolls the last one back, and `migrate status` reports +what is applied vs pending. `database create`/`drop` manage the target database. SQL Server today; +other providers in M15. See [docs/migrations.md](docs/migrations.md). ## Packages diff --git a/docs/migrations.md b/docs/migrations.md index 15c04ad..e38d3e7 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -4,9 +4,9 @@ SqlBound applies schema changes as ordered SQL-file migrations, tracked in a dat This document covers the on-disk format, the ledger, and the CLI commands. The format decisions are recorded in [ADR 0006](adr/0006-migration-file-format.md). -> **Status.** M13 ships the migration model, the SQL Server ledger, `migrate add`, and -> `database create`/`database drop`. Applying and reverting migrations (`migrate run`, `migrate -> revert`, `migrate status`) arrives in M14, and the other providers in M15. +> **Status.** The full command set — `migrate add`, `migrate run`, `migrate revert`, `migrate +> status`, and `database create`/`database drop` — works against **SQL Server**. The other providers +> follow in M15. ## File format @@ -68,6 +68,55 @@ dotnet sqlbound migrate add "backfill emails" --irreversible - `--migrations ` sets the directory (default `./migrations`); it is created if missing. - `--irreversible` writes only the up script. +### `migrate run` + +Applies every pending migration, in version order: + +```bash +export SQLBOUND_DATABASE_URL="sqlserver://sa:password@localhost:1433/myapp?TrustServerCertificate=true" +dotnet sqlbound migrate run +# applied 20260712143000_create_items (18 ms) +# applied 1 migration(s). +``` + +- Each migration's up-script and its ledger row commit in **one transaction**. If a script fails, + that migration is rolled back and the run stops; every earlier migration stays applied. +- `run` refuses to proceed on two inconsistencies: an already-applied migration whose up-script has + been **edited** (checksum drift), and a **pending migration ordered before** one already applied + (a late-merged branch). Fix the directory rather than the database. +- `--migrations ` and `--connection` work as elsewhere. + +> **Batch separators.** Each script runs as a single command; SQL Server's `GO` separator is **not** +> supported, so a migration needing multiple batches (e.g. `CREATE PROCEDURE` followed by more SQL) +> must be split into separate migrations. This may be revisited in a later release. + +### `migrate revert` + +Rolls back the most recently applied migration by running its down-script: + +```bash +dotnet sqlbound migrate revert +# reverted 20260712143000_create_items. +``` + +- The down-script and the ledger removal commit in one transaction. +- `revert` refuses if the target migration is **irreversible** (no down script) or its files are + **missing**; it is a no-op ("nothing to revert") when the ledger is empty. +- Reverts one migration per invocation. + +### `migrate status` + +Reports each migration's state without changing anything: + +```bash +dotnet sqlbound migrate status +# 20260712143000_create_items applied 2026-07-12 14:30:07Z +# 20260712150000_backfill_emails pending +``` + +- States: **applied**, **pending**, **drifted** (up-script edited since it was applied), and + **missing** (in the ledger, but the file is gone). + ### `database create` / `database drop` Create or drop the database named by the connection string's `Initial Catalog`, connecting to diff --git a/src/SqlBound.Cli/MigrateCommand.cs b/src/SqlBound.Cli/MigrateCommand.cs index 971b8c8..c800b2f 100644 --- a/src/SqlBound.Cli/MigrateCommand.cs +++ b/src/SqlBound.Cli/MigrateCommand.cs @@ -1,23 +1,30 @@ using System.CommandLine; +using SqlBound.Migrations; namespace SqlBound.Cli; /// -/// Builds the migrate command tree. M13 ships migrate add (scaffold a new migration); -/// run, revert, and status follow in M14. +/// Builds the migrate command tree: add scaffolds a migration, run applies the +/// pending ones. revert and status are added alongside run as M14 proceeds. /// internal static class MigrateCommand { - public static Command Build() + public static Command Build() => + new("migrate", "Author and apply SQL-file migrations.") + { + BuildAdd(), + BuildRun(), + BuildRevert(), + BuildStatus(), + }; + + private static Command BuildAdd() { var nameArgument = new Argument("name") { Description = "A short description of the migration, e.g. \"create items\".", }; - var migrationsOption = new Option("--migrations") - { - Description = "The migrations directory (default: ./migrations).", - }; + var migrationsOption = MigrationsOption(); var irreversibleOption = new Option("--irreversible") { Description = "Scaffold only the up script; the migration cannot be reverted.", @@ -33,16 +40,10 @@ public static Command Build() }; addCommand.SetAction(parseResult => { - var directory = parseResult.GetValue(migrationsOption); - if (string.IsNullOrWhiteSpace(directory)) - { - directory = Path.Combine(Directory.GetCurrentDirectory(), "migrations"); - } - try { var created = MigrationScaffolder.Create( - directory, + ResolveDirectory(parseResult.GetValue(migrationsOption)), parseResult.GetValue(nameArgument)!, reversible: !parseResult.GetValue(irreversibleOption), TimeProvider.System); @@ -60,9 +61,126 @@ public static Command Build() } }); - return new Command("migrate", "Author and apply SQL-file migrations.") + return addCommand; + } + + private static Command BuildRun() + { + var migrationsOption = MigrationsOption(); + var connectionOption = ConnectionOption(); + + var runCommand = new Command("run", "Apply all pending migrations, each in its own transaction.") { - addCommand, + migrationsOption, + connectionOption, }; + runCommand.SetAction((parseResult, cancellationToken) => MigrationCli.ExecuteAsync( + parseResult.GetValue(connectionOption), + ResolveDirectory(parseResult.GetValue(migrationsOption)), + async (connection, ledger, migrations) => + { + var applied = await MigrationRunner + .RunAsync(connection, ledger, migrations, TimeProvider.System, cancellationToken) + .ConfigureAwait(false); + if (applied.Count == 0) + { + Console.Out.WriteLine("already up to date; no migrations to apply."); + return 0; + } + + foreach (var migration in applied) + { + Console.Out.WriteLine($"applied {migration.Version}_{migration.Name} ({migration.ExecutionMs} ms)"); + } + + Console.Out.WriteLine($"applied {applied.Count} migration(s)."); + return 0; + }, + cancellationToken)); + + return runCommand; } + + private static Command BuildRevert() + { + var migrationsOption = MigrationsOption(); + var connectionOption = ConnectionOption(); + + var revertCommand = new Command("revert", "Revert the most recently applied migration.") + { + migrationsOption, + connectionOption, + }; + revertCommand.SetAction((parseResult, cancellationToken) => MigrationCli.ExecuteAsync( + parseResult.GetValue(connectionOption), + ResolveDirectory(parseResult.GetValue(migrationsOption)), + async (connection, ledger, migrations) => + { + var reverted = await MigrationRunner + .RevertAsync(connection, ledger, migrations, cancellationToken) + .ConfigureAwait(false); + Console.Out.WriteLine(reverted is null + ? "nothing to revert." + : $"reverted {reverted.Version}_{reverted.Name}."); + return 0; + }, + cancellationToken)); + + return revertCommand; + } + + private static Command BuildStatus() + { + var migrationsOption = MigrationsOption(); + var connectionOption = ConnectionOption(); + + var statusCommand = new Command("status", "Show each migration's state: applied, pending, drifted, or missing.") + { + migrationsOption, + connectionOption, + }; + statusCommand.SetAction((parseResult, cancellationToken) => MigrationCli.ExecuteAsync( + parseResult.GetValue(connectionOption), + ResolveDirectory(parseResult.GetValue(migrationsOption)), + async (connection, ledger, migrations) => + { + var report = await MigrationRunner + .StatusAsync(connection, ledger, migrations, cancellationToken) + .ConfigureAwait(false); + if (report.Count == 0) + { + Console.Out.WriteLine("no migrations."); + return 0; + } + + foreach (var status in report) + { + var appliedOn = status.AppliedOnUtc is { } when + ? $" {when:yyyy-MM-dd HH:mm:ss}Z" + : string.Empty; + Console.Out.WriteLine( + $"{status.Version}_{status.Name} {status.State.ToString().ToLowerInvariant()}{appliedOn}"); + } + + return 0; + }, + cancellationToken)); + + return statusCommand; + } + + private static Option MigrationsOption() => + new("--migrations") { Description = "The migrations directory (default: ./migrations)." }; + + private static Option ConnectionOption() => + new("--connection") + { + Description = + $"Connection string or provider URL (default: the {DatabaseUrl.EnvironmentVariable} environment variable).", + }; + + private static string ResolveDirectory(string? migrationsDirectory) => + string.IsNullOrWhiteSpace(migrationsDirectory) + ? Path.Combine(Directory.GetCurrentDirectory(), "migrations") + : migrationsDirectory; } diff --git a/src/SqlBound.Cli/MigrationCli.cs b/src/SqlBound.Cli/MigrationCli.cs new file mode 100644 index 0000000..87c636d --- /dev/null +++ b/src/SqlBound.Cli/MigrationCli.cs @@ -0,0 +1,82 @@ +using System.Data.Common; +using Microsoft.Data.SqlClient; +using SqlBound.Migrations; +using SqlBound.SqlServer; + +namespace SqlBound.Cli; + +/// +/// Shared plumbing for the connected migrate subcommands (run, revert, +/// status): resolve the target, load the migrations directory, open the connection, build the +/// SQL Server ledger, and invoke the command's action — translating every expected failure into an +/// error message and a non-zero exit code. SQL Server only in this release. +/// +internal static class MigrationCli +{ + public static async Task ExecuteAsync( + string? connectionValue, + string migrationsDirectory, + Func, Task> action, + CancellationToken cancellationToken) + { + var databaseValue = connectionValue ?? Environment.GetEnvironmentVariable(DatabaseUrl.EnvironmentVariable); + if (string.IsNullOrWhiteSpace(databaseValue)) + { + Console.Error.WriteLine($"error: set {DatabaseUrl.EnvironmentVariable} or pass --connection."); + return 1; + } + + DatabaseTarget target; + try + { + target = DatabaseUrl.Resolve(databaseValue); + } + catch (ArgumentException exception) + { + Console.Error.WriteLine($"error: {exception.Message}"); + return 1; + } + + if (target.Provider != DatabaseProviders.SqlServer) + { + Console.Error.WriteLine("error: 'migrate' currently supports SQL Server only."); + return 1; + } + + IReadOnlyList migrations; + try + { + migrations = MigrationDirectory.Load(migrationsDirectory); + } + catch (Exception exception) when (exception is MigrationFormatException or DirectoryNotFoundException) + { + Console.Error.WriteLine($"error: {exception.Message}"); + return 1; + } + + var connection = new SqlConnection(target.ConnectionString); + await using (connection.ConfigureAwait(false)) + { + try + { + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) when (exception is SqlException or InvalidOperationException) + { + Console.Error.WriteLine($"error: cannot connect to the database: {exception.Message}"); + return 1; + } + + try + { + return await action(connection, new SqlServerMigrationLedger(), migrations).ConfigureAwait(false); + } + catch (Exception exception) + when (exception is MigrationInconsistencyException or MigrationExecutionException or DbException) + { + Console.Error.WriteLine($"error: {exception.Message}"); + return 1; + } + } + } +} diff --git a/src/SqlBound.Migrations/IMigrationLedger.cs b/src/SqlBound.Migrations/IMigrationLedger.cs index 6109dd7..5f16fcc 100644 --- a/src/SqlBound.Migrations/IMigrationLedger.cs +++ b/src/SqlBound.Migrations/IMigrationLedger.cs @@ -4,8 +4,9 @@ namespace SqlBound.Migrations; /// /// The per-provider migration history table (_sqlbound_migrations): the durable record of -/// which migrations have been applied. M13 defines the read side; the write side arrives with -/// migrate run. Implementations own only the SQL — they never open or close the connection. +/// which migrations have been applied. Implementations own only the SQL — they never open or close +/// the connection. The write methods take the ambient transaction so the ledger row commits +/// atomically with the migration's own script; the read methods run outside any transaction. /// public interface IMigrationLedger { @@ -19,4 +20,20 @@ public interface IMigrationLedger /// A token to cancel the operation. /// The applied migrations; empty when none have been applied. Task> GetAppliedAsync(DbConnection connection, CancellationToken cancellationToken); + + /// Records a migration as applied, on the given transaction. + /// An open connection to the target database. + /// The transaction the migration's up-script is running on, or . + /// The migration to record. + /// A token to cancel the operation. + Task RecordAppliedAsync( + DbConnection connection, DbTransaction? transaction, AppliedMigration migration, CancellationToken cancellationToken); + + /// Removes a migration's row from the ledger, on the given transaction. + /// An open connection to the target database. + /// The transaction the migration's down-script is running on, or . + /// The version of the migration to remove. + /// A token to cancel the operation. + Task RemoveAsync( + DbConnection connection, DbTransaction? transaction, long version, CancellationToken cancellationToken); } diff --git a/src/SqlBound.Migrations/MigrationExecutionException.cs b/src/SqlBound.Migrations/MigrationExecutionException.cs new file mode 100644 index 0000000..dee8a40 --- /dev/null +++ b/src/SqlBound.Migrations/MigrationExecutionException.cs @@ -0,0 +1,26 @@ +namespace SqlBound.Migrations; + +/// +/// Thrown when a migration's script fails to execute against the database. The offending +/// migration's transaction is rolled back before this is raised, so no partial change or ledger row +/// survives. The database's own exception is the . +/// +public sealed class MigrationExecutionException : Exception +{ + /// Initializes a new instance of the class. + /// The version of the migration that failed. + /// The name of the migration that failed. + /// The database exception that caused the failure. + public MigrationExecutionException(long version, string name, Exception innerException) + : base($"migration {version}_{name} failed: {innerException.Message}", innerException) + { + Version = version; + Name = name; + } + + /// The version of the migration that failed. + public long Version { get; } + + /// The name of the migration that failed. + public string Name { get; } +} diff --git a/src/SqlBound.Migrations/MigrationInconsistencyException.cs b/src/SqlBound.Migrations/MigrationInconsistencyException.cs new file mode 100644 index 0000000..758b598 --- /dev/null +++ b/src/SqlBound.Migrations/MigrationInconsistencyException.cs @@ -0,0 +1,18 @@ +namespace SqlBound.Migrations; + +/// +/// Thrown when the migrations directory is inconsistent with the applied history: an applied +/// migration's up-script has been edited (checksum drift), a not-yet-applied migration is ordered +/// before one that is already applied, or the migration to revert is irreversible or missing. This +/// is distinct from , which is about the directory being +/// malformed in isolation, before any comparison with the ledger. +/// +public sealed class MigrationInconsistencyException : Exception +{ + /// Initializes a new instance of the class. + /// A description of how the directory and the applied history disagree. + public MigrationInconsistencyException(string message) + : base(message) + { + } +} diff --git a/src/SqlBound.Migrations/MigrationPlan.cs b/src/SqlBound.Migrations/MigrationPlan.cs new file mode 100644 index 0000000..e6699c7 --- /dev/null +++ b/src/SqlBound.Migrations/MigrationPlan.cs @@ -0,0 +1,53 @@ +namespace SqlBound.Migrations; + +/// +/// The ordered set of migrations migrate run would apply, computed from the directory and +/// the ledger. Creating the plan is also where the run's safety checks live: it refuses to proceed +/// if an already-applied migration has been edited, or if a pending migration is ordered before one +/// that is already applied. +/// +/// The migrations to apply, ascending by version. +public sealed record MigrationPlan(IReadOnlyList Pending) +{ + /// Computes the plan, validating the directory against the applied history. + /// The migrations on disk, as loaded from the directory. + /// The migrations the ledger records as applied. + /// + /// An applied migration's checksum has drifted, or a pending migration is out of order. + /// + public static MigrationPlan Create( + IReadOnlyList migrations, IReadOnlyList applied) + { + var appliedByVersion = applied.ToDictionary(row => row.Version); + + foreach (var migration in migrations) + { + if (appliedByVersion.TryGetValue(migration.Version, out var appliedRow) + && migration.Checksum != appliedRow.Checksum) + { + throw new MigrationInconsistencyException( + $"migration {migration.Version}_{migration.Name} has been modified since it was applied " + + "(checksum mismatch); an applied migration must not be edited."); + } + } + + var pending = migrations + .Where(migration => !appliedByVersion.ContainsKey(migration.Version)) + .OrderBy(migration => migration.Version) + .ToList(); + + if (applied.Count > 0 && pending.Count > 0) + { + var highestApplied = applied.Max(row => row.Version); + var outOfOrder = pending.FirstOrDefault(migration => migration.Version < highestApplied); + if (outOfOrder is not null) + { + throw new MigrationInconsistencyException( + $"migration {outOfOrder.Version}_{outOfOrder.Name} is ordered before the already-applied " + + $"migration {highestApplied}; migrations must be applied in version order."); + } + } + + return new MigrationPlan(pending); + } +} diff --git a/src/SqlBound.Migrations/MigrationReverter.cs b/src/SqlBound.Migrations/MigrationReverter.cs new file mode 100644 index 0000000..4050e23 --- /dev/null +++ b/src/SqlBound.Migrations/MigrationReverter.cs @@ -0,0 +1,40 @@ +namespace SqlBound.Migrations; + +/// +/// Selects the migration migrate revert would roll back: the most recently applied one. The +/// selection fails if that migration's files are gone or it ships no down script, so the caller +/// never runs a rollback it cannot honor. +/// +public static class MigrationReverter +{ + /// Chooses the migration to revert. + /// The migrations on disk, as loaded from the directory. + /// The migrations the ledger records as applied. + /// The migration to revert, or when nothing is applied. + /// + /// The most recently applied migration is missing from disk or is irreversible. + /// + public static Migration? Plan(IReadOnlyList migrations, IReadOnlyList applied) + { + if (applied.Count == 0) + { + return null; + } + + var target = applied.OrderByDescending(row => row.Version).First(); + var migration = migrations.FirstOrDefault(candidate => candidate.Version == target.Version); + if (migration is null) + { + throw new MigrationInconsistencyException( + $"cannot revert {target.Version}_{target.Name}: its migration files are missing."); + } + + if (!migration.IsReversible) + { + throw new MigrationInconsistencyException( + $"cannot revert {migration.Version}_{migration.Name}: it is irreversible (no down script)."); + } + + return migration; + } +} diff --git a/src/SqlBound.Migrations/MigrationRunner.cs b/src/SqlBound.Migrations/MigrationRunner.cs new file mode 100644 index 0000000..106eed7 --- /dev/null +++ b/src/SqlBound.Migrations/MigrationRunner.cs @@ -0,0 +1,140 @@ +using System.Data.Common; +using System.Diagnostics; + +namespace SqlBound.Migrations; + +/// +/// Applies and reverts migrations against a database. The orchestration is provider-neutral — a +/// migration script is executed as an ordinary and the ledger is written +/// through — so every provider shares this engine. Each migration is +/// applied in its own transaction together with its ledger row: a failure rolls back that one +/// migration and stops, leaving every earlier migration applied. +/// +public static class MigrationRunner +{ + /// Applies every pending migration, in version order. + /// An open connection to the target database. + /// The migration ledger for the target provider. + /// The migrations on disk, as loaded from the directory. + /// Supplies the applied_on_utc timestamp. + /// A token to cancel the operation. + /// The migrations applied by this run, in order; empty when already up to date. + /// The directory disagrees with the applied history. + /// A migration's up-script failed to execute. + public static async Task> RunAsync( + DbConnection connection, + IMigrationLedger ledger, + IReadOnlyList migrations, + TimeProvider timeProvider, + CancellationToken cancellationToken) + { + await ledger.EnsureCreatedAsync(connection, cancellationToken).ConfigureAwait(false); + var applied = await ledger.GetAppliedAsync(connection, cancellationToken).ConfigureAwait(false); + var plan = MigrationPlan.Create(migrations, applied); + + var appliedNow = new List(); + foreach (var migration in plan.Pending) + { + var record = await ApplyAsync(connection, ledger, migration, timeProvider, cancellationToken) + .ConfigureAwait(false); + appliedNow.Add(record); + } + + return appliedNow; + } + + /// Reverts the most recently applied migration, running its down-script. + /// An open connection to the target database. + /// The migration ledger for the target provider. + /// The migrations on disk, as loaded from the directory. + /// A token to cancel the operation. + /// The reverted migration, or when nothing was applied. + /// The migration to revert is missing or irreversible. + /// The down-script failed to execute. + public static async Task RevertAsync( + DbConnection connection, + IMigrationLedger ledger, + IReadOnlyList migrations, + CancellationToken cancellationToken) + { + await ledger.EnsureCreatedAsync(connection, cancellationToken).ConfigureAwait(false); + var applied = await ledger.GetAppliedAsync(connection, cancellationToken).ConfigureAwait(false); + var target = MigrationReverter.Plan(migrations, applied); + if (target is null) + { + return null; + } + + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + try + { + // MigrationReverter.Plan guarantees a reversible target, so DownScript is non-null here. + await ExecuteScriptAsync(connection, transaction, target.DownScript!, cancellationToken).ConfigureAwait(false); + await ledger.RemoveAsync(connection, transaction, target.Version, cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return target; + } + catch (DbException exception) + { + await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); + throw new MigrationExecutionException(target.Version, target.Name, exception); + } + } + + /// Reports each migration's state relative to the ledger, without changing anything. + /// An open connection to the target database. + /// The migration ledger for the target provider. + /// The migrations on disk, as loaded from the directory. + /// A token to cancel the operation. + /// One status per migration known to disk or the ledger, ascending by version. + public static async Task> StatusAsync( + DbConnection connection, + IMigrationLedger ledger, + IReadOnlyList migrations, + CancellationToken cancellationToken) + { + await ledger.EnsureCreatedAsync(connection, cancellationToken).ConfigureAwait(false); + var applied = await ledger.GetAppliedAsync(connection, cancellationToken).ConfigureAwait(false); + return MigrationStatusReport.Build(migrations, applied); + } + + private static async Task ApplyAsync( + DbConnection connection, + IMigrationLedger ledger, + Migration migration, + TimeProvider timeProvider, + CancellationToken cancellationToken) + { + await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + try + { + var stopwatch = Stopwatch.StartNew(); + await ExecuteScriptAsync(connection, transaction, migration.UpScript, cancellationToken).ConfigureAwait(false); + stopwatch.Stop(); + + var record = new AppliedMigration( + migration.Version, + migration.Name, + migration.Checksum, + timeProvider.GetUtcNow().UtcDateTime, + stopwatch.ElapsedMilliseconds); + await ledger.RecordAppliedAsync(connection, transaction, record, cancellationToken).ConfigureAwait(false); + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + return record; + } + catch (DbException exception) + { + await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); + throw new MigrationExecutionException(migration.Version, migration.Name, exception); + } + } + + private static async Task ExecuteScriptAsync( + DbConnection connection, DbTransaction transaction, string script, CancellationToken cancellationToken) + { + await using var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = script; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } +} diff --git a/src/SqlBound.Migrations/MigrationState.cs b/src/SqlBound.Migrations/MigrationState.cs new file mode 100644 index 0000000..2e3d440 --- /dev/null +++ b/src/SqlBound.Migrations/MigrationState.cs @@ -0,0 +1,17 @@ +namespace SqlBound.Migrations; + +/// A migration's state relative to the ledger, as reported by migrate status. +public enum MigrationState +{ + /// Present on disk and applied; the checksums agree. + Applied, + + /// Present on disk but not yet applied. + Pending, + + /// Applied, but the on-disk up-script no longer matches the applied checksum. + Drifted, + + /// Recorded as applied in the ledger, but no longer present on disk. + Missing, +} diff --git a/src/SqlBound.Migrations/MigrationStatus.cs b/src/SqlBound.Migrations/MigrationStatus.cs new file mode 100644 index 0000000..587597e --- /dev/null +++ b/src/SqlBound.Migrations/MigrationStatus.cs @@ -0,0 +1,8 @@ +namespace SqlBound.Migrations; + +/// One migration's state relative to the ledger. +/// The migration's timestamp version. +/// The migration's name slug. +/// The migration's state relative to the applied history. +/// When the migration was applied, or if it is pending. +public sealed record MigrationStatus(long Version, string Name, MigrationState State, DateTime? AppliedOnUtc); diff --git a/src/SqlBound.Migrations/MigrationStatusReport.cs b/src/SqlBound.Migrations/MigrationStatusReport.cs new file mode 100644 index 0000000..8837607 --- /dev/null +++ b/src/SqlBound.Migrations/MigrationStatusReport.cs @@ -0,0 +1,45 @@ +namespace SqlBound.Migrations; + +/// +/// Classifies every migration — on disk, in the ledger, or both — into a , +/// ordered by version. This is the read-only view behind migrate status; unlike +/// it reports drift and missing migrations rather than throwing on them. +/// +public static class MigrationStatusReport +{ + /// Classifies every known migration into its state relative to the ledger. + /// The migrations on disk, as loaded from the directory. + /// The migrations the ledger records as applied. + /// One status per migration known to either source, ascending by version. + public static IReadOnlyList Build( + IReadOnlyList migrations, IReadOnlyList applied) + { + var migrationsByVersion = migrations.ToDictionary(migration => migration.Version); + var appliedByVersion = applied.ToDictionary(row => row.Version); + + var report = new List(); + foreach (var version in migrationsByVersion.Keys.Union(appliedByVersion.Keys).OrderBy(version => version)) + { + migrationsByVersion.TryGetValue(version, out var migration); + appliedByVersion.TryGetValue(version, out var appliedRow); + + report.Add((migration, appliedRow) switch + { + (null, not null) => + new MigrationStatus(version, appliedRow.Name, MigrationState.Missing, appliedRow.AppliedOnUtc), + (not null, not null) => + new MigrationStatus( + version, + migration.Name, + migration.Checksum == appliedRow.Checksum ? MigrationState.Applied : MigrationState.Drifted, + appliedRow.AppliedOnUtc), + (not null, null) => + new MigrationStatus(version, migration.Name, MigrationState.Pending, null), + (null, null) => + throw new InvalidOperationException($"Version {version} is in neither collection."), + }); + } + + return report; + } +} diff --git a/src/SqlBound.SqlServer/SqlServerMigrationLedger.cs b/src/SqlBound.SqlServer/SqlServerMigrationLedger.cs index ad0e566..d574a44 100644 --- a/src/SqlBound.SqlServer/SqlServerMigrationLedger.cs +++ b/src/SqlBound.SqlServer/SqlServerMigrationLedger.cs @@ -53,6 +53,36 @@ public async Task> GetAppliedAsync( return applied; } + /// + public async Task RecordAppliedAsync( + DbConnection connection, DbTransaction? transaction, AppliedMigration migration, CancellationToken cancellationToken) + { + await using var command = AsSqlConnection(connection).CreateCommand(); + command.Transaction = (SqlTransaction?)transaction; + command.CommandText = + $""" + INSERT INTO dbo.{TableName} (version, name, checksum, applied_on_utc, execution_ms) + VALUES (@version, @name, @checksum, @appliedOnUtc, @executionMs); + """; + command.Parameters.AddWithValue("@version", migration.Version); + command.Parameters.AddWithValue("@name", migration.Name); + command.Parameters.AddWithValue("@checksum", migration.Checksum); + command.Parameters.AddWithValue("@appliedOnUtc", migration.AppliedOnUtc); + command.Parameters.AddWithValue("@executionMs", migration.ExecutionMs); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task RemoveAsync( + DbConnection connection, DbTransaction? transaction, long version, CancellationToken cancellationToken) + { + await using var command = AsSqlConnection(connection).CreateCommand(); + command.Transaction = (SqlTransaction?)transaction; + command.CommandText = $"DELETE FROM dbo.{TableName} WHERE version = @version;"; + command.Parameters.AddWithValue("@version", version); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + private static SqlConnection AsSqlConnection(DbConnection connection) => connection as SqlConnection ?? throw new ArgumentException( diff --git a/test/SqlBound.Migrations.UnitTests/MigrationPlanTests.cs b/test/SqlBound.Migrations.UnitTests/MigrationPlanTests.cs new file mode 100644 index 0000000..23a07ef --- /dev/null +++ b/test/SqlBound.Migrations.UnitTests/MigrationPlanTests.cs @@ -0,0 +1,62 @@ +namespace SqlBound.Migrations.UnitTests; + +public sealed class MigrationPlanTests +{ + [Fact] + public void Should_ReturnAllMigrationsAsPending_When_NothingApplied() + { + var plan = MigrationPlan.Create([Migration(1), Migration(2)], []); + + Assert.Equal([1, 2], plan.Pending.Select(migration => migration.Version)); + } + + [Fact] + public void Should_ReturnOnlyUnappliedMigrations_When_SomeApplied() + { + var plan = MigrationPlan.Create( + [Migration(1), Migration(2), Migration(3)], + [Applied(1)]); + + Assert.Equal([2, 3], plan.Pending.Select(migration => migration.Version)); + } + + [Fact] + public void Should_ReturnEmpty_When_AllApplied() + { + var plan = MigrationPlan.Create([Migration(1), Migration(2)], [Applied(1), Applied(2)]); + + Assert.Empty(plan.Pending); + } + + [Fact] + public void Should_TolerateAppliedMigrationWithNoFile_When_LedgerHasExtraVersion() + { + var plan = MigrationPlan.Create([Migration(2)], [Applied(1), Applied(2)]); + + Assert.Empty(plan.Pending); + } + + [Fact] + public void Should_ThrowInconsistency_When_AppliedMigrationChecksumDiffers() + { + var exception = Assert.Throws( + () => MigrationPlan.Create([Migration(1, "aaa")], [Applied(1, "bbb")])); + + Assert.Contains("checksum", exception.Message); + } + + [Fact] + public void Should_ThrowInconsistency_When_PendingMigrationIsOrderedBeforeAnApplied() + { + var exception = Assert.Throws( + () => MigrationPlan.Create([Migration(1), Migration(2)], [Applied(2)])); + + Assert.Contains("version order", exception.Message); + } + + private static Migration Migration(long version, string checksum = "checksum") => + new(version, $"m{version}", $"-- up {version}", $"-- down {version}", checksum); + + private static AppliedMigration Applied(long version, string checksum = "checksum") => + new(version, $"m{version}", checksum, new DateTime(2026, 7, 12, 0, 0, 0), 1); +} diff --git a/test/SqlBound.Migrations.UnitTests/MigrationReverterTests.cs b/test/SqlBound.Migrations.UnitTests/MigrationReverterTests.cs new file mode 100644 index 0000000..51258e0 --- /dev/null +++ b/test/SqlBound.Migrations.UnitTests/MigrationReverterTests.cs @@ -0,0 +1,44 @@ +namespace SqlBound.Migrations.UnitTests; + +public sealed class MigrationReverterTests +{ + [Fact] + public void Should_ReturnNull_When_NothingApplied() + { + Assert.Null(MigrationReverter.Plan([Migration(1)], [])); + } + + [Fact] + public void Should_ReturnMostRecentlyApplied_When_ReversibleMigrationsApplied() + { + var target = MigrationReverter.Plan([Migration(1), Migration(2)], [Applied(1), Applied(2)]); + + Assert.Equal(2, target!.Version); + } + + [Fact] + public void Should_ThrowInconsistency_When_TargetMigrationFilesAreMissing() + { + var exception = Assert.Throws( + () => MigrationReverter.Plan([], [Applied(1)])); + + Assert.Contains("missing", exception.Message); + } + + [Fact] + public void Should_ThrowInconsistency_When_TargetMigrationIsIrreversible() + { + var irreversible = new Migration(1, "m1", "-- up 1", DownScript: null, "checksum"); + + var exception = Assert.Throws( + () => MigrationReverter.Plan([irreversible], [Applied(1)])); + + Assert.Contains("irreversible", exception.Message); + } + + private static Migration Migration(long version, string checksum = "checksum") => + new(version, $"m{version}", $"-- up {version}", $"-- down {version}", checksum); + + private static AppliedMigration Applied(long version, string checksum = "checksum") => + new(version, $"m{version}", checksum, new DateTime(2026, 7, 12, 0, 0, 0), 1); +} diff --git a/test/SqlBound.Migrations.UnitTests/MigrationStatusReportTests.cs b/test/SqlBound.Migrations.UnitTests/MigrationStatusReportTests.cs new file mode 100644 index 0000000..d0ce431 --- /dev/null +++ b/test/SqlBound.Migrations.UnitTests/MigrationStatusReportTests.cs @@ -0,0 +1,47 @@ +namespace SqlBound.Migrations.UnitTests; + +public sealed class MigrationStatusReportTests +{ + [Fact] + public void Should_ClassifyAppliedAndPending_When_SomeMigrationsApplied() + { + var report = MigrationStatusReport.Build([Migration(1), Migration(2)], [Applied(1)]); + + Assert.Equal( + [ + new MigrationStatus(1, "m1", MigrationState.Applied, new DateTime(2026, 7, 12, 0, 0, 0)), + new MigrationStatus(2, "m2", MigrationState.Pending, null), + ], + report); + } + + [Fact] + public void Should_ClassifyDrifted_When_ChecksumDiffers() + { + var report = MigrationStatusReport.Build([Migration(1, "aaa")], [Applied(1, "bbb")]); + + Assert.Equal(MigrationState.Drifted, Assert.Single(report).State); + } + + [Fact] + public void Should_ClassifyMissing_When_AppliedMigrationHasNoFile() + { + var report = MigrationStatusReport.Build([], [Applied(1)]); + + Assert.Equal(MigrationState.Missing, Assert.Single(report).State); + } + + [Fact] + public void Should_OrderByVersion_When_MigrationsAndLedgerInterleave() + { + var report = MigrationStatusReport.Build([Migration(3), Migration(1)], [Applied(1), Applied(2)]); + + Assert.Equal([1, 2, 3], report.Select(status => status.Version)); + } + + private static Migration Migration(long version, string checksum = "checksum") => + new(version, $"m{version}", $"-- up {version}", $"-- down {version}", checksum); + + private static AppliedMigration Applied(long version, string checksum = "checksum") => + new(version, $"m{version}", checksum, new DateTime(2026, 7, 12, 0, 0, 0), 1); +} diff --git a/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerRevertTests.cs b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerRevertTests.cs new file mode 100644 index 0000000..7aabb33 --- /dev/null +++ b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerRevertTests.cs @@ -0,0 +1,81 @@ +using Microsoft.Data.SqlClient; +using SqlBound.Migrations; + +namespace SqlBound.SqlServer.IntegrationTests; + +/// +/// Exercises end to end against the shared container. +/// Each test drops the ledger and its tables first so the sequential methods start clean. +/// +[Collection("SqlServerMigrations")] +public sealed class MigrationRunnerRevertTests(SqlServerFixture fixture) +{ + private readonly SqlServerMigrationLedger _ledger = new(); + private readonly TimeProvider _clock = TimeProvider.System; + + [Fact] + public async Task Should_RunDownScriptAndRemoveLedgerRow_When_Reverting() + { + await using var connection = await ResetAsync("mrr_a", "mrr_b"); + var migrations = new[] + { + Migration(20260712100000, "create_a", "CREATE TABLE dbo.mrr_a (id int);"), + Migration(20260712110000, "create_b", "CREATE TABLE dbo.mrr_b (id int);"), + }; + await MigrationRunner.RunAsync(connection, _ledger, migrations, _clock, Token); + + var reverted = await MigrationRunner.RevertAsync(connection, _ledger, migrations, Token); + + Assert.Equal(20260712110000, reverted!.Version); + Assert.True(await TableExistsAsync(connection, "mrr_a")); + Assert.False(await TableExistsAsync(connection, "mrr_b")); + Assert.Equal([20260712100000], (await _ledger.GetAppliedAsync(connection, Token)).Select(row => row.Version)); + } + + [Fact] + public async Task Should_ReturnNull_When_NothingApplied() + { + await using var connection = await ResetAsync("mrr_a"); + var migrations = new[] { Migration(20260712100000, "create_a", "CREATE TABLE dbo.mrr_a (id int);") }; + await _ledger.EnsureCreatedAsync(connection, Token); + + Assert.Null(await MigrationRunner.RevertAsync(connection, _ledger, migrations, Token)); + } + + [Fact] + public async Task Should_ThrowInconsistency_When_TargetMigrationIsIrreversible() + { + await using var connection = await ResetAsync("mrr_a"); + var irreversible = new Migration( + 20260712100000, "create_a", "CREATE TABLE dbo.mrr_a (id int);", DownScript: null, Checksum("a")); + await MigrationRunner.RunAsync(connection, _ledger, [irreversible], _clock, Token); + + await Assert.ThrowsAsync( + () => MigrationRunner.RevertAsync(connection, _ledger, [irreversible], Token)); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private static string Checksum(string seed) => seed.PadRight(64, '0'); + + private static Migration Migration(long version, string name, string upScript) => + new(version, name, upScript, $"DROP TABLE dbo.{name.Replace("create_", "mrr_")};", Checksum(name)); + + private async Task ResetAsync(params string[] tables) + { + var connection = await fixture.OpenConnectionAsync(); + await using var command = connection.CreateCommand(); + var drops = string.Join("\n", tables.Select(table => $"DROP TABLE IF EXISTS dbo.{table};")); + command.CommandText = $"DROP TABLE IF EXISTS dbo._sqlbound_migrations;\n{drops}"; + await command.ExecuteNonQueryAsync(Token); + return connection; + } + + private static async Task TableExistsAsync(SqlConnection connection, string table) + { + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT OBJECT_ID(@name, N'U');"; + command.Parameters.AddWithValue("@name", $"dbo.{table}"); + return await command.ExecuteScalarAsync(Token) is not (null or DBNull); + } +} diff --git a/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerStatusTests.cs b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerStatusTests.cs new file mode 100644 index 0000000..a820663 --- /dev/null +++ b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerStatusTests.cs @@ -0,0 +1,51 @@ +using Microsoft.Data.SqlClient; +using SqlBound.Migrations; + +namespace SqlBound.SqlServer.IntegrationTests; + +/// +/// Exercises end to end against the shared container: +/// the pure classification is unit-tested, so this proves the read-only wiring over the real ledger. +/// +[Collection("SqlServerMigrations")] +public sealed class MigrationRunnerStatusTests(SqlServerFixture fixture) +{ + private readonly SqlServerMigrationLedger _ledger = new(); + + [Fact] + public async Task Should_ReportAppliedAndPending_When_SomeMigrationsApplied() + { + await using var connection = await ResetAsync("ms_a"); + var first = new Migration(20260712100000, "create_a", "CREATE TABLE dbo.ms_a (id int);", "DROP TABLE dbo.ms_a;", Checksum("a")); + await MigrationRunner.RunAsync(connection, _ledger, [first], TimeProvider.System, Token); + var second = new Migration(20260712110000, "create_b", "SELECT 1;", "SELECT 1;", Checksum("b")); + + var report = await MigrationRunner.StatusAsync(connection, _ledger, [first, second], Token); + + Assert.Equal(MigrationState.Applied, report[0].State); + Assert.NotNull(report[0].AppliedOnUtc); + Assert.Equal(MigrationState.Pending, report[1].State); + } + + [Fact] + public async Task Should_ReportEmpty_When_NoMigrationsOnDiskOrInLedger() + { + await using var connection = await ResetAsync(); + + Assert.Empty(await MigrationRunner.StatusAsync(connection, _ledger, [], Token)); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private static string Checksum(string seed) => seed.PadRight(64, '0'); + + private async Task ResetAsync(params string[] tables) + { + var connection = await fixture.OpenConnectionAsync(); + await using var command = connection.CreateCommand(); + var drops = string.Join("\n", tables.Select(table => $"DROP TABLE IF EXISTS dbo.{table};")); + command.CommandText = $"DROP TABLE IF EXISTS dbo._sqlbound_migrations;\n{drops}"; + await command.ExecuteNonQueryAsync(Token); + return connection; + } +} diff --git a/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerTests.cs b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerTests.cs new file mode 100644 index 0000000..994ce6a --- /dev/null +++ b/test/SqlBound.SqlServer.IntegrationTests/MigrationRunnerTests.cs @@ -0,0 +1,106 @@ +using Microsoft.Data.SqlClient; +using SqlBound.Migrations; + +namespace SqlBound.SqlServer.IntegrationTests; + +/// +/// Exercises end to end against the shared container with the real +/// SQL Server ledger. Each test drops the ledger and the tables its migrations create, so the +/// sequential methods start from a clean slate. +/// +[Collection("SqlServerMigrations")] +public sealed class MigrationRunnerTests(SqlServerFixture fixture) +{ + private readonly SqlServerMigrationLedger _ledger = new(); + private readonly TimeProvider _clock = TimeProvider.System; + + [Fact] + public async Task Should_ApplyAllPendingMigrations_When_Run() + { + await using var connection = await ResetAsync("mr_a", "mr_b"); + var migrations = new[] + { + Migration(20260712100000, "create_a", "CREATE TABLE dbo.mr_a (id int);", "aaa"), + Migration(20260712110000, "create_b", "CREATE TABLE dbo.mr_b (id int);", "bbb"), + }; + + var applied = await MigrationRunner.RunAsync(connection, _ledger, migrations, _clock, Token); + + Assert.Equal([20260712100000, 20260712110000], applied.Select(migration => migration.Version)); + Assert.True(await TableExistsAsync(connection, "mr_a")); + Assert.True(await TableExistsAsync(connection, "mr_b")); + Assert.Equal(2, (await _ledger.GetAppliedAsync(connection, Token)).Count); + } + + [Fact] + public async Task Should_ApplyNothing_When_AlreadyUpToDate() + { + await using var connection = await ResetAsync("mr_a"); + var migrations = new[] { Migration(20260712100000, "create_a", "CREATE TABLE dbo.mr_a (id int);", "aaa") }; + await MigrationRunner.RunAsync(connection, _ledger, migrations, _clock, Token); + + var second = await MigrationRunner.RunAsync(connection, _ledger, migrations, _clock, Token); + + Assert.Empty(second); + Assert.Single(await _ledger.GetAppliedAsync(connection, Token)); + } + + [Fact] + public async Task Should_RollBackFailedMigrationAndStop_When_AScriptErrors() + { + await using var connection = await ResetAsync("mr_a", "mr_bad"); + var migrations = new[] + { + Migration(20260712100000, "create_a", "CREATE TABLE dbo.mr_a (id int);", "aaa"), + Migration(20260712110000, "broken", "CREATE TABLE dbo.mr_bad (id int) THIS IS NOT SQL;", "bad"), + }; + + var exception = await Assert.ThrowsAsync( + () => MigrationRunner.RunAsync(connection, _ledger, migrations, _clock, Token)); + + Assert.Equal(20260712110000, exception.Version); + Assert.True(await TableExistsAsync(connection, "mr_a")); + Assert.False(await TableExistsAsync(connection, "mr_bad")); + Assert.Single(await _ledger.GetAppliedAsync(connection, Token)); + } + + [Fact] + public async Task Should_ThrowInconsistency_When_AnAppliedMigrationHasBeenEdited() + { + await using var connection = await ResetAsync("mr_a"); + await MigrationRunner.RunAsync( + connection, _ledger, + [Migration(20260712100000, "create_a", "CREATE TABLE dbo.mr_a (id int);", "aaa")], + _clock, Token); + + await Assert.ThrowsAsync( + () => MigrationRunner.RunAsync( + connection, _ledger, + [Migration(20260712100000, "create_a", "CREATE TABLE dbo.mr_a (id int, extra int);", "edited")], + _clock, Token)); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private static Migration Migration(long version, string name, string upScript, string checksum) => + // Real checksums are 64-char SHA-256 hex; pad so the CHAR(64) ledger column round-trips exactly. + new(version, name, upScript, $"DROP TABLE dbo.{name};", checksum.PadRight(64, '0')); + + private async Task ResetAsync(params string[] tables) + { + var connection = await fixture.OpenConnectionAsync(); + await using var command = connection.CreateCommand(); + var drops = string.Join("\n", tables.Select(table => $"DROP TABLE IF EXISTS dbo.{table};")); + command.CommandText = $"DROP TABLE IF EXISTS dbo._sqlbound_migrations;\n{drops}"; + await command.ExecuteNonQueryAsync(Token); + return connection; + } + + private static async Task TableExistsAsync(SqlConnection connection, string table) + { + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT OBJECT_ID(@name, N'U');"; + command.Parameters.AddWithValue("@name", $"dbo.{table}"); + return await command.ExecuteScalarAsync(Token) is not (null or DBNull); + } +} diff --git a/test/SqlBound.SqlServer.IntegrationTests/SqlServerMigrationLedgerTests.cs b/test/SqlBound.SqlServer.IntegrationTests/SqlServerMigrationLedgerTests.cs index ca90aa2..20af8f4 100644 --- a/test/SqlBound.SqlServer.IntegrationTests/SqlServerMigrationLedgerTests.cs +++ b/test/SqlBound.SqlServer.IntegrationTests/SqlServerMigrationLedgerTests.cs @@ -8,6 +8,7 @@ namespace SqlBound.SqlServer.IntegrationTests; /// sequentially (one xunit collection) and each drops the ledger table first, so they do not /// observe one another's rows; the describe suites run in parallel but only read other tables. /// +[Collection("SqlServerMigrations")] public sealed class SqlServerMigrationLedgerTests(SqlServerFixture fixture) { private const string SampleChecksum = @@ -35,22 +36,52 @@ public async Task Should_NotThrow_When_EnsureCreatedCalledTwice() } [Fact] - public async Task Should_ReturnAppliedMigrationsOrderedByVersion_When_LedgerHasRows() + public async Task Should_RecordAppliedMigrationsAndReadThemBackOrderedByVersion() { await using var connection = await ResetAsync(); var ledger = new SqlServerMigrationLedger(); await ledger.EnsureCreatedAsync(connection, TestContext.Current.CancellationToken); - await SeedAsync(connection, 20260712150000, "second", 12); - await SeedAsync(connection, 20260712143000, "first", 7); + var second = new AppliedMigration(20260712150000, "second", SampleChecksum, new DateTime(2026, 7, 12, 15, 0, 0), 12); + var first = new AppliedMigration(20260712143000, "first", SampleChecksum, new DateTime(2026, 7, 12, 14, 30, 0), 7); + await ledger.RecordAppliedAsync(connection, null, second, TestContext.Current.CancellationToken); + await ledger.RecordAppliedAsync(connection, null, first, TestContext.Current.CancellationToken); var applied = await ledger.GetAppliedAsync(connection, TestContext.Current.CancellationToken); - Assert.Equal( - [ - new AppliedMigration(20260712143000, "first", SampleChecksum, new DateTime(2026, 7, 12, 14, 30, 0), 7), - new AppliedMigration(20260712150000, "second", SampleChecksum, new DateTime(2026, 7, 12, 15, 0, 0), 12), - ], - applied); + Assert.Equal([first, second], applied); + } + + [Fact] + public async Task Should_RemoveOnlyTheNamedMigration_When_RemoveCalled() + { + await using var connection = await ResetAsync(); + var ledger = new SqlServerMigrationLedger(); + await ledger.EnsureCreatedAsync(connection, TestContext.Current.CancellationToken); + var first = new AppliedMigration(20260712143000, "first", SampleChecksum, new DateTime(2026, 7, 12, 14, 30, 0), 7); + var second = new AppliedMigration(20260712150000, "second", SampleChecksum, new DateTime(2026, 7, 12, 15, 0, 0), 12); + await ledger.RecordAppliedAsync(connection, null, first, TestContext.Current.CancellationToken); + await ledger.RecordAppliedAsync(connection, null, second, TestContext.Current.CancellationToken); + + await ledger.RemoveAsync(connection, null, second.Version, TestContext.Current.CancellationToken); + + Assert.Equal([first], await ledger.GetAppliedAsync(connection, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task Should_LeaveNoRow_When_TransactionRecordingAMigrationIsRolledBack() + { + await using var connection = await ResetAsync(); + var ledger = new SqlServerMigrationLedger(); + await ledger.EnsureCreatedAsync(connection, TestContext.Current.CancellationToken); + var migration = new AppliedMigration(20260712143000, "first", SampleChecksum, new DateTime(2026, 7, 12, 14, 30, 0), 7); + + await using (var transaction = (SqlTransaction)await connection.BeginTransactionAsync(TestContext.Current.CancellationToken)) + { + await ledger.RecordAppliedAsync(connection, transaction, migration, TestContext.Current.CancellationToken); + await transaction.RollbackAsync(TestContext.Current.CancellationToken); + } + + Assert.Empty(await ledger.GetAppliedAsync(connection, TestContext.Current.CancellationToken)); } private async Task ResetAsync() @@ -61,21 +92,4 @@ private async Task ResetAsync() await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); return connection; } - - private static async Task SeedAsync(SqlConnection connection, long version, string name, long executionMs) - { - await using var command = connection.CreateCommand(); - command.CommandText = - """ - INSERT INTO dbo._sqlbound_migrations (version, name, checksum, applied_on_utc, execution_ms) - VALUES (@version, @name, @checksum, - CASE @version WHEN 20260712143000 THEN '2026-07-12T14:30:00' ELSE '2026-07-12T15:00:00' END, - @executionMs); - """; - command.Parameters.AddWithValue("@version", version); - command.Parameters.AddWithValue("@name", name); - command.Parameters.AddWithValue("@checksum", SampleChecksum); - command.Parameters.AddWithValue("@executionMs", executionMs); - await command.ExecuteNonQueryAsync(TestContext.Current.CancellationToken); - } }