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.2</PackageVersion>
<PackageVersion>0.5.0</PackageVersion>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/lgamorim/sqlbound</PackageProjectUrl>
<RepositoryUrl>https://github.com/lgamorim/sqlbound</RepositoryUrl>
Expand Down
57 changes: 42 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -63,26 +64,51 @@ 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

| Package | Purpose |
|---|---|
| `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

Expand All @@ -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

Expand All @@ -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

Expand Down
60 changes: 60 additions & 0 deletions docs/adr/0007-mysql-migrations-not-transactional.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 26 additions & 13 deletions docs/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand All @@ -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:
Expand Down Expand Up @@ -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"
Expand All @@ -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).
24 changes: 9 additions & 15 deletions src/SqlBound.Cli/DatabaseCommand.cs
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
using System.CommandLine;
using Microsoft.Data.SqlClient;
using SqlBound.SqlServer;
using System.Data.Common;
using SqlBound.Migrations;

namespace SqlBound.Cli;

/// <summary>
/// Builds the <c>database</c> command tree: <c>create</c> and <c>drop</c> 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
/// <see cref="IDatabaseAdmin"/>.
/// </summary>
internal static class DatabaseCommand
{
Expand All @@ -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,
Expand All @@ -52,24 +52,18 @@ private static async Task<int> 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
Expand All @@ -78,7 +72,7 @@ private static async Task<int> 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 or IOException)
{
Console.Error.WriteLine($"error: {exception.Message}");
return 1;
Expand Down
22 changes: 8 additions & 14 deletions src/SqlBound.Cli/MigrationCli.cs
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
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.
/// provider's ledger, and invoke the command's action — translating every expected failure into an
/// error message and a non-zero exit code.
/// </summary>
internal static class MigrationCli
{
Expand All @@ -27,22 +25,18 @@ public static async Task<int> 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<Migration> migrations;
try
{
Expand All @@ -54,22 +48,22 @@ public static async Task<int> 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;
}

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)
Expand Down
Loading
Loading