Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

<PropertyGroup Label="Package metadata">
<Authors>Luís Amorim</Authors>
<PackageVersion>0.5.0-preview.1</PackageVersion>
<PackageVersion>0.5.0-preview.2</PackageVersion>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/lgamorim/sqlbound</PackageProjectUrl>
<RepositoryUrl>https://github.com/lgamorim/sqlbound</RepositoryUrl>
Expand Down
8 changes: 5 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
55 changes: 52 additions & 3 deletions docs/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -68,6 +68,55 @@ dotnet sqlbound migrate add "backfill emails" --irreversible
- `--migrations <dir>` 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 <dir>` 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
Expand Down
150 changes: 134 additions & 16 deletions src/SqlBound.Cli/MigrateCommand.cs
Original file line number Diff line number Diff line change
@@ -1,23 +1,30 @@
using System.CommandLine;
using SqlBound.Migrations;

namespace SqlBound.Cli;

/// <summary>
/// Builds the <c>migrate</c> command tree. M13 ships <c>migrate add</c> (scaffold a new migration);
/// <c>run</c>, <c>revert</c>, and <c>status</c> follow in M14.
/// Builds the <c>migrate</c> command tree: <c>add</c> scaffolds a migration, <c>run</c> applies the
/// pending ones. <c>revert</c> and <c>status</c> are added alongside <c>run</c> as M14 proceeds.
/// </summary>
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<string>("name")
{
Description = "A short description of the migration, e.g. \"create items\".",
};
var migrationsOption = new Option<string?>("--migrations")
{
Description = "The migrations directory (default: ./migrations).",
};
var migrationsOption = MigrationsOption();
var irreversibleOption = new Option<bool>("--irreversible")
{
Description = "Scaffold only the up script; the migration cannot be reverted.",
Expand All @@ -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);
Expand All @@ -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<string?> MigrationsOption() =>
new("--migrations") { Description = "The migrations directory (default: ./migrations)." };

private static Option<string?> 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;
}
82 changes: 82 additions & 0 deletions src/SqlBound.Cli/MigrationCli.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
using System.Data.Common;
using Microsoft.Data.SqlClient;
using SqlBound.Migrations;
using SqlBound.SqlServer;

namespace SqlBound.Cli;

/// <summary>
/// Shared plumbing for the connected <c>migrate</c> subcommands (<c>run</c>, <c>revert</c>,
/// <c>status</c>): 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.
/// </summary>
internal static class MigrationCli
{
public static async Task<int> ExecuteAsync(
string? connectionValue,
string migrationsDirectory,
Func<DbConnection, IMigrationLedger, IReadOnlyList<Migration>, Task<int>> 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<Migration> 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;
}
}
}
}
21 changes: 19 additions & 2 deletions src/SqlBound.Migrations/IMigrationLedger.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ namespace SqlBound.Migrations;

/// <summary>
/// The per-provider migration history table (<c>_sqlbound_migrations</c>): the durable record of
/// which migrations have been applied. M13 defines the read side; the write side arrives with
/// <c>migrate run</c>. 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.
/// </summary>
public interface IMigrationLedger
{
Expand All @@ -19,4 +20,20 @@ public interface IMigrationLedger
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>The applied migrations; empty when none have been applied.</returns>
Task<IReadOnlyList<AppliedMigration>> GetAppliedAsync(DbConnection connection, CancellationToken cancellationToken);

/// <summary>Records a migration as applied, on the given transaction.</summary>
/// <param name="connection">An open connection to the target database.</param>
/// <param name="transaction">The transaction the migration's up-script is running on, or <see langword="null"/>.</param>
/// <param name="migration">The migration to record.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
Task RecordAppliedAsync(
DbConnection connection, DbTransaction? transaction, AppliedMigration migration, CancellationToken cancellationToken);

/// <summary>Removes a migration's row from the ledger, on the given transaction.</summary>
/// <param name="connection">An open connection to the target database.</param>
/// <param name="transaction">The transaction the migration's down-script is running on, or <see langword="null"/>.</param>
/// <param name="version">The version of the migration to remove.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
Task RemoveAsync(
DbConnection connection, DbTransaction? transaction, long version, CancellationToken cancellationToken);
}
Loading
Loading