From 14d73873677c9b54967e33a0ba76d7b4d48f8158 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 16:50:53 +0100 Subject: [PATCH 1/8] Start M15 prerelease line (0.5.0-preview.3) Co-Authored-By: Claude Opus 4.8 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 9719520..77c34f3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ Luís Amorim - 0.5.0-preview.2 + 0.5.0-preview.3 Apache-2.0 https://github.com/lgamorim/sqlbound https://github.com/lgamorim/sqlbound From 37c71e46c94ef59db7c386614aead88de3d2b375 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 16:57:40 +0100 Subject: [PATCH 2/8] Extract IDatabaseAdmin and a CLI provider resolver Prepare the migrate and database commands to dispatch across providers without changing behaviour (SQL Server stays the only one wired): - Add IDatabaseAdmin in SqlBound.Migrations; SqlServerDatabaseAdmin implements it. - Make transactional DDL a ledger capability (IMigrationLedger. SupportsTransactionalDdl); MigrationRunner wraps each migration in a transaction only when the provider supports it, so a non-transactional provider (MySQL, coming next) applies migrations without one. - Add ProviderServices, a resolver for the connection, ledger, and database administrator per DatabaseTarget; route MigrationCli, DatabaseCommand, and PrepareRunner's connection creation through it. Co-Authored-By: Claude Opus 4.8 --- src/SqlBound.Cli/DatabaseCommand.cs | 24 ++++------ src/SqlBound.Cli/MigrationCli.cs | 22 ++++----- src/SqlBound.Cli/PrepareRunner.cs | 11 +---- src/SqlBound.Cli/ProviderServices.cs | 41 ++++++++++++++++ src/SqlBound.Migrations/IDatabaseAdmin.cs | 21 ++++++++ src/SqlBound.Migrations/IMigrationLedger.cs | 8 ++++ src/SqlBound.Migrations/MigrationRunner.cs | 48 ++++++++++++++++--- .../SqlServerDatabaseAdmin.cs | 13 ++--- .../SqlServerMigrationLedger.cs | 3 ++ 9 files changed, 136 insertions(+), 55 deletions(-) create mode 100644 src/SqlBound.Cli/ProviderServices.cs create mode 100644 src/SqlBound.Migrations/IDatabaseAdmin.cs diff --git a/src/SqlBound.Cli/DatabaseCommand.cs b/src/SqlBound.Cli/DatabaseCommand.cs index 0935053..94bcee1 100644 --- a/src/SqlBound.Cli/DatabaseCommand.cs +++ b/src/SqlBound.Cli/DatabaseCommand.cs @@ -1,13 +1,13 @@ using System.CommandLine; -using Microsoft.Data.SqlClient; -using SqlBound.SqlServer; +using System.Data.Common; +using SqlBound.Migrations; namespace SqlBound.Cli; /// /// Builds the database command tree: create and drop for the target database -/// named by the connection string. SQL Server only in this release; the other providers follow in -/// M15 once the database-lifecycle abstraction is extracted alongside a second implementation. +/// named by the connection string, dispatched to the resolved provider's +/// . /// internal static class DatabaseCommand { @@ -33,7 +33,7 @@ public static Command Build() dropCommand.SetAction((parseResult, cancellationToken) => RunAsync(parseResult, connectionOption, create: false, cancellationToken)); - return new Command("database", "Create or drop the target database (SQL Server only in this release).") + return new Command("database", "Create or drop the target database.") { createCommand, dropCommand, @@ -52,24 +52,18 @@ private static async Task RunAsync( } DatabaseTarget target; + IDatabaseAdmin admin; try { target = DatabaseUrl.Resolve(databaseValue); + admin = ProviderServices.DatabaseAdmin(target.Provider); } - catch (ArgumentException exception) + catch (Exception exception) when (exception is ArgumentException or NotSupportedException) { Console.Error.WriteLine($"error: {exception.Message}"); return 1; } - if (target.Provider != DatabaseProviders.SqlServer) - { - Console.Error.WriteLine( - $"error: 'database {(create ? "create" : "drop")}' currently supports SQL Server only."); - return 1; - } - - var admin = new SqlServerDatabaseAdmin(); try { var name = create @@ -78,7 +72,7 @@ private static async Task RunAsync( Console.Out.WriteLine(create ? $"database '{name}' is ready." : $"database '{name}' is dropped."); return 0; } - catch (Exception exception) when (exception is SqlException or ArgumentException) + catch (Exception exception) when (exception is DbException or ArgumentException) { Console.Error.WriteLine($"error: {exception.Message}"); return 1; diff --git a/src/SqlBound.Cli/MigrationCli.cs b/src/SqlBound.Cli/MigrationCli.cs index 87c636d..a851805 100644 --- a/src/SqlBound.Cli/MigrationCli.cs +++ b/src/SqlBound.Cli/MigrationCli.cs @@ -1,15 +1,13 @@ 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. +/// provider's ledger, and invoke the command's action — translating every expected failure into an +/// error message and a non-zero exit code. /// internal static class MigrationCli { @@ -27,22 +25,18 @@ public static async Task ExecuteAsync( } DatabaseTarget target; + IMigrationLedger ledger; try { target = DatabaseUrl.Resolve(databaseValue); + ledger = ProviderServices.Ledger(target.Provider); } - catch (ArgumentException exception) + catch (Exception exception) when (exception is ArgumentException or NotSupportedException) { 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 { @@ -54,14 +48,14 @@ public static async Task ExecuteAsync( return 1; } - var connection = new SqlConnection(target.ConnectionString); + var connection = ProviderServices.CreateConnection(target); await using (connection.ConfigureAwait(false)) { try { await connection.OpenAsync(cancellationToken).ConfigureAwait(false); } - catch (Exception exception) when (exception is SqlException or InvalidOperationException) + catch (Exception exception) when (exception is DbException or InvalidOperationException) { Console.Error.WriteLine($"error: cannot connect to the database: {exception.Message}"); return 1; @@ -69,7 +63,7 @@ public static async Task ExecuteAsync( try { - return await action(connection, new SqlServerMigrationLedger(), migrations).ConfigureAwait(false); + return await action(connection, ledger, migrations).ConfigureAwait(false); } catch (Exception exception) when (exception is MigrationInconsistencyException or MigrationExecutionException or DbException) diff --git a/src/SqlBound.Cli/PrepareRunner.cs b/src/SqlBound.Cli/PrepareRunner.cs index 21fe3c3..38f51be 100644 --- a/src/SqlBound.Cli/PrepareRunner.cs +++ b/src/SqlBound.Cli/PrepareRunner.cs @@ -1,4 +1,3 @@ -using System.Data.Common; using Microsoft.Data.Sqlite; using Microsoft.Data.SqlClient; using MySqlConnector; @@ -40,7 +39,7 @@ public static async Task RunAsync( output.WriteLine($"warning: {warning}"); } - var connection = CreateConnection(target); + var connection = ProviderServices.CreateConnection(target); await using (connection.ConfigureAwait(false)) { try @@ -117,14 +116,6 @@ public static async Task RunAsync( } } - private static DbConnection CreateConnection(DatabaseTarget target) => target.Provider switch - { - DatabaseProviders.Sqlite => new SqliteConnection(target.ConnectionString), - DatabaseProviders.Postgres => new NpgsqlConnection(target.ConnectionString), - DatabaseProviders.MySql => new MySqlConnection(target.ConnectionString), - _ => new SqlConnection(target.ConnectionString), - }; - private static IQueryDescriber CreateDescriber(DatabaseTarget target) => target.Provider switch { DatabaseProviders.Sqlite => new SqliteQueryDescriber(), diff --git a/src/SqlBound.Cli/ProviderServices.cs b/src/SqlBound.Cli/ProviderServices.cs new file mode 100644 index 0000000..f0e5c6d --- /dev/null +++ b/src/SqlBound.Cli/ProviderServices.cs @@ -0,0 +1,41 @@ +using System.Data.Common; +using Microsoft.Data.Sqlite; +using Microsoft.Data.SqlClient; +using MySqlConnector; +using Npgsql; +using SqlBound.Migrations; +using SqlBound.SqlServer; + +namespace SqlBound.Cli; + +/// +/// Resolves the per-provider services the connected commands need: the ADO.NET connection, the +/// migration ledger, and the database administrator. Connection creation covers every provider (the +/// drivers ship in the CLI); the ledger and administrator are wired provider by provider as M15 +/// lands them, with a clear error for any not yet supported. +/// +internal static class ProviderServices +{ + public static DbConnection CreateConnection(DatabaseTarget target) => target.Provider switch + { + DatabaseProviders.Sqlite => new SqliteConnection(target.ConnectionString), + DatabaseProviders.Postgres => new NpgsqlConnection(target.ConnectionString), + DatabaseProviders.MySql => new MySqlConnection(target.ConnectionString), + _ => new SqlConnection(target.ConnectionString), + }; + + public static IMigrationLedger Ledger(string provider) => provider switch + { + DatabaseProviders.SqlServer => new SqlServerMigrationLedger(), + _ => throw Unsupported(provider), + }; + + public static IDatabaseAdmin DatabaseAdmin(string provider) => provider switch + { + DatabaseProviders.SqlServer => new SqlServerDatabaseAdmin(), + _ => throw Unsupported(provider), + }; + + private static NotSupportedException Unsupported(string provider) => + new($"the '{provider}' provider is not supported yet."); +} diff --git a/src/SqlBound.Migrations/IDatabaseAdmin.cs b/src/SqlBound.Migrations/IDatabaseAdmin.cs new file mode 100644 index 0000000..88fb75a --- /dev/null +++ b/src/SqlBound.Migrations/IDatabaseAdmin.cs @@ -0,0 +1,21 @@ +namespace SqlBound.Migrations; + +/// +/// Creates and drops the database named by a connection string. Implementations connect to a +/// maintenance database (or, for a file-based engine, act on the file) and guard against a +/// connection string that names no database. Used by the CLI database command. +/// +public interface IDatabaseAdmin +{ + /// Creates the target database if it does not already exist. + /// A connection string whose target database is to be created. + /// A token to cancel the operation. + /// The name of the target database. + Task CreateAsync(string connectionString, CancellationToken cancellationToken); + + /// Drops the target database if it exists. + /// A connection string whose target database is to be dropped. + /// A token to cancel the operation. + /// The name of the target database. + Task DropAsync(string connectionString, CancellationToken cancellationToken); +} diff --git a/src/SqlBound.Migrations/IMigrationLedger.cs b/src/SqlBound.Migrations/IMigrationLedger.cs index 5f16fcc..ec4da31 100644 --- a/src/SqlBound.Migrations/IMigrationLedger.cs +++ b/src/SqlBound.Migrations/IMigrationLedger.cs @@ -10,6 +10,14 @@ namespace SqlBound.Migrations; /// public interface IMigrationLedger { + /// + /// Whether the provider applies schema changes within a transaction. When , + /// migrate run applies each migration without wrapping it in a transaction, because the + /// provider commits DDL implicitly (as MySQL does) — so a mid-migration failure cannot be + /// rolled back. for SQL Server, PostgreSQL, and SQLite. + /// + bool SupportsTransactionalDdl { get; } + /// Creates the ledger table if it does not already exist. Idempotent. /// An open connection to the target database. /// A token to cancel the operation. diff --git a/src/SqlBound.Migrations/MigrationRunner.cs b/src/SqlBound.Migrations/MigrationRunner.cs index 106eed7..f61d86d 100644 --- a/src/SqlBound.Migrations/MigrationRunner.cs +++ b/src/SqlBound.Migrations/MigrationRunner.cs @@ -65,20 +65,37 @@ public static async Task> RunAsync( return null; } - await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + DbTransaction? transaction = ledger.SupportsTransactionalDdl + ? await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false) + : null; 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); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + } + return target; } catch (DbException exception) { - await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); + if (transaction is not null) + { + await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); + } + throw new MigrationExecutionException(target.Version, target.Name, exception); } + finally + { + if (transaction is not null) + { + await transaction.DisposeAsync().ConfigureAwait(false); + } + } } /// Reports each migration's state relative to the ledger, without changing anything. @@ -105,7 +122,9 @@ private static async Task ApplyAsync( TimeProvider timeProvider, CancellationToken cancellationToken) { - await using var transaction = await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false); + DbTransaction? transaction = ledger.SupportsTransactionalDdl + ? await connection.BeginTransactionAsync(cancellationToken).ConfigureAwait(false) + : null; try { var stopwatch = Stopwatch.StartNew(); @@ -119,18 +138,33 @@ private static async Task ApplyAsync( timeProvider.GetUtcNow().UtcDateTime, stopwatch.ElapsedMilliseconds); await ledger.RecordAppliedAsync(connection, transaction, record, cancellationToken).ConfigureAwait(false); - await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken).ConfigureAwait(false); + } + return record; } catch (DbException exception) { - await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); + if (transaction is not null) + { + await transaction.RollbackAsync(cancellationToken).ConfigureAwait(false); + } + throw new MigrationExecutionException(migration.Version, migration.Name, exception); } + finally + { + if (transaction is not null) + { + await transaction.DisposeAsync().ConfigureAwait(false); + } + } } private static async Task ExecuteScriptAsync( - DbConnection connection, DbTransaction transaction, string script, CancellationToken cancellationToken) + DbConnection connection, DbTransaction? transaction, string script, CancellationToken cancellationToken) { await using var command = connection.CreateCommand(); command.Transaction = transaction; diff --git a/src/SqlBound.SqlServer/SqlServerDatabaseAdmin.cs b/src/SqlBound.SqlServer/SqlServerDatabaseAdmin.cs index 3096954..549baaa 100644 --- a/src/SqlBound.SqlServer/SqlServerDatabaseAdmin.cs +++ b/src/SqlBound.SqlServer/SqlServerDatabaseAdmin.cs @@ -1,4 +1,5 @@ using Microsoft.Data.SqlClient; +using SqlBound.Migrations; namespace SqlBound.SqlServer; @@ -8,14 +9,11 @@ namespace SqlBound.SqlServer; /// connection string that names no database or names a system database. The target name is bracketed /// with QUOTENAME server-side, so it can never be interpreted as SQL. /// -public sealed class SqlServerDatabaseAdmin +public sealed class SqlServerDatabaseAdmin : IDatabaseAdmin { private static readonly string[] SystemDatabases = ["master", "model", "msdb", "tempdb"]; - /// Creates the target database if it does not already exist. - /// A connection string whose Initial Catalog is the database to create. - /// A token to cancel the operation. - /// The name of the target database. + /// public Task CreateAsync(string connectionString, CancellationToken cancellationToken) => ExecuteAgainstMasterAsync( connectionString, @@ -28,10 +26,7 @@ IF DB_ID(@name) IS NULL """, cancellationToken); - /// Drops the target database if it exists, forcing out any open connections first. - /// A connection string whose Initial Catalog is the database to drop. - /// A token to cancel the operation. - /// The name of the target database. + /// public Task DropAsync(string connectionString, CancellationToken cancellationToken) => ExecuteAgainstMasterAsync( connectionString, diff --git a/src/SqlBound.SqlServer/SqlServerMigrationLedger.cs b/src/SqlBound.SqlServer/SqlServerMigrationLedger.cs index d574a44..35eb86e 100644 --- a/src/SqlBound.SqlServer/SqlServerMigrationLedger.cs +++ b/src/SqlBound.SqlServer/SqlServerMigrationLedger.cs @@ -13,6 +13,9 @@ public sealed class SqlServerMigrationLedger : IMigrationLedger { private const string TableName = "_sqlbound_migrations"; + /// + public bool SupportsTransactionalDdl => true; + /// public async Task EnsureCreatedAsync(DbConnection connection, CancellationToken cancellationToken) { From 66864b6c7d9b6748fdbf06f7971403d8749062e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 17:01:24 +0100 Subject: [PATCH 3/8] Add SQLite migration ledger and database admin SqliteMigrationLedger stores the history in _sqlbound_migrations (with applied_on_utc as ISO-8601 text, since SQLite has no date type) and reports transactional DDL. SqliteDatabaseAdmin treats the Data Source file as the database: create materializes the file, drop deletes it. Both wired into the CLI provider resolver, so migrate run/revert/status and database create/drop now work against SQLite. Co-Authored-By: Claude Opus 4.8 --- src/SqlBound.Cli/DatabaseCommand.cs | 2 +- src/SqlBound.Cli/ProviderServices.cs | 3 + src/SqlBound.Sqlite/SqlBound.Sqlite.csproj | 1 + src/SqlBound.Sqlite/SqliteDatabaseAdmin.cs | 48 ++++++++++ src/SqlBound.Sqlite/SqliteMigrationLedger.cs | 93 +++++++++++++++++++ .../SqliteDatabaseAdminTests.cs | 56 +++++++++++ .../SqliteMigrationLedgerTests.cs | 72 ++++++++++++++ .../SqliteMigrationRunnerTests.cs | 62 +++++++++++++ 8 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 src/SqlBound.Sqlite/SqliteDatabaseAdmin.cs create mode 100644 src/SqlBound.Sqlite/SqliteMigrationLedger.cs create mode 100644 test/SqlBound.Sqlite.IntegrationTests/SqliteDatabaseAdminTests.cs create mode 100644 test/SqlBound.Sqlite.IntegrationTests/SqliteMigrationLedgerTests.cs create mode 100644 test/SqlBound.Sqlite.IntegrationTests/SqliteMigrationRunnerTests.cs diff --git a/src/SqlBound.Cli/DatabaseCommand.cs b/src/SqlBound.Cli/DatabaseCommand.cs index 94bcee1..4aec8df 100644 --- a/src/SqlBound.Cli/DatabaseCommand.cs +++ b/src/SqlBound.Cli/DatabaseCommand.cs @@ -72,7 +72,7 @@ private static async Task RunAsync( Console.Out.WriteLine(create ? $"database '{name}' is ready." : $"database '{name}' is dropped."); return 0; } - catch (Exception exception) when (exception is DbException or ArgumentException) + catch (Exception exception) when (exception is DbException or ArgumentException or IOException) { Console.Error.WriteLine($"error: {exception.Message}"); return 1; diff --git a/src/SqlBound.Cli/ProviderServices.cs b/src/SqlBound.Cli/ProviderServices.cs index f0e5c6d..b646063 100644 --- a/src/SqlBound.Cli/ProviderServices.cs +++ b/src/SqlBound.Cli/ProviderServices.cs @@ -4,6 +4,7 @@ using MySqlConnector; using Npgsql; using SqlBound.Migrations; +using SqlBound.Sqlite; using SqlBound.SqlServer; namespace SqlBound.Cli; @@ -27,12 +28,14 @@ internal static class ProviderServices public static IMigrationLedger Ledger(string provider) => provider switch { DatabaseProviders.SqlServer => new SqlServerMigrationLedger(), + DatabaseProviders.Sqlite => new SqliteMigrationLedger(), _ => throw Unsupported(provider), }; public static IDatabaseAdmin DatabaseAdmin(string provider) => provider switch { DatabaseProviders.SqlServer => new SqlServerDatabaseAdmin(), + DatabaseProviders.Sqlite => new SqliteDatabaseAdmin(), _ => throw Unsupported(provider), }; diff --git a/src/SqlBound.Sqlite/SqlBound.Sqlite.csproj b/src/SqlBound.Sqlite/SqlBound.Sqlite.csproj index d8234d9..1a6c51b 100644 --- a/src/SqlBound.Sqlite/SqlBound.Sqlite.csproj +++ b/src/SqlBound.Sqlite/SqlBound.Sqlite.csproj @@ -12,6 +12,7 @@ + diff --git a/src/SqlBound.Sqlite/SqliteDatabaseAdmin.cs b/src/SqlBound.Sqlite/SqliteDatabaseAdmin.cs new file mode 100644 index 0000000..d07250e --- /dev/null +++ b/src/SqlBound.Sqlite/SqliteDatabaseAdmin.cs @@ -0,0 +1,48 @@ +using Microsoft.Data.Sqlite; +using SqlBound.Migrations; + +namespace SqlBound.Sqlite; + +/// +/// The SQLite implementation of . SQLite has no server: the "database" +/// is the file named by the connection string's Data Source. Creating it opens a connection +/// (which materializes the file); dropping it deletes the file. Both are idempotent. +/// +public sealed class SqliteDatabaseAdmin : IDatabaseAdmin +{ + /// + public async Task CreateAsync(string connectionString, CancellationToken cancellationToken) + { + var dataSource = DataSourceOf(connectionString); + await using var connection = new SqliteConnection(connectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + return dataSource; + } + + /// + public Task DropAsync(string connectionString, CancellationToken cancellationToken) + { + var dataSource = DataSourceOf(connectionString); + + // Release any pooled handles so the file is not locked when we delete it. + SqliteConnection.ClearAllPools(); + if (File.Exists(dataSource)) + { + File.Delete(dataSource); + } + + return Task.FromResult(dataSource); + } + + private static string DataSourceOf(string connectionString) + { + var dataSource = new SqliteConnectionStringBuilder(connectionString).DataSource; + if (string.IsNullOrWhiteSpace(dataSource)) + { + throw new ArgumentException( + "The connection string names no database (set Data Source).", nameof(connectionString)); + } + + return dataSource; + } +} diff --git a/src/SqlBound.Sqlite/SqliteMigrationLedger.cs b/src/SqlBound.Sqlite/SqliteMigrationLedger.cs new file mode 100644 index 0000000..408fe13 --- /dev/null +++ b/src/SqlBound.Sqlite/SqliteMigrationLedger.cs @@ -0,0 +1,93 @@ +using System.Data.Common; +using Microsoft.Data.Sqlite; +using SqlBound.Migrations; + +namespace SqlBound.Sqlite; + +/// +/// The SQLite implementation of . The ledger lives in the +/// _sqlbound_migrations table. SQLite has no date/time type, so applied_on_utc is +/// stored as ISO-8601 text; Microsoft.Data.Sqlite round-trips it through . +/// SQLite applies DDL within a transaction, so migrations are transactional. +/// +public sealed class SqliteMigrationLedger : IMigrationLedger +{ + private const string TableName = "_sqlbound_migrations"; + + /// + public bool SupportsTransactionalDdl => true; + + /// + public async Task EnsureCreatedAsync(DbConnection connection, CancellationToken cancellationToken) + { + await using var command = AsSqliteConnection(connection).CreateCommand(); + command.CommandText = + $""" + CREATE TABLE IF NOT EXISTS {TableName} ( + version INTEGER NOT NULL PRIMARY KEY, + name TEXT NOT NULL, + checksum TEXT NOT NULL, + applied_on_utc TEXT NOT NULL, + execution_ms INTEGER NOT NULL); + """; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> GetAppliedAsync( + DbConnection connection, CancellationToken cancellationToken) + { + await using var command = AsSqliteConnection(connection).CreateCommand(); + command.CommandText = + $"SELECT version, name, checksum, applied_on_utc, execution_ms FROM {TableName} ORDER BY version;"; + + var applied = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + applied.Add(new AppliedMigration( + reader.GetInt64(0), + reader.GetString(1), + reader.GetString(2), + reader.GetDateTime(3), + reader.GetInt64(4))); + } + + return applied; + } + + /// + public async Task RecordAppliedAsync( + DbConnection connection, DbTransaction? transaction, AppliedMigration migration, CancellationToken cancellationToken) + { + await using var command = AsSqliteConnection(connection).CreateCommand(); + command.Transaction = (SqliteTransaction?)transaction; + command.CommandText = + $""" + INSERT INTO {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 = AsSqliteConnection(connection).CreateCommand(); + command.Transaction = (SqliteTransaction?)transaction; + command.CommandText = $"DELETE FROM {TableName} WHERE version = @version;"; + command.Parameters.AddWithValue("@version", version); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private static SqliteConnection AsSqliteConnection(DbConnection connection) => + connection as SqliteConnection + ?? throw new ArgumentException( + $"The SQLite migration ledger requires a {nameof(SqliteConnection)}.", nameof(connection)); +} diff --git a/test/SqlBound.Sqlite.IntegrationTests/SqliteDatabaseAdminTests.cs b/test/SqlBound.Sqlite.IntegrationTests/SqliteDatabaseAdminTests.cs new file mode 100644 index 0000000..141117e --- /dev/null +++ b/test/SqlBound.Sqlite.IntegrationTests/SqliteDatabaseAdminTests.cs @@ -0,0 +1,56 @@ +using Microsoft.Data.Sqlite; + +namespace SqlBound.Sqlite.IntegrationTests; + +/// +/// Exercises : for SQLite, creating a database materializes its +/// file and dropping it deletes the file. +/// +public sealed class SqliteDatabaseAdminTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"sqlbound-admin-{Guid.NewGuid():N}.db"); + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + if (File.Exists(_path)) + { + File.Delete(_path); + } + } + + [Fact] + public async Task Should_CreateDatabaseFile_When_Create() + { + await new SqliteDatabaseAdmin().CreateAsync(ConnectionString, Token); + + Assert.True(File.Exists(_path)); + } + + [Fact] + public async Task Should_DeleteDatabaseFile_When_Drop() + { + await new SqliteDatabaseAdmin().CreateAsync(ConnectionString, Token); + + await new SqliteDatabaseAdmin().DropAsync(ConnectionString, Token); + + Assert.False(File.Exists(_path)); + } + + [Fact] + public async Task Should_NotThrow_When_DropCalledForMissingFile() + { + await new SqliteDatabaseAdmin().DropAsync(ConnectionString, Token); + } + + [Fact] + public async Task Should_ThrowArgumentException_When_ConnectionStringNamesNoDataSource() + { + await Assert.ThrowsAsync( + () => new SqliteDatabaseAdmin().CreateAsync(new SqliteConnectionStringBuilder().ConnectionString, Token)); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private string ConnectionString => new SqliteConnectionStringBuilder { DataSource = _path }.ConnectionString; +} diff --git a/test/SqlBound.Sqlite.IntegrationTests/SqliteMigrationLedgerTests.cs b/test/SqlBound.Sqlite.IntegrationTests/SqliteMigrationLedgerTests.cs new file mode 100644 index 0000000..dddc0ba --- /dev/null +++ b/test/SqlBound.Sqlite.IntegrationTests/SqliteMigrationLedgerTests.cs @@ -0,0 +1,72 @@ +using Microsoft.Data.Sqlite; +using SqlBound.Migrations; + +namespace SqlBound.Sqlite.IntegrationTests; + +/// +/// Exercises against a real temp-file database — no Docker, no +/// shared fixture, one file per test instance. +/// +public sealed class SqliteMigrationLedgerTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"sqlbound-ledger-{Guid.NewGuid():N}.db"); + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + if (File.Exists(_path)) + { + File.Delete(_path); + } + } + + [Fact] + public async Task Should_CreateLedgerAndReadEmpty_When_NoMigrationsApplied() + { + await using var connection = await OpenAsync(); + var ledger = new SqliteMigrationLedger(); + + await ledger.EnsureCreatedAsync(connection, Token); + + Assert.Empty(await ledger.GetAppliedAsync(connection, Token)); + } + + [Fact] + public async Task Should_RecordAppliedMigrationsAndReadThemBackOrderedByVersion() + { + await using var connection = await OpenAsync(); + var ledger = new SqliteMigrationLedger(); + await ledger.EnsureCreatedAsync(connection, Token); + var second = new AppliedMigration(20260712150000, "second", "bbb", new DateTime(2026, 7, 12, 15, 0, 0), 12); + var first = new AppliedMigration(20260712143000, "first", "aaa", new DateTime(2026, 7, 12, 14, 30, 0), 7); + await ledger.RecordAppliedAsync(connection, null, second, Token); + await ledger.RecordAppliedAsync(connection, null, first, Token); + + Assert.Equal([first, second], await ledger.GetAppliedAsync(connection, Token)); + } + + [Fact] + public async Task Should_RemoveOnlyTheNamedMigration_When_RemoveCalled() + { + await using var connection = await OpenAsync(); + var ledger = new SqliteMigrationLedger(); + await ledger.EnsureCreatedAsync(connection, Token); + var first = new AppliedMigration(20260712143000, "first", "aaa", new DateTime(2026, 7, 12, 14, 30, 0), 7); + var second = new AppliedMigration(20260712150000, "second", "bbb", new DateTime(2026, 7, 12, 15, 0, 0), 12); + await ledger.RecordAppliedAsync(connection, null, first, Token); + await ledger.RecordAppliedAsync(connection, null, second, Token); + + await ledger.RemoveAsync(connection, null, second.Version, Token); + + Assert.Equal([first], await ledger.GetAppliedAsync(connection, Token)); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private async Task OpenAsync() + { + var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = _path }.ConnectionString); + await connection.OpenAsync(Token); + return connection; + } +} diff --git a/test/SqlBound.Sqlite.IntegrationTests/SqliteMigrationRunnerTests.cs b/test/SqlBound.Sqlite.IntegrationTests/SqliteMigrationRunnerTests.cs new file mode 100644 index 0000000..cda8cb0 --- /dev/null +++ b/test/SqlBound.Sqlite.IntegrationTests/SqliteMigrationRunnerTests.cs @@ -0,0 +1,62 @@ +using Microsoft.Data.Sqlite; +using SqlBound.Migrations; + +namespace SqlBound.Sqlite.IntegrationTests; + +/// +/// End-to-end run and revert against SQLite through , proving the +/// provider-neutral engine drives the SQLite ledger and transactional DDL correctly. +/// +public sealed class SqliteMigrationRunnerTests : IDisposable +{ + private readonly string _path = Path.Combine(Path.GetTempPath(), $"sqlbound-runner-{Guid.NewGuid():N}.db"); + + public void Dispose() + { + SqliteConnection.ClearAllPools(); + if (File.Exists(_path)) + { + File.Delete(_path); + } + } + + [Fact] + public async Task Should_ApplyThenRevertMigration() + { + await using var connection = await OpenAsync(); + var ledger = new SqliteMigrationLedger(); + var migrations = new[] + { + new Migration(20260712100000, "create_widgets", + "CREATE TABLE widgets (id INTEGER PRIMARY KEY);", "DROP TABLE widgets;", "aaa"), + }; + + var applied = await MigrationRunner.RunAsync(connection, ledger, migrations, TimeProvider.System, Token); + + Assert.Single(applied); + Assert.True(await TableExistsAsync(connection, "widgets")); + + var reverted = await MigrationRunner.RevertAsync(connection, ledger, migrations, Token); + + Assert.Equal(20260712100000, reverted!.Version); + Assert.False(await TableExistsAsync(connection, "widgets")); + Assert.Empty(await ledger.GetAppliedAsync(connection, Token)); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private async Task OpenAsync() + { + var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = _path }.ConnectionString); + await connection.OpenAsync(Token); + return connection; + } + + private static async Task TableExistsAsync(SqliteConnection connection, string table) + { + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = @name;"; + command.Parameters.AddWithValue("@name", table); + return Convert.ToInt64(await command.ExecuteScalarAsync(Token)) > 0; + } +} From af3d14e06041f327cba69affabdd7af0edfb0058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 17:05:51 +0100 Subject: [PATCH 4/8] Add PostgreSQL migration ledger and database admin NpgsqlMigrationLedger stores the history in _sqlbound_migrations (applied_on_utc as timestamptz, paired with the UTC-kind DateTime the runner produces) and reports transactional DDL. NpgsqlDatabaseAdmin creates and drops the target database from the maintenance 'postgres' database, checking pg_database for idempotent create and using DROP DATABASE ... WITH (FORCE) to evict connections. Both wired into the provider resolver, so migrate and database now work against PostgreSQL. Co-Authored-By: Claude Opus 4.8 --- src/SqlBound.Cli/ProviderServices.cs | 3 + src/SqlBound.Npgsql/NpgsqlDatabaseAdmin.cs | 75 +++++++++++++++ src/SqlBound.Npgsql/NpgsqlMigrationLedger.cs | 93 +++++++++++++++++++ src/SqlBound.Npgsql/SqlBound.Npgsql.csproj | 1 + .../NpgsqlDatabaseAdminTests.cs | 63 +++++++++++++ .../NpgsqlMigrationLedgerTests.cs | 65 +++++++++++++ .../NpgsqlMigrationRunnerTests.cs | 51 ++++++++++ .../PostgresFixture.cs | 11 ++- 8 files changed, 359 insertions(+), 3 deletions(-) create mode 100644 src/SqlBound.Npgsql/NpgsqlDatabaseAdmin.cs create mode 100644 src/SqlBound.Npgsql/NpgsqlMigrationLedger.cs create mode 100644 test/SqlBound.Npgsql.IntegrationTests/NpgsqlDatabaseAdminTests.cs create mode 100644 test/SqlBound.Npgsql.IntegrationTests/NpgsqlMigrationLedgerTests.cs create mode 100644 test/SqlBound.Npgsql.IntegrationTests/NpgsqlMigrationRunnerTests.cs diff --git a/src/SqlBound.Cli/ProviderServices.cs b/src/SqlBound.Cli/ProviderServices.cs index b646063..09688ec 100644 --- a/src/SqlBound.Cli/ProviderServices.cs +++ b/src/SqlBound.Cli/ProviderServices.cs @@ -4,6 +4,7 @@ using MySqlConnector; using Npgsql; using SqlBound.Migrations; +using SqlBound.Npgsql; using SqlBound.Sqlite; using SqlBound.SqlServer; @@ -29,6 +30,7 @@ internal static class ProviderServices { DatabaseProviders.SqlServer => new SqlServerMigrationLedger(), DatabaseProviders.Sqlite => new SqliteMigrationLedger(), + DatabaseProviders.Postgres => new NpgsqlMigrationLedger(), _ => throw Unsupported(provider), }; @@ -36,6 +38,7 @@ internal static class ProviderServices { DatabaseProviders.SqlServer => new SqlServerDatabaseAdmin(), DatabaseProviders.Sqlite => new SqliteDatabaseAdmin(), + DatabaseProviders.Postgres => new NpgsqlDatabaseAdmin(), _ => throw Unsupported(provider), }; diff --git a/src/SqlBound.Npgsql/NpgsqlDatabaseAdmin.cs b/src/SqlBound.Npgsql/NpgsqlDatabaseAdmin.cs new file mode 100644 index 0000000..47a752f --- /dev/null +++ b/src/SqlBound.Npgsql/NpgsqlDatabaseAdmin.cs @@ -0,0 +1,75 @@ +using global::Npgsql; +using SqlBound.Migrations; + +namespace SqlBound.Npgsql; + +/// +/// The PostgreSQL implementation of . Connects to the maintenance +/// postgres database to create or drop the target named by the connection string's +/// Database. Both operations are idempotent and refuse a connection string that names no +/// database or a system database. Drop uses WITH (FORCE) to evict open connections. +/// +public sealed class NpgsqlDatabaseAdmin : IDatabaseAdmin +{ + private const string MaintenanceDatabase = "postgres"; + private static readonly string[] SystemDatabases = ["postgres", "template0", "template1"]; + + /// + public async Task CreateAsync(string connectionString, CancellationToken cancellationToken) + { + var database = DatabaseOf(connectionString); + await using var connection = await OpenMaintenanceAsync(connectionString, cancellationToken).ConfigureAwait(false); + + await using var exists = connection.CreateCommand(); + exists.CommandText = "SELECT 1 FROM pg_database WHERE datname = @name;"; + exists.Parameters.AddWithValue("@name", database); + if (await exists.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false) is null) + { + await using var create = connection.CreateCommand(); + create.CommandText = $"CREATE DATABASE {Quote(database)};"; + await create.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + return database; + } + + /// + public async Task DropAsync(string connectionString, CancellationToken cancellationToken) + { + var database = DatabaseOf(connectionString); + await using var connection = await OpenMaintenanceAsync(connectionString, cancellationToken).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = $"DROP DATABASE IF EXISTS {Quote(database)} WITH (FORCE);"; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + return database; + } + + private static async Task OpenMaintenanceAsync( + string connectionString, CancellationToken cancellationToken) + { + var builder = new NpgsqlConnectionStringBuilder(connectionString) { Database = MaintenanceDatabase }; + var connection = new NpgsqlConnection(builder.ConnectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + return connection; + } + + private static string DatabaseOf(string connectionString) + { + var database = new NpgsqlConnectionStringBuilder(connectionString).Database; + if (string.IsNullOrWhiteSpace(database)) + { + throw new ArgumentException( + "The connection string names no database (set Database).", nameof(connectionString)); + } + + if (SystemDatabases.Contains(database, StringComparer.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Refusing to operate on the system database '{database}'.", nameof(connectionString)); + } + + return database; + } + + private static string Quote(string identifier) => $"\"{identifier.Replace("\"", "\"\"")}\""; +} diff --git a/src/SqlBound.Npgsql/NpgsqlMigrationLedger.cs b/src/SqlBound.Npgsql/NpgsqlMigrationLedger.cs new file mode 100644 index 0000000..cbd0cec --- /dev/null +++ b/src/SqlBound.Npgsql/NpgsqlMigrationLedger.cs @@ -0,0 +1,93 @@ +using System.Data.Common; +using global::Npgsql; +using SqlBound.Migrations; + +namespace SqlBound.Npgsql; + +/// +/// The PostgreSQL implementation of . The ledger lives in the +/// _sqlbound_migrations table; applied_on_utc is a timestamptz, which Npgsql +/// pairs with a of . PostgreSQL applies DDL +/// within a transaction, so migrations are transactional. +/// +public sealed class NpgsqlMigrationLedger : IMigrationLedger +{ + private const string TableName = "_sqlbound_migrations"; + + /// + public bool SupportsTransactionalDdl => true; + + /// + public async Task EnsureCreatedAsync(DbConnection connection, CancellationToken cancellationToken) + { + await using var command = AsNpgsqlConnection(connection).CreateCommand(); + command.CommandText = + $""" + CREATE TABLE IF NOT EXISTS {TableName} ( + version bigint NOT NULL PRIMARY KEY, + name text NOT NULL, + checksum varchar(64) NOT NULL, + applied_on_utc timestamptz NOT NULL, + execution_ms bigint NOT NULL); + """; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> GetAppliedAsync( + DbConnection connection, CancellationToken cancellationToken) + { + await using var command = AsNpgsqlConnection(connection).CreateCommand(); + command.CommandText = + $"SELECT version, name, checksum, applied_on_utc, execution_ms FROM {TableName} ORDER BY version;"; + + var applied = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + applied.Add(new AppliedMigration( + reader.GetInt64(0), + reader.GetString(1), + reader.GetString(2), + reader.GetDateTime(3), + reader.GetInt64(4))); + } + + return applied; + } + + /// + public async Task RecordAppliedAsync( + DbConnection connection, DbTransaction? transaction, AppliedMigration migration, CancellationToken cancellationToken) + { + await using var command = AsNpgsqlConnection(connection).CreateCommand(); + command.Transaction = (NpgsqlTransaction?)transaction; + command.CommandText = + $""" + INSERT INTO {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 = AsNpgsqlConnection(connection).CreateCommand(); + command.Transaction = (NpgsqlTransaction?)transaction; + command.CommandText = $"DELETE FROM {TableName} WHERE version = @version;"; + command.Parameters.AddWithValue("@version", version); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private static NpgsqlConnection AsNpgsqlConnection(DbConnection connection) => + connection as NpgsqlConnection + ?? throw new ArgumentException( + $"The PostgreSQL migration ledger requires an {nameof(NpgsqlConnection)}.", nameof(connection)); +} diff --git a/src/SqlBound.Npgsql/SqlBound.Npgsql.csproj b/src/SqlBound.Npgsql/SqlBound.Npgsql.csproj index 476422e..05c3161 100644 --- a/src/SqlBound.Npgsql/SqlBound.Npgsql.csproj +++ b/src/SqlBound.Npgsql/SqlBound.Npgsql.csproj @@ -10,6 +10,7 @@ + diff --git a/test/SqlBound.Npgsql.IntegrationTests/NpgsqlDatabaseAdminTests.cs b/test/SqlBound.Npgsql.IntegrationTests/NpgsqlDatabaseAdminTests.cs new file mode 100644 index 0000000..7d50ca1 --- /dev/null +++ b/test/SqlBound.Npgsql.IntegrationTests/NpgsqlDatabaseAdminTests.cs @@ -0,0 +1,63 @@ +using global::Npgsql; + +namespace SqlBound.Npgsql.IntegrationTests; + +/// +/// Exercises : create and drop a uniquely named database via the +/// maintenance connection. Unique names mean these need no serialization with the ledger tests. +/// +public sealed class NpgsqlDatabaseAdminTests(PostgresFixture fixture) +{ + [Fact] + public async Task Should_CreateThenDropDatabase_When_TargetNamed() + { + var name = $"sqlbound_admin_{Guid.NewGuid():N}"; + var connectionString = ConnectionStringFor(name); + var admin = new NpgsqlDatabaseAdmin(); + + await admin.CreateAsync(connectionString, Token); + Assert.True(await DatabaseExistsAsync(name)); + + await admin.DropAsync(connectionString, Token); + Assert.False(await DatabaseExistsAsync(name)); + } + + [Fact] + public async Task Should_NotThrow_When_CreateCalledForExistingDatabase() + { + var name = $"sqlbound_admin_{Guid.NewGuid():N}"; + var connectionString = ConnectionStringFor(name); + var admin = new NpgsqlDatabaseAdmin(); + + try + { + await admin.CreateAsync(connectionString, Token); + await admin.CreateAsync(connectionString, Token); + Assert.True(await DatabaseExistsAsync(name)); + } + finally + { + await admin.DropAsync(connectionString, Token); + } + } + + [Fact] + public async Task Should_NotThrow_When_DropCalledForMissingDatabase() + { + await new NpgsqlDatabaseAdmin().DropAsync(ConnectionStringFor($"sqlbound_admin_{Guid.NewGuid():N}"), Token); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private string ConnectionStringFor(string database) => + new NpgsqlConnectionStringBuilder(fixture.GetConnectionString()) { Database = database }.ConnectionString; + + private async Task DatabaseExistsAsync(string name) + { + await using var connection = await fixture.OpenConnectionAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT 1 FROM pg_database WHERE datname = @name;"; + command.Parameters.AddWithValue("@name", name); + return await command.ExecuteScalarAsync(Token) is not null; + } +} diff --git a/test/SqlBound.Npgsql.IntegrationTests/NpgsqlMigrationLedgerTests.cs b/test/SqlBound.Npgsql.IntegrationTests/NpgsqlMigrationLedgerTests.cs new file mode 100644 index 0000000..a27fccc --- /dev/null +++ b/test/SqlBound.Npgsql.IntegrationTests/NpgsqlMigrationLedgerTests.cs @@ -0,0 +1,65 @@ +using global::Npgsql; +using SqlBound.Migrations; + +namespace SqlBound.Npgsql.IntegrationTests; + +/// +/// Exercises against the shared container. Ledger and runner +/// tests share the _sqlbound_migrations table, so they run sequentially in one collection; +/// each drops the table first. +/// +[Collection("PostgresMigrations")] +public sealed class NpgsqlMigrationLedgerTests(PostgresFixture fixture) +{ + [Fact] + public async Task Should_CreateLedgerAndReadEmpty_When_NoMigrationsApplied() + { + await using var connection = await ResetAsync(); + var ledger = new NpgsqlMigrationLedger(); + + await ledger.EnsureCreatedAsync(connection, Token); + + Assert.Empty(await ledger.GetAppliedAsync(connection, Token)); + } + + [Fact] + public async Task Should_RecordAppliedMigrationsAndReadThemBackOrderedByVersion() + { + await using var connection = await ResetAsync(); + var ledger = new NpgsqlMigrationLedger(); + await ledger.EnsureCreatedAsync(connection, Token); + var second = new AppliedMigration(20260712150000, "second", "bbb", new DateTime(2026, 7, 12, 15, 0, 0, DateTimeKind.Utc), 12); + var first = new AppliedMigration(20260712143000, "first", "aaa", new DateTime(2026, 7, 12, 14, 30, 0, DateTimeKind.Utc), 7); + await ledger.RecordAppliedAsync(connection, null, second, Token); + await ledger.RecordAppliedAsync(connection, null, first, Token); + + Assert.Equal([first, second], await ledger.GetAppliedAsync(connection, Token)); + } + + [Fact] + public async Task Should_RemoveOnlyTheNamedMigration_When_RemoveCalled() + { + await using var connection = await ResetAsync(); + var ledger = new NpgsqlMigrationLedger(); + await ledger.EnsureCreatedAsync(connection, Token); + var first = new AppliedMigration(20260712143000, "first", "aaa", new DateTime(2026, 7, 12, 14, 30, 0, DateTimeKind.Utc), 7); + var second = new AppliedMigration(20260712150000, "second", "bbb", new DateTime(2026, 7, 12, 15, 0, 0, DateTimeKind.Utc), 12); + await ledger.RecordAppliedAsync(connection, null, first, Token); + await ledger.RecordAppliedAsync(connection, null, second, Token); + + await ledger.RemoveAsync(connection, null, second.Version, Token); + + Assert.Equal([first], await ledger.GetAppliedAsync(connection, Token)); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private async Task ResetAsync() + { + var connection = await fixture.OpenConnectionAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = "DROP TABLE IF EXISTS _sqlbound_migrations;"; + await command.ExecuteNonQueryAsync(Token); + return connection; + } +} diff --git a/test/SqlBound.Npgsql.IntegrationTests/NpgsqlMigrationRunnerTests.cs b/test/SqlBound.Npgsql.IntegrationTests/NpgsqlMigrationRunnerTests.cs new file mode 100644 index 0000000..ff3157f --- /dev/null +++ b/test/SqlBound.Npgsql.IntegrationTests/NpgsqlMigrationRunnerTests.cs @@ -0,0 +1,51 @@ +using global::Npgsql; +using SqlBound.Migrations; + +namespace SqlBound.Npgsql.IntegrationTests; + +/// End-to-end run and revert against PostgreSQL through . +[Collection("PostgresMigrations")] +public sealed class NpgsqlMigrationRunnerTests(PostgresFixture fixture) +{ + [Fact] + public async Task Should_ApplyThenRevertMigration() + { + await using var connection = await ResetAsync("pg_widgets"); + var ledger = new NpgsqlMigrationLedger(); + var migrations = new[] + { + new Migration(20260712100000, "create_widgets", + "CREATE TABLE pg_widgets (id integer PRIMARY KEY);", "DROP TABLE pg_widgets;", "aaa"), + }; + + var applied = await MigrationRunner.RunAsync(connection, ledger, migrations, TimeProvider.System, Token); + + Assert.Single(applied); + Assert.True(await TableExistsAsync(connection, "pg_widgets")); + + var reverted = await MigrationRunner.RevertAsync(connection, ledger, migrations, Token); + + Assert.Equal(20260712100000, reverted!.Version); + Assert.False(await TableExistsAsync(connection, "pg_widgets")); + Assert.Empty(await ledger.GetAppliedAsync(connection, Token)); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private async Task ResetAsync(string table) + { + var connection = await fixture.OpenConnectionAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $"DROP TABLE IF EXISTS _sqlbound_migrations; DROP TABLE IF EXISTS {table};"; + await command.ExecuteNonQueryAsync(Token); + return connection; + } + + private static async Task TableExistsAsync(NpgsqlConnection connection, string table) + { + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT to_regclass(@name) IS NOT NULL;"; + command.Parameters.AddWithValue("@name", table); + return await command.ExecuteScalarAsync(Token) is true; + } +} diff --git a/test/SqlBound.Npgsql.IntegrationTests/PostgresFixture.cs b/test/SqlBound.Npgsql.IntegrationTests/PostgresFixture.cs index 284eaec..037b20a 100644 --- a/test/SqlBound.Npgsql.IntegrationTests/PostgresFixture.cs +++ b/test/SqlBound.Npgsql.IntegrationTests/PostgresFixture.cs @@ -60,6 +60,13 @@ varchar_col character varying(50) NOT NULL, public async ValueTask DisposeAsync() => await _container.DisposeAsync(); public async Task OpenConnectionAsync() + { + var connection = new NpgsqlConnection(GetConnectionString()); + await connection.OpenAsync(); + return connection; + } + + public string GetConnectionString() { if (_startupFailure is not null) { @@ -72,8 +79,6 @@ public async Task OpenConnectionAsync() Assert.Skip($"Postgres container unavailable (is Docker running?): {_startupFailure.Message}"); } - var connection = new NpgsqlConnection(_container.GetConnectionString()); - await connection.OpenAsync(); - return connection; + return _container.GetConnectionString(); } } From 57a36af7dcc725f2daf3b4d0ce45d3fecf3d64bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 17:11:50 +0100 Subject: [PATCH 5/8] Add MySQL migration ledger, database admin, and ADR 0007 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MySqlMigrationLedger stores the history in _sqlbound_migrations and, because MySQL commits DDL implicitly, reports SupportsTransactionalDdl = false — so MigrationRunner applies each migration without a transaction. MySqlDatabaseAdmin creates and drops the target database with CREATE/DROP DATABASE IF (NOT) EXISTS from a no-default-database connection. ADR 0007 records the non-transactional decision. Both wired into the resolver; MySQL now completes the four-provider migrate and database support. Co-Authored-By: Claude Opus 4.8 --- ...0007-mysql-migrations-not-transactional.md | 60 ++++++++++++ src/SqlBound.Cli/ProviderServices.cs | 3 + src/SqlBound.MySql/MySqlDatabaseAdmin.cs | 52 +++++++++++ src/SqlBound.MySql/MySqlMigrationLedger.cs | 93 +++++++++++++++++++ src/SqlBound.MySql/SqlBound.MySql.csproj | 1 + .../MySqlDatabaseAdminTests.cs | 68 ++++++++++++++ .../MySqlFixture.cs | 11 ++- .../MySqlMigrationLedgerTests.cs | 65 +++++++++++++ .../MySqlMigrationRunnerTests.cs | 56 +++++++++++ .../MySqlMigrationLedgerTests.cs | 10 ++ 10 files changed, 416 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0007-mysql-migrations-not-transactional.md create mode 100644 src/SqlBound.MySql/MySqlDatabaseAdmin.cs create mode 100644 src/SqlBound.MySql/MySqlMigrationLedger.cs create mode 100644 test/SqlBound.MySql.IntegrationTests/MySqlDatabaseAdminTests.cs create mode 100644 test/SqlBound.MySql.IntegrationTests/MySqlMigrationLedgerTests.cs create mode 100644 test/SqlBound.MySql.IntegrationTests/MySqlMigrationRunnerTests.cs create mode 100644 test/SqlBound.MySql.UnitTests/MySqlMigrationLedgerTests.cs diff --git a/docs/adr/0007-mysql-migrations-not-transactional.md b/docs/adr/0007-mysql-migrations-not-transactional.md new file mode 100644 index 0000000..e92bd8b --- /dev/null +++ b/docs/adr/0007-mysql-migrations-not-transactional.md @@ -0,0 +1,60 @@ +# 7. MySQL migrations are not transactional + +## Status + +Accepted + +## Context + +M14 established that `migrate run` applies each migration in its own transaction, together with its +`_sqlbound_migrations` ledger row: if a script fails, the transaction rolls back and the migration +leaves neither a partial schema change nor a ledger row. This holds because SQL Server, PostgreSQL, +and SQLite all support **transactional DDL** — a `CREATE TABLE` can be rolled back. + +M15 extends migrations to MySQL, which does not. In MySQL (and MariaDB), most DDL statements — +`CREATE TABLE`, `ALTER TABLE`, `DROP TABLE`, and so on — cause an **implicit commit**: the moment +one runs, any open transaction is committed, and the statement itself cannot be rolled back. +Wrapping a migration's DDL and its ledger insert in a `BEGIN … COMMIT` therefore buys no +atomicity — the DDL has already committed before the ledger row is written, and a failure between +the two cannot be undone. This is a property of the MySQL engine, not of the driver, and every +migration tool that supports MySQL documents the same limitation (Flyway, Liquibase, and others). + +## Decision + +On MySQL, migrations are applied **without a transaction**, and this is surfaced honestly rather +than papered over: + +- `IMigrationLedger` exposes `SupportsTransactionalDdl`. `MySqlMigrationLedger` returns + `false`; the SQL Server, PostgreSQL, and SQLite ledgers return `true`. +- `MigrationRunner` opens a per-migration transaction **only when the provider supports it**. For + MySQL it runs the up-script and then writes the ledger row, both auto-committed. A pretend + transaction is not used, because MySQL's implicit commit would make it silently meaningless. + +The consequence is that a migration that fails partway on MySQL can leave the database in a partial +state, and — if the failure lands between the DDL and the ledger write — an applied change that the +ledger does not record. `migrate run` still stops at the first failure and reports which migration +failed, so the operator can inspect and fix it by hand. + +## Consequences + +**Positive** + +- MySQL is supported honestly: the engine never claims an atomicity guarantee the database cannot + provide. +- The capability lives on the ledger, the per-provider object the runner already holds, so the + engine stays provider-neutral — it asks whether to use a transaction rather than sniffing the + provider. +- SQL Server, PostgreSQL, and SQLite keep full per-migration atomicity, unchanged from M14. + +**Negative / trade-offs** + +- A failed migration on MySQL is not automatically rolled back; recovery may require manual + cleanup. This matches every other MySQL migration tool but is a real difference from the other + three providers. +- Authors targeting MySQL should keep migrations small and, where possible, idempotent, so a + partial failure is easy to reconcile. + +**Follow-up** + +- None planned. This is an inherent MySQL limitation, documented in `docs/migrations.md` alongside + the per-provider matrix rather than worked around. diff --git a/src/SqlBound.Cli/ProviderServices.cs b/src/SqlBound.Cli/ProviderServices.cs index 09688ec..b2e5e7a 100644 --- a/src/SqlBound.Cli/ProviderServices.cs +++ b/src/SqlBound.Cli/ProviderServices.cs @@ -4,6 +4,7 @@ using MySqlConnector; using Npgsql; using SqlBound.Migrations; +using SqlBound.MySql; using SqlBound.Npgsql; using SqlBound.Sqlite; using SqlBound.SqlServer; @@ -31,6 +32,7 @@ internal static class ProviderServices DatabaseProviders.SqlServer => new SqlServerMigrationLedger(), DatabaseProviders.Sqlite => new SqliteMigrationLedger(), DatabaseProviders.Postgres => new NpgsqlMigrationLedger(), + DatabaseProviders.MySql => new MySqlMigrationLedger(), _ => throw Unsupported(provider), }; @@ -39,6 +41,7 @@ internal static class ProviderServices DatabaseProviders.SqlServer => new SqlServerDatabaseAdmin(), DatabaseProviders.Sqlite => new SqliteDatabaseAdmin(), DatabaseProviders.Postgres => new NpgsqlDatabaseAdmin(), + DatabaseProviders.MySql => new MySqlDatabaseAdmin(), _ => throw Unsupported(provider), }; diff --git a/src/SqlBound.MySql/MySqlDatabaseAdmin.cs b/src/SqlBound.MySql/MySqlDatabaseAdmin.cs new file mode 100644 index 0000000..b19f9fe --- /dev/null +++ b/src/SqlBound.MySql/MySqlDatabaseAdmin.cs @@ -0,0 +1,52 @@ +using MySqlConnector; +using SqlBound.Migrations; + +namespace SqlBound.MySql; + +/// +/// The MySQL implementation of . Connects without a default database to +/// create or drop the target named by the connection string's Database, using +/// CREATE/DROP DATABASE IF (NOT) EXISTS for idempotency. Refuses a connection string that +/// names no database or a system database. Identifiers are backtick-quoted. +/// +public sealed class MySqlDatabaseAdmin : IDatabaseAdmin +{ + private static readonly string[] SystemDatabases = + ["mysql", "information_schema", "performance_schema", "sys"]; + + /// + public Task CreateAsync(string connectionString, CancellationToken cancellationToken) => + ExecuteAsync(connectionString, "CREATE DATABASE IF NOT EXISTS", cancellationToken); + + /// + public Task DropAsync(string connectionString, CancellationToken cancellationToken) => + ExecuteAsync(connectionString, "DROP DATABASE IF EXISTS", cancellationToken); + + private static async Task ExecuteAsync( + string connectionString, string statement, CancellationToken cancellationToken) + { + var builder = new MySqlConnectionStringBuilder(connectionString); + var database = builder.Database; + if (string.IsNullOrWhiteSpace(database)) + { + throw new ArgumentException( + "The connection string names no database (set Database).", nameof(connectionString)); + } + + if (SystemDatabases.Contains(database, StringComparer.OrdinalIgnoreCase)) + { + throw new ArgumentException( + $"Refusing to operate on the system database '{database}'.", nameof(connectionString)); + } + + builder.Database = string.Empty; + await using var connection = new MySqlConnection(builder.ConnectionString); + await connection.OpenAsync(cancellationToken).ConfigureAwait(false); + await using var command = connection.CreateCommand(); + command.CommandText = $"{statement} {Quote(database)};"; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + return database; + } + + private static string Quote(string identifier) => $"`{identifier.Replace("`", "``")}`"; +} diff --git a/src/SqlBound.MySql/MySqlMigrationLedger.cs b/src/SqlBound.MySql/MySqlMigrationLedger.cs new file mode 100644 index 0000000..5b81b0a --- /dev/null +++ b/src/SqlBound.MySql/MySqlMigrationLedger.cs @@ -0,0 +1,93 @@ +using System.Data.Common; +using MySqlConnector; +using SqlBound.Migrations; + +namespace SqlBound.MySql; + +/// +/// The MySQL implementation of . The ledger lives in the +/// _sqlbound_migrations table. MySQL commits DDL implicitly (see ADR 0007), so +/// is and migrate run applies +/// each migration without a transaction. +/// +public sealed class MySqlMigrationLedger : IMigrationLedger +{ + private const string TableName = "_sqlbound_migrations"; + + /// + public bool SupportsTransactionalDdl => false; + + /// + public async Task EnsureCreatedAsync(DbConnection connection, CancellationToken cancellationToken) + { + await using var command = AsMySqlConnection(connection).CreateCommand(); + command.CommandText = + $""" + CREATE TABLE IF NOT EXISTS {TableName} ( + version BIGINT NOT NULL PRIMARY KEY, + name VARCHAR(200) NOT NULL, + checksum VARCHAR(64) NOT NULL, + applied_on_utc DATETIME(6) NOT NULL, + execution_ms BIGINT NOT NULL); + """; + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + /// + public async Task> GetAppliedAsync( + DbConnection connection, CancellationToken cancellationToken) + { + await using var command = AsMySqlConnection(connection).CreateCommand(); + command.CommandText = + $"SELECT version, name, checksum, applied_on_utc, execution_ms FROM {TableName} ORDER BY version;"; + + var applied = new List(); + await using var reader = await command.ExecuteReaderAsync(cancellationToken).ConfigureAwait(false); + while (await reader.ReadAsync(cancellationToken).ConfigureAwait(false)) + { + applied.Add(new AppliedMigration( + reader.GetInt64(0), + reader.GetString(1), + reader.GetString(2), + reader.GetDateTime(3), + reader.GetInt64(4))); + } + + return applied; + } + + /// + public async Task RecordAppliedAsync( + DbConnection connection, DbTransaction? transaction, AppliedMigration migration, CancellationToken cancellationToken) + { + await using var command = AsMySqlConnection(connection).CreateCommand(); + command.Transaction = (MySqlTransaction?)transaction; + command.CommandText = + $""" + INSERT INTO {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 = AsMySqlConnection(connection).CreateCommand(); + command.Transaction = (MySqlTransaction?)transaction; + command.CommandText = $"DELETE FROM {TableName} WHERE version = @version;"; + command.Parameters.AddWithValue("@version", version); + await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); + } + + private static MySqlConnection AsMySqlConnection(DbConnection connection) => + connection as MySqlConnection + ?? throw new ArgumentException( + $"The MySQL migration ledger requires a {nameof(MySqlConnection)}.", nameof(connection)); +} diff --git a/src/SqlBound.MySql/SqlBound.MySql.csproj b/src/SqlBound.MySql/SqlBound.MySql.csproj index e12dde3..c613873 100644 --- a/src/SqlBound.MySql/SqlBound.MySql.csproj +++ b/src/SqlBound.MySql/SqlBound.MySql.csproj @@ -10,6 +10,7 @@ + diff --git a/test/SqlBound.MySql.IntegrationTests/MySqlDatabaseAdminTests.cs b/test/SqlBound.MySql.IntegrationTests/MySqlDatabaseAdminTests.cs new file mode 100644 index 0000000..e1a1bf3 --- /dev/null +++ b/test/SqlBound.MySql.IntegrationTests/MySqlDatabaseAdminTests.cs @@ -0,0 +1,68 @@ +using MySqlConnector; + +namespace SqlBound.MySql.IntegrationTests; + +/// +/// Exercises : create and drop a uniquely named database. Creating +/// a database is an administrative operation, so these connect as root (Testcontainers sets +/// the root password to the same value as the default user). Unique names mean these need no +/// serialization with the ledger tests. +/// +public sealed class MySqlDatabaseAdminTests(MySqlFixture fixture) +{ + [Fact] + public async Task Should_CreateThenDropDatabase_When_TargetNamed() + { + var name = $"sqlbound_admin_{Guid.NewGuid():N}"; + var admin = new MySqlDatabaseAdmin(); + + await admin.CreateAsync(RootConnectionStringFor(name), Token); + Assert.True(await DatabaseExistsAsync(name)); + + await admin.DropAsync(RootConnectionStringFor(name), Token); + Assert.False(await DatabaseExistsAsync(name)); + } + + [Fact] + public async Task Should_NotThrow_When_CreateCalledForExistingDatabase() + { + var name = $"sqlbound_admin_{Guid.NewGuid():N}"; + var admin = new MySqlDatabaseAdmin(); + + try + { + await admin.CreateAsync(RootConnectionStringFor(name), Token); + await admin.CreateAsync(RootConnectionStringFor(name), Token); + Assert.True(await DatabaseExistsAsync(name)); + } + finally + { + await admin.DropAsync(RootConnectionStringFor(name), Token); + } + } + + [Fact] + public async Task Should_NotThrow_When_DropCalledForMissingDatabase() + { + await new MySqlDatabaseAdmin().DropAsync(RootConnectionStringFor($"sqlbound_admin_{Guid.NewGuid():N}"), Token); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private string RootConnectionStringFor(string? database) => + new MySqlConnectionStringBuilder(fixture.GetConnectionString()) + { + UserID = "root", + Database = database ?? string.Empty, + }.ConnectionString; + + private async Task DatabaseExistsAsync(string name) + { + await using var connection = new MySqlConnection(RootConnectionStringFor(null)); + await connection.OpenAsync(Token); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM information_schema.schemata WHERE schema_name = @name;"; + command.Parameters.AddWithValue("@name", name); + return Convert.ToInt64(await command.ExecuteScalarAsync(Token)) > 0; + } +} diff --git a/test/SqlBound.MySql.IntegrationTests/MySqlFixture.cs b/test/SqlBound.MySql.IntegrationTests/MySqlFixture.cs index e4bec65..18a0b37 100644 --- a/test/SqlBound.MySql.IntegrationTests/MySqlFixture.cs +++ b/test/SqlBound.MySql.IntegrationTests/MySqlFixture.cs @@ -61,6 +61,13 @@ varchar_col VARCHAR(50) NOT NULL, public async ValueTask DisposeAsync() => await _container.DisposeAsync(); public async Task OpenConnectionAsync() + { + var connection = new MySqlConnection(GetConnectionString()); + await connection.OpenAsync(); + return connection; + } + + public string GetConnectionString() { if (_startupFailure is not null) { @@ -73,8 +80,6 @@ public async Task OpenConnectionAsync() Assert.Skip($"MySQL container unavailable (is Docker running?): {_startupFailure.Message}"); } - var connection = new MySqlConnection(_container.GetConnectionString()); - await connection.OpenAsync(); - return connection; + return _container.GetConnectionString(); } } diff --git a/test/SqlBound.MySql.IntegrationTests/MySqlMigrationLedgerTests.cs b/test/SqlBound.MySql.IntegrationTests/MySqlMigrationLedgerTests.cs new file mode 100644 index 0000000..6b80db3 --- /dev/null +++ b/test/SqlBound.MySql.IntegrationTests/MySqlMigrationLedgerTests.cs @@ -0,0 +1,65 @@ +using MySqlConnector; +using SqlBound.Migrations; + +namespace SqlBound.MySql.IntegrationTests; + +/// +/// Exercises against the shared container. Ledger and runner +/// tests share the _sqlbound_migrations table, so they run sequentially in one collection; +/// each drops the table first. +/// +[Collection("MySqlMigrations")] +public sealed class MySqlMigrationLedgerTests(MySqlFixture fixture) +{ + [Fact] + public async Task Should_CreateLedgerAndReadEmpty_When_NoMigrationsApplied() + { + await using var connection = await ResetAsync(); + var ledger = new MySqlMigrationLedger(); + + await ledger.EnsureCreatedAsync(connection, Token); + + Assert.Empty(await ledger.GetAppliedAsync(connection, Token)); + } + + [Fact] + public async Task Should_RecordAppliedMigrationsAndReadThemBackOrderedByVersion() + { + await using var connection = await ResetAsync(); + var ledger = new MySqlMigrationLedger(); + await ledger.EnsureCreatedAsync(connection, Token); + var second = new AppliedMigration(20260712150000, "second", "bbb", new DateTime(2026, 7, 12, 15, 0, 0), 12); + var first = new AppliedMigration(20260712143000, "first", "aaa", new DateTime(2026, 7, 12, 14, 30, 0), 7); + await ledger.RecordAppliedAsync(connection, null, second, Token); + await ledger.RecordAppliedAsync(connection, null, first, Token); + + Assert.Equal([first, second], await ledger.GetAppliedAsync(connection, Token)); + } + + [Fact] + public async Task Should_RemoveOnlyTheNamedMigration_When_RemoveCalled() + { + await using var connection = await ResetAsync(); + var ledger = new MySqlMigrationLedger(); + await ledger.EnsureCreatedAsync(connection, Token); + var first = new AppliedMigration(20260712143000, "first", "aaa", new DateTime(2026, 7, 12, 14, 30, 0), 7); + var second = new AppliedMigration(20260712150000, "second", "bbb", new DateTime(2026, 7, 12, 15, 0, 0), 12); + await ledger.RecordAppliedAsync(connection, null, first, Token); + await ledger.RecordAppliedAsync(connection, null, second, Token); + + await ledger.RemoveAsync(connection, null, second.Version, Token); + + Assert.Equal([first], await ledger.GetAppliedAsync(connection, Token)); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private async Task ResetAsync() + { + var connection = await fixture.OpenConnectionAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = "DROP TABLE IF EXISTS _sqlbound_migrations;"; + await command.ExecuteNonQueryAsync(Token); + return connection; + } +} diff --git a/test/SqlBound.MySql.IntegrationTests/MySqlMigrationRunnerTests.cs b/test/SqlBound.MySql.IntegrationTests/MySqlMigrationRunnerTests.cs new file mode 100644 index 0000000..17d6dda --- /dev/null +++ b/test/SqlBound.MySql.IntegrationTests/MySqlMigrationRunnerTests.cs @@ -0,0 +1,56 @@ +using MySqlConnector; +using SqlBound.Migrations; + +namespace SqlBound.MySql.IntegrationTests; + +/// +/// End-to-end run and revert against MySQL through . MySQL commits DDL +/// implicitly (ADR 0007), so the runner applies each migration without a transaction; this proves +/// that non-transactional path applies and reverts correctly. +/// +[Collection("MySqlMigrations")] +public sealed class MySqlMigrationRunnerTests(MySqlFixture fixture) +{ + [Fact] + public async Task Should_ApplyThenRevertMigration() + { + await using var connection = await ResetAsync("my_widgets"); + var ledger = new MySqlMigrationLedger(); + var migrations = new[] + { + new Migration(20260712100000, "create_widgets", + "CREATE TABLE my_widgets (id INT PRIMARY KEY);", "DROP TABLE my_widgets;", "aaa"), + }; + + var applied = await MigrationRunner.RunAsync(connection, ledger, migrations, TimeProvider.System, Token); + + Assert.Single(applied); + Assert.True(await TableExistsAsync(connection, "my_widgets")); + + var reverted = await MigrationRunner.RevertAsync(connection, ledger, migrations, Token); + + Assert.Equal(20260712100000, reverted!.Version); + Assert.False(await TableExistsAsync(connection, "my_widgets")); + Assert.Empty(await ledger.GetAppliedAsync(connection, Token)); + } + + private static CancellationToken Token => TestContext.Current.CancellationToken; + + private async Task ResetAsync(string table) + { + var connection = await fixture.OpenConnectionAsync(); + await using var command = connection.CreateCommand(); + command.CommandText = $"DROP TABLE IF EXISTS _sqlbound_migrations; DROP TABLE IF EXISTS {table};"; + await command.ExecuteNonQueryAsync(Token); + return connection; + } + + private static async Task TableExistsAsync(MySqlConnection connection, string table) + { + await using var command = connection.CreateCommand(); + command.CommandText = + "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = @name;"; + command.Parameters.AddWithValue("@name", table); + return Convert.ToInt64(await command.ExecuteScalarAsync(Token)) > 0; + } +} diff --git a/test/SqlBound.MySql.UnitTests/MySqlMigrationLedgerTests.cs b/test/SqlBound.MySql.UnitTests/MySqlMigrationLedgerTests.cs new file mode 100644 index 0000000..43ec733 --- /dev/null +++ b/test/SqlBound.MySql.UnitTests/MySqlMigrationLedgerTests.cs @@ -0,0 +1,10 @@ +namespace SqlBound.MySql.UnitTests; + +public sealed class MySqlMigrationLedgerTests +{ + [Fact] + public void Should_ReportNonTransactionalDdl_Because_MySqlCommitsDdlImplicitly() + { + Assert.False(new MySqlMigrationLedger().SupportsTransactionalDdl); + } +} From a7f17ae4d330be0aef4c3e0aa70a602abbc8b1e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 17:12:53 +0100 Subject: [PATCH 6/8] Document per-provider migration behaviour Update docs/migrations.md now that migrate and database support all four providers: replace the SQL-Server-only notes with a per-provider matrix covering transactional DDL and the create/drop mechanics, and link ADR 0007 for MySQL's non-transactional migrations. Co-Authored-By: Claude Opus 4.8 --- docs/migrations.md | 39 ++++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/docs/migrations.md b/docs/migrations.md index e38d3e7..9c9ed55 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -2,11 +2,12 @@ SqlBound applies schema changes as ordered SQL-file migrations, tracked in a database-side ledger. 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). +recorded in [ADR 0006](adr/0006-migration-file-format.md); the MySQL transactional caveat in +[ADR 0007](adr/0007-mysql-migrations-not-transactional.md). -> **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. +The full command set — `migrate add`, `migrate run`, `migrate revert`, `migrate status`, and +`database create`/`database drop` — works against **SQL Server, SQLite, PostgreSQL, and MySQL**. +The provider is chosen from the connection URL's scheme (see [verification.md](verification.md)). ## File format @@ -79,8 +80,10 @@ dotnet sqlbound migrate run # 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. +- On SQL Server, PostgreSQL, and SQLite, 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, while + every earlier migration stays applied. On **MySQL** there is no such rollback — see the matrix + below and [ADR 0007](adr/0007-mysql-migrations-not-transactional.md). - `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. @@ -90,6 +93,15 @@ dotnet sqlbound migrate run > 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. +#### Per-provider behaviour + +| Provider | Migrations transactional? | `database create` / `drop` | +| --- | --- | --- | +| SQL Server | Yes | Connects to `master`; `QUOTENAME`-quoted; force-drops open connections | +| PostgreSQL | Yes | Connects to `postgres`; drops with `WITH (FORCE)` | +| SQLite | Yes | The `Data Source` file *is* the database: create materializes it, drop deletes it | +| MySQL | **No** — DDL auto-commits, so a failed migration is not rolled back ([ADR 0007](adr/0007-mysql-migrations-not-transactional.md)) | `CREATE`/`DROP DATABASE IF (NOT) EXISTS` from a no-default-database connection | + ### `migrate revert` Rolls back the most recently applied migration by running its down-script: @@ -119,8 +131,8 @@ dotnet sqlbound migrate status ### `database create` / `database drop` -Create or drop the database named by the connection string's `Initial Catalog`, connecting to -`master` to do so: +Create or drop the database named by the connection string, connecting to the provider's +maintenance database (or, for SQLite, acting on the file) to do so: ```bash export SQLBOUND_DATABASE_URL="sqlserver://sa:password@localhost:1433/myapp?TrustServerCertificate=true" @@ -130,8 +142,9 @@ dotnet sqlbound database drop # database 'myapp' is dropped. - `--connection` overrides `SQLBOUND_DATABASE_URL`. - Both are idempotent: `create` does nothing if the database exists, `drop` does nothing if it does - not. `drop` forces out open connections before dropping. -- The target name is bracketed with `QUOTENAME` server-side, so it can never be interpreted as SQL. -- Both refuse a connection string that names no database or names a system database - (`master`, `model`, `msdb`, `tempdb`). -- **SQL Server only in this release.** The other providers follow in M15. + not. The server providers force out open connections before dropping. +- Identifiers are quoted for the provider, so a database name can never be interpreted as SQL. +- All providers refuse a connection string that names no database or names a system database. The + per-provider mechanics are in the matrix above. +- `create`/`drop` are administrative operations: the connection must have the privilege to create + databases (e.g. a privileged/`root` user on the server providers). From 673c58997392c5c0c58b0fecaa0e5564c601f727 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 17:14:43 +0100 Subject: [PATCH 7/8] Close Phase 5: set version to 0.5.0 Drop the prerelease suffix now that migrations and the database lifecycle work across all four providers. This is the commit the v0.5.0 tag will mark; PackageVersion reads a clean 0.5.0. Co-Authored-By: Claude Opus 4.8 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index 77c34f3..696a0cb 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ Luís Amorim - 0.5.0-preview.3 + 0.5.0 Apache-2.0 https://github.com/lgamorim/sqlbound https://github.com/lgamorim/sqlbound From 9af2827118d44aba67136359a14052583da8726b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 17:15:50 +0100 Subject: [PATCH 8/8] Rewrite README for the v0.5.0 feature set Bring the README up to date with all work through Phase 5: v0.5.0 status, the full migration and database command set, a provider support matrix (verification fidelity and migration transactionality per provider), the complete package list including SqlBound.Introspection and .Migrations, the ADR index through 0007, and the roadmap with Phases 1-5 done and only Phase 6 (Ship) remaining. Co-Authored-By: Claude Opus 4.8 --- README.md | 57 ++++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index a571c8c..75c3ea0 100644 --- a/README.md +++ b/README.md @@ -2,11 +2,12 @@ SqlBound is a .NET library providing [SQLx](https://github.com/launchbadge/sqlx)-equivalent functionality for C#: SQL queries verified at compile time against a real database schema, -reflection-free row materialization via a Roslyn incremental source generator, and clean -coexistence with [Dapper](https://github.com/DapperLib/Dapper) in the same project. +reflection-free row materialization via a Roslyn incremental source generator, SQL-file +migrations, and clean coexistence with [Dapper](https://github.com/DapperLib/Dapper) in the same +project. -Status: **v0.3.0** — Phases 1–3 (Bedrock, Codegen, Verification) are complete. Phases 4–6 -(Providers, Migrations & CLI, Ship) are still ahead; see [Roadmap](#roadmap). +Status: **v0.5.0** — Phases 1–5 (Bedrock, Codegen, Verification, Providers, Migrations & CLI) are +complete. Phase 6 (Ship — API freeze and the 1.0 release) is still ahead; see [Roadmap](#roadmap). ## Why @@ -63,16 +64,40 @@ An opt-in, two-stage pipeline (mirroring SQLx's own offline `.sqlx` mode): Projects that never run `prepare` never see a verification diagnostic (opt-in by snapshot presence). See [docs/verification.md](docs/verification.md), [docs/diagnostics.md](docs/diagnostics.md), and [docs/introspection.md](docs/introspection.md) for the full workflow, the diagnostic catalog, -and how SQL Server introspection works today. +and what each provider can and cannot describe. ## 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. `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). +files with a timestamp version (the down file optional per migration) — tracked in a +`_sqlbound_migrations` ledger that checksums each applied script. The `sqlbound` tool drives the +whole lifecycle: + +```bash +dotnet sqlbound migrate add "create items" # scaffold a timestamped up/down pair +dotnet sqlbound migrate run # apply every pending migration, in order +dotnet sqlbound migrate status # applied / pending / drifted / missing +dotnet sqlbound migrate revert # roll back the most recent migration +dotnet sqlbound database create # create or drop the target database +``` + +`migrate run` applies each migration in its own transaction (where the provider supports it), +refusing to proceed on checksum drift or an out-of-order migration. See +[docs/migrations.md](docs/migrations.md) for the file format, the ledger, and per-provider +behaviour. Design decisions are in [ADR 0006](docs/adr/0006-migration-file-format.md) and +[ADR 0007](docs/adr/0007-mysql-migrations-not-transactional.md). + +## Providers + +SQL Server is the pilot; SQLite, PostgreSQL, and MySQL followed. The provider is selected from the +`SQLBOUND_DATABASE_URL` scheme (`sqlserver://`, `sqlite://`, `postgresql://`, `mysql://`). + +| Provider | Verification (`prepare`) | Migrations & `database` | Notes | +| --- | --- | --- | --- | +| SQL Server | Full (columns + parameters) | Yes, transactional | Pilot provider | +| PostgreSQL | Full (columns + parameters) | Yes, transactional | | +| SQLite | Columns only; computed columns rejected ([ADR 0005](docs/adr/0005-sqlite-describe-scope.md)) | Yes, transactional | The `Data Source` file is the database | +| MySQL | Columns only; no parameter typing | Yes, **not** transactional ([ADR 0007](docs/adr/0007-mysql-migrations-not-transactional.md)) | DDL auto-commits | ## Packages @@ -80,9 +105,10 @@ other providers in M15. See [docs/migrations.md](docs/migrations.md). |---|---| | `SqlBound` | Runtime core — attributes, `SqlSession`, dependency-free. | | `SqlBound.Generators` | The incremental source generator and the verification analyzer. Packed as an analyzer, never a runtime dependency. | -| `SqlBound.SqlServer` / `.Sqlite` / `.Npgsql` / `.MySql` | Per-provider introspection and SQL-to-CLR type mapping. SQL Server is the pilot; SQLite, Postgres, and MySQL shipped in Phase 4. | -| `SqlBound.Migrations` | Provider-neutral SQL-file migration model and the `IMigrationLedger` history contract. | -| `SqlBound.Cli` | The `sqlbound` dotnet tool — `prepare`, `migrate add`, and `database create`/`drop`. | +| `SqlBound.Introspection` | Provider-neutral introspection contracts (`IQueryDescriber`). | +| `SqlBound.Migrations` | Provider-neutral migration model, the `IMigrationLedger` history contract, and `IDatabaseAdmin`. | +| `SqlBound.SqlServer` / `.Sqlite` / `.Npgsql` / `.MySql` | Per-provider introspection, type mapping, migration ledger, and database administration. | +| `SqlBound.Cli` | The `sqlbound` dotnet tool — `prepare`, `migrate add`/`run`/`revert`/`status`, and `database create`/`drop`. | ## Design decisions @@ -94,6 +120,7 @@ Architectural decisions are recorded as ADRs in [docs/adr/](docs/adr/): - [0004](docs/adr/0004-prepare-is-cli-only.md) — `prepare` stays a CLI step; an MSBuild task is deferred - [0005](docs/adr/0005-sqlite-describe-scope.md) — SQLite describe stays dry-run-only; computed columns and parameter types are out of scope - [0006](docs/adr/0006-migration-file-format.md) — migrations are paired up/down SQL files with a timestamp version and a checksummed ledger +- [0007](docs/adr/0007-mysql-migrations-not-transactional.md) — MySQL migrations are not transactional, because MySQL commits DDL implicitly ## Performance @@ -116,8 +143,8 @@ minor version: | 2 — Codegen | M4–M6 | Done (`v0.2.0`) | | 3 — Verification | M7–M9 | Done (`v0.3.0`) | | 4 — Providers | M10–M12 | Done (`v0.4.0`) | -| 5 — Migrations & CLI | M13– | In progress | -| 6 — Ship | — | Planned (`v1.0.0`) | +| 5 — Migrations & CLI | M13–M15 | Done (`v0.5.0`) | +| 6 — Ship | M16 | Planned (`v1.0.0`) | ## License