From 2f6fc86d7f0ea2eb1979af3ebf1b0ea555eaa993 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 20:00:36 +0100 Subject: [PATCH 01/22] Open Phase 6: bump to 1.0.0-preview.1 Phase 6 (Ship) begins the API-freeze and packaging work that leads to the 1.0 release. Start the phase's prerelease line at 1.0.0-preview.1; the phase closes at 1.0.0-rc.1 for review before the 1.0.0 GA promotion. 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 696a0cb..b3fc0d0 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ Luís Amorim - 0.5.0 + 1.0.0-preview.1 Apache-2.0 https://github.com/lgamorim/sqlbound https://github.com/lgamorim/sqlbound From 20f18a602df391e57d85e263ade9707f6a2c71ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 20:05:45 +0100 Subject: [PATCH 02/22] Strong-name all assemblies with a shared key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A strong-named assembly cannot reference an unsigned one, and every peer in the data-access space (EF Core, Dapper, Npgsql, Microsoft.Data.*) is signed; shipping 1.0 unsigned would permanently lock out strong-named consumers, and adding a name later is a binary-breaking change. Fix the identity now. All projects share one committed key (sqlbound.snk) — strong naming is an identity token, not a security boundary, so the key is not a secret. Setting the PublicKey property lets the SDK stamp every InternalsVisibleTo with the friend's key automatically, so the existing test/generator friend grants keep resolving against the signed assemblies. Co-Authored-By: Claude Opus 4.8 --- Directory.Build.props | 13 +++++++++++++ sqlbound.snk | Bin 0 -> 596 bytes 2 files changed, 13 insertions(+) create mode 100644 sqlbound.snk diff --git a/Directory.Build.props b/Directory.Build.props index b3fc0d0..8f67bf3 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,6 +10,19 @@ true + + + true + $(MSBuildThisFileDirectory)sqlbound.snk + 002400000480000094000000060200000024000052534131000400000100010099008ae592c0e403ce8ee0fad0005f95e61c709799847b62d56c375c739ac245fb993ccb7912a3d983024e1478ab13ba15094ae76aac09342324d3746124c97c9ac21cb8b877948400a7b7689f74ab55771620618f0a685fadb784a4a7636f9270647efb8a4fbaded853792609197b6fb2d271f178438f06a77c0746523a97ca + + Luís Amorim 1.0.0-preview.1 diff --git a/sqlbound.snk b/sqlbound.snk new file mode 100644 index 0000000000000000000000000000000000000000..06c750f36e5d14942064797e9a00bdaea3a3b389 GIT binary patch literal 596 zcmV-a0;~N80ssI2qyPX?Q$aES1ONa50097*0E*?3z~lqYj^O&x0AH2n9B`MJgnMGu zY&TqUn!-i--9+Lgu~dg^#q2D?Vvqr;Xx6KP+~BjXi>Lx3E3vuH4uisy`vs{-n;F&8epNUnl$-|HEDP6rX!a)#4 zV#7K%I+nbX7Qt(>@k2icDio$h;?wX`eyL3`e>BW}V@xi@!FnlgjDl($(h9o6*rCAq}X0keSBn8z+Px@w37&5ECo6clHECk0@jl zcP^3 zhQS2rnB8aF8lF>-0z{Mo|7aV1A<^J_Y?j=dHX>Jym0i4L5?ac<$wp-m@TcECK;7$e zqBQ%S-;bPu#9^LRw6eC61=-|A>t0lgnG-cce3T>BcS?EXKSr&gJQ&E%wePysS0D!{ zBn&9>o8-wL(W#cK&T=U|Bm_5xj0vJ5hVQxaJp Date: Sun, 12 Jul 2026 20:11:42 +0100 Subject: [PATCH 03/22] Freeze the public API with declared-surface baselines Adds Microsoft.CodeAnalysis.PublicApiAnalyzers to the seven shipping library packages (opted in via , wired centrally in Directory.Build.targets so it is skipped for tests, the CLI tool, and the Roslyn component). The analyzer fails the build under TreatWarningsAsErrors if the public surface drifts from PublicAPI.{Shipped,Unshipped}.txt, making the post-1.0 SemVer commitment tool-enforced rather than prose. Nothing has shipped to NuGet yet, so the entire 1.0 surface is recorded in Unshipped.txt (with #nullable enable so nullability is tracked); the GA promotion moves it to Shipped.txt. Co-Authored-By: Claude Opus 4.8 --- Directory.Build.targets | 20 +++++ .../PublicAPI.Shipped.txt | 0 .../PublicAPI.Unshipped.txt | 33 +++++++++ .../SqlBound.Introspection.csproj | 1 + src/SqlBound.Migrations/PublicAPI.Shipped.txt | 0 .../PublicAPI.Unshipped.txt | 73 +++++++++++++++++++ .../SqlBound.Migrations.csproj | 1 + src/SqlBound.MySql/PublicAPI.Shipped.txt | 0 src/SqlBound.MySql/PublicAPI.Unshipped.txt | 15 ++++ src/SqlBound.MySql/SqlBound.MySql.csproj | 1 + src/SqlBound.Npgsql/PublicAPI.Shipped.txt | 0 src/SqlBound.Npgsql/PublicAPI.Unshipped.txt | 15 ++++ src/SqlBound.Npgsql/SqlBound.Npgsql.csproj | 1 + src/SqlBound.SqlServer/PublicAPI.Shipped.txt | 0 .../PublicAPI.Unshipped.txt | 15 ++++ .../SqlBound.SqlServer.csproj | 1 + src/SqlBound.Sqlite/PublicAPI.Shipped.txt | 0 src/SqlBound.Sqlite/PublicAPI.Unshipped.txt | 15 ++++ src/SqlBound.Sqlite/SqlBound.Sqlite.csproj | 1 + src/SqlBound/PublicAPI.Shipped.txt | 0 src/SqlBound/PublicAPI.Unshipped.txt | 16 ++++ src/SqlBound/SqlBound.csproj | 1 + 22 files changed, 209 insertions(+) create mode 100644 Directory.Build.targets create mode 100644 src/SqlBound.Introspection/PublicAPI.Shipped.txt create mode 100644 src/SqlBound.Introspection/PublicAPI.Unshipped.txt create mode 100644 src/SqlBound.Migrations/PublicAPI.Shipped.txt create mode 100644 src/SqlBound.Migrations/PublicAPI.Unshipped.txt create mode 100644 src/SqlBound.MySql/PublicAPI.Shipped.txt create mode 100644 src/SqlBound.MySql/PublicAPI.Unshipped.txt create mode 100644 src/SqlBound.Npgsql/PublicAPI.Shipped.txt create mode 100644 src/SqlBound.Npgsql/PublicAPI.Unshipped.txt create mode 100644 src/SqlBound.SqlServer/PublicAPI.Shipped.txt create mode 100644 src/SqlBound.SqlServer/PublicAPI.Unshipped.txt create mode 100644 src/SqlBound.Sqlite/PublicAPI.Shipped.txt create mode 100644 src/SqlBound.Sqlite/PublicAPI.Unshipped.txt create mode 100644 src/SqlBound/PublicAPI.Shipped.txt create mode 100644 src/SqlBound/PublicAPI.Unshipped.txt diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..24b7f01 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/src/SqlBound.Introspection/PublicAPI.Shipped.txt b/src/SqlBound.Introspection/PublicAPI.Shipped.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/SqlBound.Introspection/PublicAPI.Unshipped.txt b/src/SqlBound.Introspection/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..78d4e3e --- /dev/null +++ b/src/SqlBound.Introspection/PublicAPI.Unshipped.txt @@ -0,0 +1,33 @@ +#nullable enable +SqlBound.Introspection.DescribedColumn +SqlBound.Introspection.DescribedColumn.ClrTypeText.get -> string! +SqlBound.Introspection.DescribedColumn.ClrTypeText.init -> void +SqlBound.Introspection.DescribedColumn.DescribedColumn(int Ordinal, string! Name, string! SqlTypeName, string! ClrTypeText, bool IsNullable) -> void +SqlBound.Introspection.DescribedColumn.IsNullable.get -> bool +SqlBound.Introspection.DescribedColumn.IsNullable.init -> void +SqlBound.Introspection.DescribedColumn.Name.get -> string! +SqlBound.Introspection.DescribedColumn.Name.init -> void +SqlBound.Introspection.DescribedColumn.Ordinal.get -> int +SqlBound.Introspection.DescribedColumn.Ordinal.init -> void +SqlBound.Introspection.DescribedColumn.SqlTypeName.get -> string! +SqlBound.Introspection.DescribedColumn.SqlTypeName.init -> void +SqlBound.Introspection.DescribedParameter +SqlBound.Introspection.DescribedParameter.ClrTypeText.get -> string? +SqlBound.Introspection.DescribedParameter.ClrTypeText.init -> void +SqlBound.Introspection.DescribedParameter.DescribedParameter(string! Name, string! SqlTypeName, string? ClrTypeText) -> void +SqlBound.Introspection.DescribedParameter.Name.get -> string! +SqlBound.Introspection.DescribedParameter.Name.init -> void +SqlBound.Introspection.DescribedParameter.SqlTypeName.get -> string! +SqlBound.Introspection.DescribedParameter.SqlTypeName.init -> void +SqlBound.Introspection.IQueryDescriber +SqlBound.Introspection.IQueryDescriber.DescribeAsync(System.Data.Common.DbConnection! connection, string! commandText, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +SqlBound.Introspection.QueryDescription +SqlBound.Introspection.QueryDescription.Columns.get -> System.Collections.Generic.IReadOnlyList! +SqlBound.Introspection.QueryDescription.Columns.init -> void +SqlBound.Introspection.QueryDescription.Parameters.get -> System.Collections.Generic.IReadOnlyList! +SqlBound.Introspection.QueryDescription.Parameters.init -> void +SqlBound.Introspection.QueryDescription.QueryDescription(System.Collections.Generic.IReadOnlyList! Columns, System.Collections.Generic.IReadOnlyList! Parameters) -> void +SqlBound.Introspection.SqlBoundDescribeException +SqlBound.Introspection.SqlBoundDescribeException.CommandText.get -> string! +SqlBound.Introspection.SqlBoundDescribeException.SqlBoundDescribeException(string! message, string! commandText) -> void +SqlBound.Introspection.SqlBoundDescribeException.SqlBoundDescribeException(string! message, string! commandText, System.Exception! innerException) -> void diff --git a/src/SqlBound.Introspection/SqlBound.Introspection.csproj b/src/SqlBound.Introspection/SqlBound.Introspection.csproj index bb9e3f2..6e98257 100644 --- a/src/SqlBound.Introspection/SqlBound.Introspection.csproj +++ b/src/SqlBound.Introspection/SqlBound.Introspection.csproj @@ -3,6 +3,7 @@ Provider-neutral database introspection contracts for SqlBound's prepare step. true + true diff --git a/src/SqlBound.Migrations/PublicAPI.Shipped.txt b/src/SqlBound.Migrations/PublicAPI.Shipped.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/SqlBound.Migrations/PublicAPI.Unshipped.txt b/src/SqlBound.Migrations/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..9d4376f --- /dev/null +++ b/src/SqlBound.Migrations/PublicAPI.Unshipped.txt @@ -0,0 +1,73 @@ +#nullable enable +SqlBound.Migrations.AppliedMigration +SqlBound.Migrations.AppliedMigration.AppliedMigration(long Version, string! Name, string! Checksum, System.DateTime AppliedOnUtc, long ExecutionMs) -> void +SqlBound.Migrations.AppliedMigration.AppliedOnUtc.get -> System.DateTime +SqlBound.Migrations.AppliedMigration.AppliedOnUtc.init -> void +SqlBound.Migrations.AppliedMigration.Checksum.get -> string! +SqlBound.Migrations.AppliedMigration.Checksum.init -> void +SqlBound.Migrations.AppliedMigration.ExecutionMs.get -> long +SqlBound.Migrations.AppliedMigration.ExecutionMs.init -> void +SqlBound.Migrations.AppliedMigration.Name.get -> string! +SqlBound.Migrations.AppliedMigration.Name.init -> void +SqlBound.Migrations.AppliedMigration.Version.get -> long +SqlBound.Migrations.AppliedMigration.Version.init -> void +SqlBound.Migrations.IDatabaseAdmin +SqlBound.Migrations.IDatabaseAdmin.CreateAsync(string! connectionString, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.Migrations.IDatabaseAdmin.DropAsync(string! connectionString, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.Migrations.IMigrationLedger +SqlBound.Migrations.IMigrationLedger.EnsureCreatedAsync(System.Data.Common.DbConnection! connection, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.Migrations.IMigrationLedger.GetAppliedAsync(System.Data.Common.DbConnection! connection, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! +SqlBound.Migrations.IMigrationLedger.RecordAppliedAsync(System.Data.Common.DbConnection! connection, System.Data.Common.DbTransaction? transaction, SqlBound.Migrations.AppliedMigration! migration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.Migrations.IMigrationLedger.RemoveAsync(System.Data.Common.DbConnection! connection, System.Data.Common.DbTransaction? transaction, long version, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.Migrations.IMigrationLedger.SupportsTransactionalDdl.get -> bool +SqlBound.Migrations.Migration +SqlBound.Migrations.Migration.Checksum.get -> string! +SqlBound.Migrations.Migration.Checksum.init -> void +SqlBound.Migrations.Migration.DownScript.get -> string? +SqlBound.Migrations.Migration.DownScript.init -> void +SqlBound.Migrations.Migration.IsReversible.get -> bool +SqlBound.Migrations.Migration.Migration(long Version, string! Name, string! UpScript, string? DownScript, string! Checksum) -> void +SqlBound.Migrations.Migration.Name.get -> string! +SqlBound.Migrations.Migration.Name.init -> void +SqlBound.Migrations.Migration.UpScript.get -> string! +SqlBound.Migrations.Migration.UpScript.init -> void +SqlBound.Migrations.Migration.Version.get -> long +SqlBound.Migrations.Migration.Version.init -> void +SqlBound.Migrations.MigrationDirectory +SqlBound.Migrations.MigrationExecutionException +SqlBound.Migrations.MigrationExecutionException.MigrationExecutionException(long version, string! name, System.Exception! innerException) -> void +SqlBound.Migrations.MigrationExecutionException.Name.get -> string! +SqlBound.Migrations.MigrationExecutionException.Version.get -> long +SqlBound.Migrations.MigrationFormatException +SqlBound.Migrations.MigrationFormatException.MigrationFormatException(string! message) -> void +SqlBound.Migrations.MigrationInconsistencyException +SqlBound.Migrations.MigrationInconsistencyException.MigrationInconsistencyException(string! message) -> void +SqlBound.Migrations.MigrationPlan +SqlBound.Migrations.MigrationPlan.MigrationPlan(System.Collections.Generic.IReadOnlyList! Pending) -> void +SqlBound.Migrations.MigrationPlan.Pending.get -> System.Collections.Generic.IReadOnlyList! +SqlBound.Migrations.MigrationPlan.Pending.init -> void +SqlBound.Migrations.MigrationReverter +SqlBound.Migrations.MigrationRunner +SqlBound.Migrations.MigrationState +SqlBound.Migrations.MigrationState.Applied = 0 -> SqlBound.Migrations.MigrationState +SqlBound.Migrations.MigrationState.Drifted = 2 -> SqlBound.Migrations.MigrationState +SqlBound.Migrations.MigrationState.Missing = 3 -> SqlBound.Migrations.MigrationState +SqlBound.Migrations.MigrationState.Pending = 1 -> SqlBound.Migrations.MigrationState +SqlBound.Migrations.MigrationStatus +SqlBound.Migrations.MigrationStatus.AppliedOnUtc.get -> System.DateTime? +SqlBound.Migrations.MigrationStatus.AppliedOnUtc.init -> void +SqlBound.Migrations.MigrationStatus.MigrationStatus(long Version, string! Name, SqlBound.Migrations.MigrationState State, System.DateTime? AppliedOnUtc) -> void +SqlBound.Migrations.MigrationStatus.Name.get -> string! +SqlBound.Migrations.MigrationStatus.Name.init -> void +SqlBound.Migrations.MigrationStatus.State.get -> SqlBound.Migrations.MigrationState +SqlBound.Migrations.MigrationStatus.State.init -> void +SqlBound.Migrations.MigrationStatus.Version.get -> long +SqlBound.Migrations.MigrationStatus.Version.init -> void +SqlBound.Migrations.MigrationStatusReport +static SqlBound.Migrations.MigrationDirectory.Load(string! path) -> System.Collections.Generic.IReadOnlyList! +static SqlBound.Migrations.MigrationPlan.Create(System.Collections.Generic.IReadOnlyList! migrations, System.Collections.Generic.IReadOnlyList! applied) -> SqlBound.Migrations.MigrationPlan! +static SqlBound.Migrations.MigrationReverter.Plan(System.Collections.Generic.IReadOnlyList! migrations, System.Collections.Generic.IReadOnlyList! applied) -> SqlBound.Migrations.Migration? +static SqlBound.Migrations.MigrationRunner.RevertAsync(System.Data.Common.DbConnection! connection, SqlBound.Migrations.IMigrationLedger! ledger, System.Collections.Generic.IReadOnlyList! migrations, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +static SqlBound.Migrations.MigrationRunner.RunAsync(System.Data.Common.DbConnection! connection, SqlBound.Migrations.IMigrationLedger! ledger, System.Collections.Generic.IReadOnlyList! migrations, System.TimeProvider! timeProvider, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! +static SqlBound.Migrations.MigrationRunner.StatusAsync(System.Data.Common.DbConnection! connection, SqlBound.Migrations.IMigrationLedger! ledger, System.Collections.Generic.IReadOnlyList! migrations, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! +static SqlBound.Migrations.MigrationStatusReport.Build(System.Collections.Generic.IReadOnlyList! migrations, System.Collections.Generic.IReadOnlyList! applied) -> System.Collections.Generic.IReadOnlyList! diff --git a/src/SqlBound.Migrations/SqlBound.Migrations.csproj b/src/SqlBound.Migrations/SqlBound.Migrations.csproj index 75e6b37..dfe3922 100644 --- a/src/SqlBound.Migrations/SqlBound.Migrations.csproj +++ b/src/SqlBound.Migrations/SqlBound.Migrations.csproj @@ -3,6 +3,7 @@ Provider-neutral SQL-file migration model and ledger contracts for SqlBound. true + true diff --git a/src/SqlBound.MySql/PublicAPI.Shipped.txt b/src/SqlBound.MySql/PublicAPI.Shipped.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/SqlBound.MySql/PublicAPI.Unshipped.txt b/src/SqlBound.MySql/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..07cd194 --- /dev/null +++ b/src/SqlBound.MySql/PublicAPI.Unshipped.txt @@ -0,0 +1,15 @@ +#nullable enable +SqlBound.MySql.MySqlDatabaseAdmin +SqlBound.MySql.MySqlDatabaseAdmin.CreateAsync(string! connectionString, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.MySql.MySqlDatabaseAdmin.DropAsync(string! connectionString, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.MySql.MySqlDatabaseAdmin.MySqlDatabaseAdmin() -> void +SqlBound.MySql.MySqlMigrationLedger +SqlBound.MySql.MySqlMigrationLedger.EnsureCreatedAsync(System.Data.Common.DbConnection! connection, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.MySql.MySqlMigrationLedger.GetAppliedAsync(System.Data.Common.DbConnection! connection, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! +SqlBound.MySql.MySqlMigrationLedger.MySqlMigrationLedger() -> void +SqlBound.MySql.MySqlMigrationLedger.RecordAppliedAsync(System.Data.Common.DbConnection! connection, System.Data.Common.DbTransaction? transaction, SqlBound.Migrations.AppliedMigration! migration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.MySql.MySqlMigrationLedger.RemoveAsync(System.Data.Common.DbConnection! connection, System.Data.Common.DbTransaction? transaction, long version, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.MySql.MySqlMigrationLedger.SupportsTransactionalDdl.get -> bool +SqlBound.MySql.MySqlQueryDescriber +SqlBound.MySql.MySqlQueryDescriber.DescribeAsync(System.Data.Common.DbConnection! connection, string! commandText, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +SqlBound.MySql.MySqlQueryDescriber.MySqlQueryDescriber() -> void diff --git a/src/SqlBound.MySql/SqlBound.MySql.csproj b/src/SqlBound.MySql/SqlBound.MySql.csproj index c613873..3b40b99 100644 --- a/src/SqlBound.MySql/SqlBound.MySql.csproj +++ b/src/SqlBound.MySql/SqlBound.MySql.csproj @@ -2,6 +2,7 @@ MySQL introspection and type mapping for SqlBound. + true diff --git a/src/SqlBound.Npgsql/PublicAPI.Shipped.txt b/src/SqlBound.Npgsql/PublicAPI.Shipped.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/SqlBound.Npgsql/PublicAPI.Unshipped.txt b/src/SqlBound.Npgsql/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..f5868b2 --- /dev/null +++ b/src/SqlBound.Npgsql/PublicAPI.Unshipped.txt @@ -0,0 +1,15 @@ +#nullable enable +SqlBound.Npgsql.NpgsqlDatabaseAdmin +SqlBound.Npgsql.NpgsqlDatabaseAdmin.CreateAsync(string! connectionString, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.Npgsql.NpgsqlDatabaseAdmin.DropAsync(string! connectionString, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.Npgsql.NpgsqlDatabaseAdmin.NpgsqlDatabaseAdmin() -> void +SqlBound.Npgsql.NpgsqlMigrationLedger +SqlBound.Npgsql.NpgsqlMigrationLedger.EnsureCreatedAsync(System.Data.Common.DbConnection! connection, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.Npgsql.NpgsqlMigrationLedger.GetAppliedAsync(System.Data.Common.DbConnection! connection, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! +SqlBound.Npgsql.NpgsqlMigrationLedger.NpgsqlMigrationLedger() -> void +SqlBound.Npgsql.NpgsqlMigrationLedger.RecordAppliedAsync(System.Data.Common.DbConnection! connection, System.Data.Common.DbTransaction? transaction, SqlBound.Migrations.AppliedMigration! migration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.Npgsql.NpgsqlMigrationLedger.RemoveAsync(System.Data.Common.DbConnection! connection, System.Data.Common.DbTransaction? transaction, long version, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.Npgsql.NpgsqlMigrationLedger.SupportsTransactionalDdl.get -> bool +SqlBound.Npgsql.NpgsqlQueryDescriber +SqlBound.Npgsql.NpgsqlQueryDescriber.DescribeAsync(System.Data.Common.DbConnection! connection, string! commandText, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +SqlBound.Npgsql.NpgsqlQueryDescriber.NpgsqlQueryDescriber() -> void diff --git a/src/SqlBound.Npgsql/SqlBound.Npgsql.csproj b/src/SqlBound.Npgsql/SqlBound.Npgsql.csproj index 05c3161..8a82688 100644 --- a/src/SqlBound.Npgsql/SqlBound.Npgsql.csproj +++ b/src/SqlBound.Npgsql/SqlBound.Npgsql.csproj @@ -2,6 +2,7 @@ PostgreSQL introspection and type mapping for SqlBound. + true diff --git a/src/SqlBound.SqlServer/PublicAPI.Shipped.txt b/src/SqlBound.SqlServer/PublicAPI.Shipped.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/SqlBound.SqlServer/PublicAPI.Unshipped.txt b/src/SqlBound.SqlServer/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..17e37fd --- /dev/null +++ b/src/SqlBound.SqlServer/PublicAPI.Unshipped.txt @@ -0,0 +1,15 @@ +#nullable enable +SqlBound.SqlServer.SqlServerDatabaseAdmin +SqlBound.SqlServer.SqlServerDatabaseAdmin.CreateAsync(string! connectionString, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.SqlServer.SqlServerDatabaseAdmin.DropAsync(string! connectionString, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.SqlServer.SqlServerDatabaseAdmin.SqlServerDatabaseAdmin() -> void +SqlBound.SqlServer.SqlServerMigrationLedger +SqlBound.SqlServer.SqlServerMigrationLedger.EnsureCreatedAsync(System.Data.Common.DbConnection! connection, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.SqlServer.SqlServerMigrationLedger.GetAppliedAsync(System.Data.Common.DbConnection! connection, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! +SqlBound.SqlServer.SqlServerMigrationLedger.RecordAppliedAsync(System.Data.Common.DbConnection! connection, System.Data.Common.DbTransaction? transaction, SqlBound.Migrations.AppliedMigration! migration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.SqlServer.SqlServerMigrationLedger.RemoveAsync(System.Data.Common.DbConnection! connection, System.Data.Common.DbTransaction? transaction, long version, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.SqlServer.SqlServerMigrationLedger.SqlServerMigrationLedger() -> void +SqlBound.SqlServer.SqlServerMigrationLedger.SupportsTransactionalDdl.get -> bool +SqlBound.SqlServer.SqlServerQueryDescriber +SqlBound.SqlServer.SqlServerQueryDescriber.DescribeAsync(System.Data.Common.DbConnection! connection, string! commandText, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +SqlBound.SqlServer.SqlServerQueryDescriber.SqlServerQueryDescriber() -> void diff --git a/src/SqlBound.SqlServer/SqlBound.SqlServer.csproj b/src/SqlBound.SqlServer/SqlBound.SqlServer.csproj index b91a46c..2926f4d 100644 --- a/src/SqlBound.SqlServer/SqlBound.SqlServer.csproj +++ b/src/SqlBound.SqlServer/SqlBound.SqlServer.csproj @@ -2,6 +2,7 @@ SQL Server introspection and type mapping for SqlBound. + true diff --git a/src/SqlBound.Sqlite/PublicAPI.Shipped.txt b/src/SqlBound.Sqlite/PublicAPI.Shipped.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/SqlBound.Sqlite/PublicAPI.Unshipped.txt b/src/SqlBound.Sqlite/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..d70e3f5 --- /dev/null +++ b/src/SqlBound.Sqlite/PublicAPI.Unshipped.txt @@ -0,0 +1,15 @@ +#nullable enable +SqlBound.Sqlite.SqliteDatabaseAdmin +SqlBound.Sqlite.SqliteDatabaseAdmin.CreateAsync(string! connectionString, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.Sqlite.SqliteDatabaseAdmin.DropAsync(string! connectionString, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.Sqlite.SqliteDatabaseAdmin.SqliteDatabaseAdmin() -> void +SqlBound.Sqlite.SqliteMigrationLedger +SqlBound.Sqlite.SqliteMigrationLedger.EnsureCreatedAsync(System.Data.Common.DbConnection! connection, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.Sqlite.SqliteMigrationLedger.GetAppliedAsync(System.Data.Common.DbConnection! connection, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task!>! +SqlBound.Sqlite.SqliteMigrationLedger.RecordAppliedAsync(System.Data.Common.DbConnection! connection, System.Data.Common.DbTransaction? transaction, SqlBound.Migrations.AppliedMigration! migration, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.Sqlite.SqliteMigrationLedger.RemoveAsync(System.Data.Common.DbConnection! connection, System.Data.Common.DbTransaction? transaction, long version, System.Threading.CancellationToken cancellationToken) -> System.Threading.Tasks.Task! +SqlBound.Sqlite.SqliteMigrationLedger.SqliteMigrationLedger() -> void +SqlBound.Sqlite.SqliteMigrationLedger.SupportsTransactionalDdl.get -> bool +SqlBound.Sqlite.SqliteQueryDescriber +SqlBound.Sqlite.SqliteQueryDescriber.DescribeAsync(System.Data.Common.DbConnection! connection, string! commandText, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +SqlBound.Sqlite.SqliteQueryDescriber.SqliteQueryDescriber() -> void diff --git a/src/SqlBound.Sqlite/SqlBound.Sqlite.csproj b/src/SqlBound.Sqlite/SqlBound.Sqlite.csproj index 1a6c51b..3814b80 100644 --- a/src/SqlBound.Sqlite/SqlBound.Sqlite.csproj +++ b/src/SqlBound.Sqlite/SqlBound.Sqlite.csproj @@ -2,6 +2,7 @@ SQLite introspection and type mapping for SqlBound. + true diff --git a/src/SqlBound/PublicAPI.Shipped.txt b/src/SqlBound/PublicAPI.Shipped.txt new file mode 100644 index 0000000..e69de29 diff --git a/src/SqlBound/PublicAPI.Unshipped.txt b/src/SqlBound/PublicAPI.Unshipped.txt new file mode 100644 index 0000000..03c6d1e --- /dev/null +++ b/src/SqlBound/PublicAPI.Unshipped.txt @@ -0,0 +1,16 @@ +#nullable enable +SqlBound.SqlExecuteAttribute +SqlBound.SqlExecuteAttribute.CommandText.get -> string! +SqlBound.SqlExecuteAttribute.SqlExecuteAttribute(string! commandText) -> void +SqlBound.SqlParameters +SqlBound.SqlParameters.Count.get -> int +SqlBound.SqlParameters.Items.get -> System.Collections.Generic.IReadOnlyList>! +SqlBound.SqlParameters.SqlParameters(params (string! Name, object? Value)[]! parameters) -> void +SqlBound.SqlQueryAttribute +SqlBound.SqlQueryAttribute.CommandText.get -> string! +SqlBound.SqlQueryAttribute.SqlQueryAttribute(string! commandText) -> void +SqlBound.SqlSession +SqlBound.SqlSession.FetchScalarAsync(string! sql, SqlBound.SqlParameters? parameters = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +SqlBound.SqlSession.RunAsync(string! sql, SqlBound.SqlParameters? parameters = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken)) -> System.Threading.Tasks.Task! +SqlBound.SqlSession.SqlSession(System.Data.Common.DbConnection! connection, System.Data.Common.DbTransaction? transaction = null) -> void +static SqlBound.SqlParameters.Empty.get -> SqlBound.SqlParameters! diff --git a/src/SqlBound/SqlBound.csproj b/src/SqlBound/SqlBound.csproj index 0d6574b..3e15a35 100644 --- a/src/SqlBound/SqlBound.csproj +++ b/src/SqlBound/SqlBound.csproj @@ -2,6 +2,7 @@ Compile-time verified SQL for .NET. + true + + + + + + true + true + + Luís Amorim 1.0.0-preview.1 From a110ea4b92a3d9468cc86baec0543dc452e2958d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 20:18:43 +0100 Subject: [PATCH 07/22] Add CHANGELOG documenting the 1.0.0-rc.1 release Keep-a-Changelog history from Phase 1 through the 1.0.0-rc.1 candidate, with per-version compare links. The top entry is the RC being cut this phase; it is promoted to [1.0.0] at the GA release. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 105 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 105 insertions(+) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c2a4a8f --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,105 @@ +# Changelog + +All notable changes to this project are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +## [1.0.0-rc.1] - 2026-07-12 + +First release candidate for 1.0 (Phase 6 — Ship, M16). Freezes the public API +and finalizes packaging ahead of the stable 1.0.0 release. No functional +changes to the query, verification, or migration engines from `0.5.0`. + +### Added + +- Strong-named assemblies across the whole library set, sharing one committed + key so strong-named consumers can reference SqlBound. +- Public-API baselines (`PublicAPI.Shipped.txt` / `PublicAPI.Unshipped.txt`) + on every shipping library, enforced at build time by + `Microsoft.CodeAnalysis.PublicApiAnalyzers`, so post-1.0 surface changes are + caught mechanically. +- A package-oriented NuGet README embedded in every package, plus per-package + `PackageTags`. +- `ContinuousIntegrationBuild` for deterministic, path-normalized CI output; + SourceLink (SDK-provided) verified to map symbols to the committed sources. +- Guide: "Using SqlBound alongside Dapper" (`docs/dapper-coexistence.md`). +- ADR 0008: public-API stability policy, package-signing rationale, and the + documentation-site decision. +- A DocFX documentation site published to GitHub Pages. +- A tag-triggered NuGet release workflow (publish deliberately gated off until + the 1.0.0 promotion). + +### Changed + +- Narrowed the `SqlBound.Migrations` public surface: `MigrationPlan`, + `MigrationReverter`, and `MigrationStatusReport` are now `internal` (pure + decision helpers composed by `MigrationRunner`; widening later is + non-breaking, narrowing after 1.0 would not be). + +## [0.5.0] - 2026-07-12 + +Phase 5 — Migrations & CLI (M13–M15). + +### Added + +- SQL-file migration model (paired `{version}_{name}.up.sql` / `.down.sql`, + timestamp versions, SHA-256 checksums) and the `_sqlbound_migrations` ledger. +- Provider-neutral migration engine (`migrate add` / `run` / `revert` / + `status`) with checksum-drift and out-of-order safety checks, plus per-migration + transactions where the provider supports transactional DDL. +- `database create` / `drop` CLI commands. +- Cross-provider support for migrations and database administration across SQL + Server, SQLite, PostgreSQL, and MySQL. +- ADR 0006 (migration file format) and ADR 0007 (MySQL non-transactional DDL). + +## [0.4.0] - 2026-07-12 + +Phase 4 — Providers (M10–M12). + +### Added + +- Introspection providers for SQLite, PostgreSQL, and MySQL alongside SQL + Server, each with real type mapping and describe fidelity verified against a + live database. +- The `SqlBound.Introspection` abstraction (`IQueryDescriber`) that all + providers implement. + +## [0.3.0] - 2026-07-11 + +Phase 3 — Verification (M7–M9). + +### Added + +- SQL Server schema introspection, `SQLB###` diagnostics, and committed offline + JSON snapshots (`.sqlbound/`) so the in-IDE analyzer validates without a + database connection. + +## [0.2.0] - 2026-07-11 + +Phase 2 — Codegen (M4–M6). + +### Added + +- Reflection-free row materialization via an incremental source generator, + query-shape support, Native AOT compatibility, and benchmarks versus Dapper + and raw ADO.NET. + +## [0.1.0] - 2026-07-11 + +Phase 1 — Bedrock (M1–M3). + +### Added + +- Solution skeleton and CI, the `System.Data.Common`-based execution core, and + the Dapper-coexistence sample that doubles as a permanent CI regression test. + +[Unreleased]: https://github.com/lgamorim/sqlbound/compare/v1.0.0-rc.1...HEAD +[1.0.0-rc.1]: https://github.com/lgamorim/sqlbound/compare/v0.5.0...v1.0.0-rc.1 +[0.5.0]: https://github.com/lgamorim/sqlbound/compare/v0.4.0...v0.5.0 +[0.4.0]: https://github.com/lgamorim/sqlbound/compare/v0.3.0...v0.4.0 +[0.3.0]: https://github.com/lgamorim/sqlbound/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/lgamorim/sqlbound/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/lgamorim/sqlbound/releases/tag/v0.1.0 From edae77f3ebedb9a732857886277464075e20f0c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 20:20:02 +0100 Subject: [PATCH 08/22] Add gated nuget.org release workflow Builds, tests, and packs on any v*.*.* tag and uploads the packages as artifacts, but never publishes automatically. Pushing to nuget.org is a separate, deliberate step: a manual workflow_dispatch run with publish=true, which also requires a maintainer to have added the NUGET_API_KEY secret. This keeps the 1.0.0 release an opt-in action and lets the release candidate be built and reviewed without any risk of an accidental publish. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/release.yml | 60 +++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..0c3a62b --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,60 @@ +name: Release + +# Builds and packs the release on any v*.*.* tag, but never publishes on its own. +# Publishing to nuget.org happens only through a deliberate manual run of this +# workflow with publish=true — and only once a maintainer has added the +# NUGET_API_KEY repository secret. A plain tag push produces verified packages as +# build artifacts for review; it does not release them. +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + publish: + description: 'Publish packages to nuget.org (leave unchecked to only build and pack)' + type: boolean + default: false + +jobs: + release: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Restore + run: dotnet restore + + - name: Build + run: dotnet build --configuration Release --no-restore + + - name: Test + run: dotnet test --configuration Release --no-build + + - name: Pack + run: dotnet pack --configuration Release --no-build --output artifacts + + - name: Upload NuGet artifacts + uses: actions/upload-artifact@v4 + with: + name: nuget-packages + path: | + artifacts/*.nupkg + artifacts/*.snupkg + + - name: Publish to nuget.org + # Gate: only a manual workflow_dispatch with publish=true reaches this step. + # Tag pushes stop after packing. Publishing 1.0.0 is an opt-in maintainer action. + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} + run: > + dotnet nuget push "artifacts/*.nupkg" + --api-key "${{ secrets.NUGET_API_KEY }}" + --source https://api.nuget.org/v3/index.json + --skip-duplicate From 01f3df64e43e883eeca639a527836449fe0dba80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 20:22:27 +0100 Subject: [PATCH 09/22] Document coexistence, shipping decisions, and RC status Adds the 'Using SqlBound alongside Dapper' guide (docs/dapper-coexistence.md) covering the shared connection/transaction, the deliberate absence of Query*/Execute* method clashes, and a side-by-side example. Adds ADR 0008 recording the 1.0 public-API stability commitment, strong-naming, the repository-signing-over-author-signing choice, and the DocFX site decision. Updates the README to the release-candidate state: status line and roadmap row 6 read 'Release candidate (v1.0.0-rc.1)' rather than Done, the ADR index gains 0008, and the License section matches the house wording used across these repos. Co-Authored-By: Claude Opus 4.8 --- README.md | 14 ++- docs/adr/0008-api-stability-and-shipping.md | 87 ++++++++++++++++ docs/dapper-coexistence.md | 107 ++++++++++++++++++++ 3 files changed, 203 insertions(+), 5 deletions(-) create mode 100644 docs/adr/0008-api-stability-and-shipping.md create mode 100644 docs/dapper-coexistence.md diff --git a/README.md b/README.md index 75c3ea0..5d0f79f 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,10 @@ reflection-free row materialization via a Roslyn incremental source generator, S migrations, and clean coexistence with [Dapper](https://github.com/DapperLib/Dapper) in the same project. -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). +Status: **v1.0.0-rc.1** — release candidate under review. Phases 1–5 (Bedrock, Codegen, Verification, +Providers, Migrations & CLI) are complete, and Phase 6 (Ship — API freeze and the 1.0 release) is in +progress; the stable `1.0.0` follows once the candidate is validated. See [Roadmap](#roadmap) and the +[changelog](CHANGELOG.md). ## Why @@ -19,7 +21,8 @@ turning a class of runtime SQL errors into compiler diagnostics. The two libraries are designed to coexist on the same `DbConnection`/`DbTransaction` with zero conflict — SqlBound never owns a connection and never defines `Query*`/`Execute*` extension -methods that would collide with Dapper's. +methods that would collide with Dapper's. See +[docs/dapper-coexistence.md](docs/dapper-coexistence.md) for a side-by-side guide. ## Quick start @@ -121,6 +124,7 @@ Architectural decisions are recorded as ADRs in [docs/adr/](docs/adr/): - [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 +- [0008](docs/adr/0008-api-stability-and-shipping.md) — the 1.0 public-API stability commitment, strong-naming, package signing, and the documentation site ## Performance @@ -144,8 +148,8 @@ minor version: | 3 — Verification | M7–M9 | Done (`v0.3.0`) | | 4 — Providers | M10–M12 | Done (`v0.4.0`) | | 5 — Migrations & CLI | M13–M15 | Done (`v0.5.0`) | -| 6 — Ship | M16 | Planned (`v1.0.0`) | +| 6 — Ship | M16 | Release candidate (`v1.0.0-rc.1`) | ## License -[Apache-2.0](LICENSE) +Copyright 2026 Luís Amorim. Licensed under the [Apache License 2.0](LICENSE). diff --git a/docs/adr/0008-api-stability-and-shipping.md b/docs/adr/0008-api-stability-and-shipping.md new file mode 100644 index 0000000..4c1c175 --- /dev/null +++ b/docs/adr/0008-api-stability-and-shipping.md @@ -0,0 +1,87 @@ +# 8. Shipping 1.0: API stability, strong-naming, signing, and docs + +## Status + +Accepted + +## Context + +Phase 6 (Ship, M16) turns SqlBound from a set of prerelease packages into a stable `1.0.0`. A `1.0` +release is a public commitment: from it onward, semantic versioning governs what may change, and +several decisions become effectively irreversible once consumers depend on the packages. This ADR +records the ones made for the release so they are not silently revisited later. + +The release is cut first as `1.0.0-rc.1` for review, then promoted to `1.0.0` unchanged; nothing in +this ADR is specific to the candidate versus the final. + +## Decision + +### Public-API stability, enforced mechanically + +After `1.0.0`, the public surface of every shipping library follows semantic versioning: additive in +minor releases, breaking only in a major. This is enforced by +`Microsoft.CodeAnalysis.PublicApiAnalyzers`: each library carries `PublicAPI.Shipped.txt` and +`PublicAPI.Unshipped.txt`, and any change to a public type or member that is not reflected in those +files fails the build (`RS0016`/`RS0017`) under `TreatWarningsAsErrors`. Reviewing a public-surface +change therefore means reviewing a baseline diff — the commitment is tool-checked, not prose. + +Until `1.0.0` actually ships to NuGet, the whole surface lives in `Unshipped.txt`; the GA promotion +moves it to `Shipped.txt`, after which removing a shipped entry is flagged as a breaking change. + +The surface was deliberately narrowed before the freeze (for example, the migration decision helpers +`MigrationPlan`, `MigrationReverter`, and `MigrationStatusReport` became `internal`). Widening a +surface after `1.0` is a non-breaking minor change; narrowing it is not — so anything whose public +exposure was incidental was pulled back while it was still free to do so. + +### Strong-naming + +All assemblies are strong-named with a single committed key (`sqlbound.snk`). A strong-named +assembly cannot reference an unsigned one, and SqlBound's entire peer group — EF Core, Dapper, +Npgsql, the `Microsoft.Data.*` providers — is strong-named; shipping unsigned would permanently +exclude strong-named consumers. Adding or removing a strong name changes assembly identity and is a +binary-breaking change, so `1.0` is the only free moment to decide. The key is committed to the repo +because strong naming is an *identity* mechanism, not a security boundary — the key is not a secret. + +### Package signing + +Packages rely on **nuget.org repository signing**, which is applied automatically on publish and +gives consumers integrity and provenance tied to the account. **Author signing** is not done for +`1.0`: it requires a code-signing certificate whose private key must (per current CA rules) live on +hardware or a cloud HSM, which is real cost and CI complexity for a marginal trust gain on an +open-source library. Author signing is purely additive and can be enabled in any later release +without a breaking change; the release workflow leaves room for it. + +### Documentation site + +A DocFX site is published to GitHub Pages. It generates the API reference from the same XML doc +comments the build already enforces (`GenerateDocumentationFile` + `CS1591`) and renders the +existing `docs/*.md` conceptual pages, so the site reuses committed content rather than duplicating +it. This was chosen over both a hand-maintained site and shipping `1.0` with no site at all. + +### Deferred publishing + +The release pipeline exists but does not publish on its own: a `v*.*.*` tag builds and packs, and +publishing to nuget.org is a separate, opt-in manual action. See the release workflow for the gate. + +## Consequences + +**Positive** + +- The `1.0` compatibility promise is enforced by the build, not by reviewer vigilance. +- Strong-named and unsigned consumers alike can reference SqlBound. +- Symbols, SourceLink, and deterministic CI builds let consumers step into exact committed sources. +- The documentation site stays in sync with the code because it is generated from it. + +**Negative / trade-offs** + +- Every intentional public-surface change now requires a baseline update in the same commit — + small, ongoing friction that is the point of the mechanism. +- The committed strong-name key means anyone can build an assembly with the same identity; this is + acceptable precisely because strong naming is not relied on as a security control. +- Without author signing, consumers who specifically require an author signature are not served at + `1.0`; this is revisited only if there is real demand. + +**Follow-up** + +- The GA promotion moves the API baselines from unshipped to shipped and drops the prerelease suffix. +- Author signing (Azure Trusted Signing + the `sign` tool) can be added later if warranted. diff --git a/docs/dapper-coexistence.md b/docs/dapper-coexistence.md new file mode 100644 index 0000000..9935096 --- /dev/null +++ b/docs/dapper-coexistence.md @@ -0,0 +1,107 @@ +# Using SqlBound alongside Dapper + +SqlBound and [Dapper](https://github.com/DapperLib/Dapper) are designed to run in the same project, +on the same `DbConnection` and `DbTransaction`, with no conflict. They solve different halves of the +data-access problem: + +- **SqlBound** handles SQL that is *static and known at build time*. It generates straight-line + ADO.NET reader code ahead of time and can verify the query against your schema before it runs. +- **Dapper** handles SQL that is *composed at runtime* — dynamic filters, search builders, `IN` + clauses of varying arity — mapping results with reflection. + +Reach for whichever fits the query in front of you. Nothing forces an either/or choice. + +## Why they don't collide + +Coexistence is a deliberate design constraint, not a happy accident. Concretely: + +1. **SqlBound never owns the connection.** Every entry point takes an already-open `DbConnection` + (and optionally a `DbTransaction`) that the caller controls. SqlBound never opens, closes, pools, + or wraps it — so Dapper can use the very same connection object. +2. **No method-name clash.** Dapper's surface is a set of `Query*` / `Execute*` / `ExecuteScalar*` + extension methods on `IDbConnection`. SqlBound deliberately defines **none** of those. Its + primary API is generated `static partial` methods you name yourself; its dynamic escape hatch, + `SqlSession`, uses distinct verbs (`RunAsync`, `FetchScalarAsync`). Importing both + `using SqlBound;` and `using Dapper;` in one file never produces an ambiguous-call error. +3. **No global configuration state.** SqlBound resolves type conversions at compile time in the + source generator; it has no equivalent of Dapper's static `SqlMapper` registry, so there is no + shared mutable state for the two libraries to fight over. + +## Sharing a connection + +Both libraries take the same open `DbConnection`. Use the generated method for the fixed query and +Dapper for the dynamic one: + +```csharp +using System.Data.Common; +using Dapper; +using SqlBound; + +public static partial class Catalog +{ + // Static, build-time-known query -> SqlBound generates the body and can verify it. + [SqlQuery("SELECT id AS Id, name AS Name, price AS Price FROM items WHERE category = @category ORDER BY id")] + public static partial Task> GetByCategoryAsync( + DbConnection connection, string category, CancellationToken cancellationToken = default); +} + +public sealed record Item(int Id, string Name, decimal? Price); + +// ... + +await connection.OpenAsync(); + +// SqlBound for the fixed query: +var byCategory = await Catalog.GetByCategoryAsync(connection, "books"); + +// Dapper on the same connection for a query composed at runtime: +var sql = $"SELECT * FROM items WHERE {BuildDynamicFilter(request)} ORDER BY id"; +var filtered = await connection.QueryAsync(sql, request.Parameters); +``` + +## Sharing a transaction + +Enlist both libraries in one `DbTransaction` — construct a `SqlSession` with it, and pass the same +transaction to Dapper's calls. Either library's writes participate in the same atomic unit: + +```csharp +await using var transaction = await connection.BeginTransactionAsync(); + +// SqlBound's dynamic surface, enlisted in the transaction: +var session = new SqlSession(connection, transaction); +await session.RunAsync( + "UPDATE accounts SET balance = balance - @amount WHERE id = @id", + new SqlParameters(("@amount", 100m), ("@id", fromId))); + +// Dapper, same transaction: +await connection.ExecuteAsync( + "UPDATE accounts SET balance = balance + @amount WHERE id = @id", + new { amount = 100m, id = toId }, + transaction); + +await transaction.CommitAsync(); +``` + +Generated `[SqlQuery]` / `[SqlExecute]` methods that accept a `DbTransaction` parameter enlist the +same way. + +## What each library is best at + +| Situation | Reach for | +| --- | --- | +| Query text fixed at build time | SqlBound `[SqlQuery]` / `[SqlExecute]` (verified, reflection-free) | +| Want compile-time schema checking | SqlBound (`sqlbound prepare` + analyzer) | +| Native AOT / trimming | SqlBound (no reflection, no IL emit) | +| SQL composed at runtime, mapped to types | Dapper `Query*` | +| One-off dynamic non-query or scalar, no generator | SqlBound `SqlSession.RunAsync` / `FetchScalarAsync`, or Dapper | + +SqlBound's `SqlSession` intentionally covers only non-query execution (`RunAsync`) and scalar reads +(`FetchScalarAsync`). Dynamic queries that materialize *rows* into types are Dapper's strength, +and SqlBound does not try to duplicate it. + +## Guaranteed by CI + +Coexistence is not just documented — it is a permanent regression test. The +`test/SqlBound.IntegrationTests` suite exercises SqlBound and Dapper against the same connection and +transaction on every CI run, so a future change that reintroduces a naming or lifecycle conflict +fails the build. From a2be01a3d06fac83dfb5c34d8f218673748425d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 20:28:31 +0100 Subject: [PATCH 10/22] Publish a DocFX documentation site to GitHub Pages Adds a DocFX site that generates the API reference from the XML doc comments the build already enforces and renders the existing docs/*.md guides and ADRs, so the site reuses committed content instead of duplicating it. A Pages workflow (docs.yml) builds and deploys it on every push to master; it requires GitHub Pages to be enabled with the GitHub Actions source (a repository setting). Generated output (_site/, api/) is gitignored. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/docs.yml | 55 ++++++++++++++++++++++++++++++++++++++ .gitignore | 4 +++ docfx.json | 44 ++++++++++++++++++++++++++++++ docs/index.md | 19 +++++++++++++ docs/toc.yml | 34 +++++++++++++++++++++++ index.md | 25 +++++++++++++++++ toc.yml | 4 +++ 7 files changed, 185 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100644 docfx.json create mode 100644 docs/index.md create mode 100644 docs/toc.yml create mode 100644 index.md create mode 100644 toc.yml diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..4259fb2 --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,55 @@ +name: Docs + +# Builds the DocFX documentation site and deploys it to GitHub Pages on every push +# to master. Requires GitHub Pages to be enabled for the repository with the source +# set to "GitHub Actions" (Settings -> Pages); until then the build still runs but +# the deploy step has nothing to publish to. +on: + push: + branches: + - master + workflow_dispatch: + +permissions: + contents: read + pages: write + id-token: write + +# Allow one concurrent deployment; a newer push supersedes an in-progress run. +concurrency: + group: pages + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + global-json-file: global.json + + - name: Install DocFX + run: dotnet tool install --global docfx + + - name: Build site + run: docfx docfx.json + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v3 + with: + path: _site + + deploy: + needs: build + runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy to GitHub Pages + id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index 80fa0fa..6fe254f 100644 --- a/.gitignore +++ b/.gitignore @@ -430,3 +430,7 @@ FodyWeavers.xsd # JetBrains Rider .idea/ + +# DocFX documentation site (generated) +_site/ +api/ diff --git a/docfx.json b/docfx.json new file mode 100644 index 0000000..6b26bc8 --- /dev/null +++ b/docfx.json @@ -0,0 +1,44 @@ +{ + "metadata": [ + { + "src": [ + { + "src": "src", + "files": [ + "SqlBound/SqlBound.csproj", + "SqlBound.Introspection/SqlBound.Introspection.csproj", + "SqlBound.Migrations/SqlBound.Migrations.csproj", + "SqlBound.SqlServer/SqlBound.SqlServer.csproj", + "SqlBound.Sqlite/SqlBound.Sqlite.csproj", + "SqlBound.Npgsql/SqlBound.Npgsql.csproj", + "SqlBound.MySql/SqlBound.MySql.csproj" + ] + } + ], + "dest": "api", + "properties": { + "TargetFramework": "net8.0" + } + } + ], + "build": { + "content": [ + { "files": [ "api/**.yml", "api/index.md" ] }, + { "files": [ "**.md", "**/toc.yml" ], "src": "docs", "dest": "docs", "exclude": [ "nuget/**" ] }, + { "files": [ "index.md", "toc.yml" ] } + ], + "output": "_site", + "template": [ "default", "modern" ], + "globalMetadata": { + "_appName": "SqlBound", + "_appTitle": "SqlBound", + "_description": "Compile-time verified SQL for .NET.", + "_enableSearch": true, + "_appFooter": "SqlBound — Apache-2.0", + "_gitContribute": { + "repo": "https://github.com/lgamorim/sqlbound", + "branch": "master" + } + } + } +} diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ce14c52 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,19 @@ +# Documentation + +Guides and design records for SqlBound. For the generated type-level reference, see the +[API Reference](xref:SqlBound). + +## Guides + +- [Compile-time verification](verification.md) — the `prepare` step, offline snapshots, and the + `SQLB###` diagnostics. +- [Diagnostics](diagnostics.md) — the full `SQLB###` catalog. +- [Introspection](introspection.md) — what each provider can and cannot describe. +- [Migrations](migrations.md) — the file format, the ledger, and per-provider behaviour. +- [Using SqlBound alongside Dapper](dapper-coexistence.md) — sharing a connection and transaction. +- [Benchmarks](benchmarks.md) — methodology and baseline numbers versus Dapper and raw ADO.NET. + +## Architecture decisions + +The [ADRs](adr/0001-verification-split.md) record the load-bearing design choices, from the +verification split through the 1.0 shipping decisions. diff --git a/docs/toc.yml b/docs/toc.yml new file mode 100644 index 0000000..7d465c6 --- /dev/null +++ b/docs/toc.yml @@ -0,0 +1,34 @@ +- name: Overview + href: index.md +- name: Guides + items: + - name: Compile-time verification + href: verification.md + - name: Diagnostics (SQLB###) + href: diagnostics.md + - name: Introspection + href: introspection.md + - name: Migrations + href: migrations.md + - name: Using SqlBound alongside Dapper + href: dapper-coexistence.md + - name: Benchmarks + href: benchmarks.md +- name: Architecture Decisions + items: + - name: 0001 — Verification split + href: adr/0001-verification-split.md + - name: 0002 — Generator snapshot consumption + href: adr/0002-generator-snapshot-consumption.md + - name: 0003 — Verification opt-in by snapshot presence + href: adr/0003-verification-opt-in-by-snapshot-presence.md + - name: 0004 — prepare is CLI-only + href: adr/0004-prepare-is-cli-only.md + - name: 0005 — SQLite describe scope + href: adr/0005-sqlite-describe-scope.md + - name: 0006 — Migration file format + href: adr/0006-migration-file-format.md + - name: 0007 — MySQL migrations not transactional + href: adr/0007-mysql-migrations-not-transactional.md + - name: 0008 — API stability and shipping + href: adr/0008-api-stability-and-shipping.md diff --git a/index.md b/index.md new file mode 100644 index 0000000..f18655c --- /dev/null +++ b/index.md @@ -0,0 +1,25 @@ +# SqlBound + +**Compile-time verified SQL for .NET.** SqlBound checks your SQL against a real database schema at +build time and materializes rows with a Roslyn source generator — no runtime reflection, no IL emit, +Native AOT- and trimming-compatible. It coexists cleanly with [Dapper](https://github.com/DapperLib/Dapper) +on the same connection and transaction. + +## Get started + +- **[Documentation](docs/index.md)** — verification, migrations, providers, and the Dapper coexistence guide. +- **[API Reference](xref:SqlBound)** — the full public surface of every package. +- **[Source on GitHub](https://github.com/lgamorim/sqlbound)** — issues, releases, and the changelog. + +```csharp +using SqlBound; + +public static partial class ItemQueries +{ + [SqlQuery("SELECT Id FROM dbo.Items")] + public static partial Task> GetIdsAsync(DbConnection connection); +} +``` + +The generator emits the method body. `sqlbound prepare` verifies the query against your schema and +commits an offline snapshot, so the in-IDE analyzer validates without a live database connection. diff --git a/toc.yml b/toc.yml new file mode 100644 index 0000000..6c3a156 --- /dev/null +++ b/toc.yml @@ -0,0 +1,4 @@ +- name: Docs + href: docs/ +- name: API Reference + href: api/ From 5b37ad092ff6894eee5214e6601dcac4590298ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 20:30:50 +0100 Subject: [PATCH 11/22] Close Phase 6 at 1.0.0-rc.1 for release-candidate review Bumps the prerelease suffix from -preview to -rc.1: the phase's work is complete and this is the candidate for 1.0.0. The suffix drops to a clean 1.0.0 at the GA promotion, once the candidate has been reviewed. Full verification passed at this version: build clean (0 warnings), 508 tests green with 0 skipped across all provider containers, format clean, and all nine packages pack. 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 65572de..ff6fdc9 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -37,7 +37,7 @@ Luís Amorim - 1.0.0-preview.1 + 1.0.0-rc.1 Apache-2.0 README.md sql;ado.net;dapper;source-generator;native-aot;sqlx;query;database;migrations From a948a5c39f863cfc0a6daa02b6fcf5684fb5d725 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 22:37:19 +0100 Subject: [PATCH 12/22] Add the SqlBound logo to the README, packages, and docs site Introduces assets/logo.png (the sqlbound wordmark) and wires it in three places: PackageIcon on every package (embedded, shown in the NuGet gallery and IDE), the top of both the repo and packaged READMEs, and the DocFX site header via _appLogoPath. The packaged README references it by absolute raw URL, as nuget.org does not resolve relative image paths. Co-Authored-By: Claude Opus 4.8 --- Directory.Build.props | 5 ++++- README.md | 4 ++++ assets/logo.png | Bin 0 -> 46454 bytes docfx.json | 4 ++++ docs/nuget/README.md | 4 ++++ 5 files changed, 16 insertions(+), 1 deletion(-) create mode 100644 assets/logo.png diff --git a/Directory.Build.props b/Directory.Build.props index ff6fdc9..8423d7b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -40,6 +40,7 @@ 1.0.0-rc.1 Apache-2.0 README.md + logo.png sql;ado.net;dapper;source-generator;native-aot;sqlx;query;database;migrations https://github.com/lgamorim/sqlbound https://github.com/lgamorim/sqlbound @@ -50,9 +51,11 @@ true - + + }jL4G1i0T;~c}v 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 literal 0 HcmV?d00001 diff --git a/docfx.json b/docfx.json index 6b26bc8..d088f00 100644 --- a/docfx.json +++ b/docfx.json @@ -27,11 +27,15 @@ { "files": [ "**.md", "**/toc.yml" ], "src": "docs", "dest": "docs", "exclude": [ "nuget/**" ] }, { "files": [ "index.md", "toc.yml" ] } ], + "resource": [ + { "files": [ "assets/logo.png" ] } + ], "output": "_site", "template": [ "default", "modern" ], "globalMetadata": { "_appName": "SqlBound", "_appTitle": "SqlBound", + "_appLogoPath": "assets/logo.png", "_description": "Compile-time verified SQL for .NET.", "_enableSearch": true, "_appFooter": "SqlBound — Apache-2.0", diff --git a/docs/nuget/README.md b/docs/nuget/README.md index 3d5ca24..ccfccb2 100644 --- a/docs/nuget/README.md +++ b/docs/nuget/README.md @@ -1,3 +1,7 @@ +

+ SqlBound +

+ # SqlBound **Compile-time verified SQL for .NET.** SqlBound checks your SQL against a real database schema at From 3113ba2b5422fe2dd55c23d3c365a027fcb8eb23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 22:47:31 +0100 Subject: [PATCH 13/22] Link the published docs site from the NuGet README Now that GitHub Pages is enabled, point NuGet visitors to the rendered guides and API reference at the docs site, keeping the GitHub link for source, issues, and the changelog. The docs URL goes live once the docs workflow runs on master. Co-Authored-By: Claude Opus 4.8 --- docs/nuget/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/nuget/README.md b/docs/nuget/README.md index ccfccb2..259b1ed 100644 --- a/docs/nuget/README.md +++ b/docs/nuget/README.md @@ -47,8 +47,8 @@ SQL-file migrations, each reflecting what the underlying engine actually support ## Documentation -Guides, provider matrix, and architecture decision records: -https://github.com/lgamorim/sqlbound +- Guides, provider matrix, and API reference: https://lgamorim.github.io/sqlbound/ +- Source, issues, and changelog: https://github.com/lgamorim/sqlbound ## License From 56952ade353a19c6229aee9f978e9716cc27e3a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sun, 12 Jul 2026 22:49:39 +0100 Subject: [PATCH 14/22] Mirror the repo README's license wording in the NuGet README Match the repository README's 'Copyright ... Licensed under the Apache License 2.0' wording for consistency, using an absolute link to the LICENSE file since nuget.org does not resolve relative paths. Co-Authored-By: Claude Opus 4.8 --- docs/nuget/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/nuget/README.md b/docs/nuget/README.md index 259b1ed..50b9534 100644 --- a/docs/nuget/README.md +++ b/docs/nuget/README.md @@ -52,4 +52,4 @@ SQL-file migrations, each reflecting what the underlying engine actually support ## License -Apache-2.0 +Copyright 2026 Luís Amorim. Licensed under the [Apache License 2.0](https://github.com/lgamorim/sqlbound/blob/master/LICENSE). From f295eaa52862b7d6f08e18e8cd43b91c393fb9ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Mon, 13 Jul 2026 02:47:16 +0100 Subject: [PATCH 15/22] Stop scanning MySQL @@ system variables as parameters The MySQL parameter scanner matched the second '@' of a system variable such as @@sql_mode or @@session.sql_mode as a parameter placeholder, so prepare declared a bogus parameter and verification then demanded a C# method parameter that should not exist. Skip the whole (optionally scoped) system-variable reference instead. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 ++++++++- src/SqlBound.MySql/MySqlParameterScanner.cs | 21 +++++++++++++++++-- .../MySqlParameterScannerTests.cs | 15 +++++++++++++ 3 files changed, 43 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2a4a8f..2db9d93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 First release candidate for 1.0 (Phase 6 — Ship, M16). Freezes the public API and finalizes packaging ahead of the stable 1.0.0 release. No functional -changes to the query, verification, or migration engines from `0.5.0`. +changes to the query, verification, or migration engines from `0.5.0` beyond +the MySQL scanner fix below. ### Added @@ -32,6 +33,13 @@ changes to the query, verification, or migration engines from `0.5.0`. - A tag-triggered NuGet release workflow (publish deliberately gated off until the 1.0.0 promotion). +### Fixed + +- The MySQL parameter scanner no longer mistakes `@@` system variables (e.g. + `@@sql_mode`, `@@session.sql_mode`) for parameter placeholders, which made + `prepare` declare a bogus parameter and verification demand a method + parameter that should not exist. + ### Changed - Narrowed the `SqlBound.Migrations` public surface: `MigrationPlan`, diff --git a/src/SqlBound.MySql/MySqlParameterScanner.cs b/src/SqlBound.MySql/MySqlParameterScanner.cs index f0218ae..4c96236 100644 --- a/src/SqlBound.MySql/MySqlParameterScanner.cs +++ b/src/SqlBound.MySql/MySqlParameterScanner.cs @@ -6,8 +6,9 @@ namespace SqlBound.MySql; /// names — MySqlQueryDescriber must pre-declare them before it can even describe a /// statement's columns — so this is a hand-rolled scan rather than a server round-trip. It skips /// single- and double-quoted string literals (both doubled-quote and backslash escaping), -/// backtick-quoted identifiers, and --/#//* */ comments, so a literal like -/// 'user@example.com' is never mistaken for a placeholder. +/// backtick-quoted identifiers, --/#//* */ comments, and @@ system +/// variables, so a literal like 'user@example.com' or a variable like @@sql_mode is +/// never mistaken for a placeholder. /// internal static class MySqlParameterScanner { @@ -31,6 +32,11 @@ public static IReadOnlyList ExtractNames(string commandText) case '/' when Peek(commandText, position + 1) == '*': position = SkipBlockComment(commandText, position); break; + // @@name is a system variable (optionally scoped, e.g. @@session.sql_mode), + // never a parameter placeholder. + case '@' when Peek(commandText, position + 1) == '@': + position = SkipSystemVariable(commandText, position); + break; case '@' when IsIdentifierStart(Peek(commandText, position + 1)): position = ReadParameterName(commandText, position, names, seen); break; @@ -90,6 +96,17 @@ private static int SkipQuoted(string text, int position, char quote) return position; } + private static int SkipSystemVariable(string text, int position) + { + position += 2; + while (position < text.Length && (IsIdentifierPart(text[position]) || text[position] == '.')) + { + position++; + } + + return position; + } + private static int SkipLineComment(string text, int position) { var newline = text.IndexOf('\n', position); diff --git a/test/SqlBound.MySql.UnitTests/MySqlParameterScannerTests.cs b/test/SqlBound.MySql.UnitTests/MySqlParameterScannerTests.cs index d30a659..94d2e03 100644 --- a/test/SqlBound.MySql.UnitTests/MySqlParameterScannerTests.cs +++ b/test/SqlBound.MySql.UnitTests/MySqlParameterScannerTests.cs @@ -101,4 +101,19 @@ public void Should_IgnoreAtSignInsideABlockComment() Assert.Equal(["id"], names); } + + [Fact] + public void Should_IgnoreSystemVariable_When_CommandUsesDoubleAtSign() + { + Assert.Empty(MySqlParameterScanner.ExtractNames("SELECT @@sql_mode")); + } + + [Fact] + public void Should_IgnoreScopedSystemVariable_When_CommandUsesDoubleAtSign() + { + var names = MySqlParameterScanner.ExtractNames( + "SELECT @@session.sql_mode, id FROM items WHERE id = @id"); + + Assert.Equal(["id"], names); + } } From b4401065d2fff9442100ca72908373ba61aba4c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Mon, 13 Jul 2026 02:47:33 +0100 Subject: [PATCH 16/22] Require a matching expected-version to publish a release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A manual workflow_dispatch run builds whatever ref the "Run workflow" dropdown selects — master by default, not the release tag — so a publish could silently ship a stale or unintended state. The publish path now requires an expected-version input and fails before pushing unless the packed output contains exactly that version. Co-Authored-By: Claude Fable 5 --- .github/workflows/release.yml | 28 +++++++++++++++++++++++++--- CHANGELOG.md | 3 ++- 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0c3a62b..9ae4d84 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,9 +2,10 @@ name: Release # Builds and packs the release on any v*.*.* tag, but never publishes on its own. # Publishing to nuget.org happens only through a deliberate manual run of this -# workflow with publish=true — and only once a maintainer has added the -# NUGET_API_KEY repository secret. A plain tag push produces verified packages as -# build artifacts for review; it does not release them. +# workflow with publish=true and an expected-version that matches what the selected +# ref packs (run it from the release tag, not master) — and only once a maintainer +# has added the NUGET_API_KEY repository secret. A plain tag push produces verified +# packages as build artifacts for review; it does not release them. on: push: tags: @@ -15,6 +16,10 @@ on: description: 'Publish packages to nuget.org (leave unchecked to only build and pack)' type: boolean default: false + expected-version: + description: 'The exact PackageVersion this run must publish (e.g. 1.0.0); required when publish is checked' + type: string + default: '' jobs: release: @@ -49,6 +54,23 @@ jobs: artifacts/*.nupkg artifacts/*.snupkg + - name: Verify the version being published + # A workflow_dispatch run builds whatever ref was selected in the "Run workflow" + # dropdown — master by default, not the release tag. Requiring the maintainer to + # type the exact version and matching it against the packed output makes publishing + # a stale or unintended ref fail here instead of reaching nuget.org. + if: ${{ github.event_name == 'workflow_dispatch' && inputs.publish }} + run: | + expected='${{ inputs.expected-version }}' + if [ -z "$expected" ]; then + echo "::error::publish=true requires expected-version to be set." + exit 1 + fi + if [ ! -f "artifacts/SqlBound.${expected}.nupkg" ]; then + echo "::error::This run packed $(ls artifacts/SqlBound.[0-9]*.nupkg), not version ${expected}. Select the release tag as the run's ref and set expected-version to its version." + exit 1 + fi + - name: Publish to nuget.org # Gate: only a manual workflow_dispatch with publish=true reaches this step. # Tag pushes stop after packing. Publishing 1.0.0 is an opt-in maintainer action. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2db9d93..039bc88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,7 +31,8 @@ the MySQL scanner fix below. documentation-site decision. - A DocFX documentation site published to GitHub Pages. - A tag-triggered NuGet release workflow (publish deliberately gated off until - the 1.0.0 promotion). + the 1.0.0 promotion, and additionally guarded by a required + `expected-version` input that must match the packed output). ### Fixed From 608352527f0cdd983f8928cb3b25e6a567b0c6f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Mon, 13 Jul 2026 04:13:55 +0100 Subject: [PATCH 17/22] Match MySQL's whitespace rule for -- line comments The scanner treated any -- as a line comment, but MySQL only recognizes -- when followed by whitespace or the end of the statement; "1--@x" is the arithmetic expression 1 - (-@x), whose placeholder the scanner silently dropped along with the rest of the line. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++++ src/SqlBound.MySql/MySqlParameterScanner.cs | 5 ++++- .../MySqlParameterScannerTests.cs | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 039bc88..c0da6bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,10 @@ the MySQL scanner fix below. `@@sql_mode`, `@@session.sql_mode`) for parameter placeholders, which made `prepare` declare a bogus parameter and verification demand a method parameter that should not exist. +- The MySQL parameter scanner now applies MySQL's actual line-comment rule: + `--` starts a comment only when followed by whitespace or the end of the + statement, so an expression like `1--@x` no longer swallows the rest of the + line. ### Changed diff --git a/src/SqlBound.MySql/MySqlParameterScanner.cs b/src/SqlBound.MySql/MySqlParameterScanner.cs index 4c96236..77edd9a 100644 --- a/src/SqlBound.MySql/MySqlParameterScanner.cs +++ b/src/SqlBound.MySql/MySqlParameterScanner.cs @@ -25,7 +25,10 @@ public static IReadOnlyList ExtractNames(string commandText) case '\'' or '"' or '`': position = SkipQuoted(commandText, position, current); break; - case '-' when Peek(commandText, position + 1) == '-': + // MySQL only recognizes -- as a comment when followed by whitespace or the end + // of the statement; "1--@x" is the arithmetic expression 1 - (-@x). + case '-' when Peek(commandText, position + 1) == '-' + && (position + 2 >= commandText.Length || char.IsWhiteSpace(commandText[position + 2])): case '#': position = SkipLineComment(commandText, position); break; diff --git a/test/SqlBound.MySql.UnitTests/MySqlParameterScannerTests.cs b/test/SqlBound.MySql.UnitTests/MySqlParameterScannerTests.cs index 94d2e03..da8d3ad 100644 --- a/test/SqlBound.MySql.UnitTests/MySqlParameterScannerTests.cs +++ b/test/SqlBound.MySql.UnitTests/MySqlParameterScannerTests.cs @@ -102,6 +102,24 @@ public void Should_IgnoreAtSignInsideABlockComment() Assert.Equal(["id"], names); } + [Fact] + public void Should_NotTreatDoubleDashAsComment_When_NotFollowedByWhitespace() + { + // MySQL only recognizes -- as a comment when followed by whitespace (or end of + // statement); "1--@offset" is the arithmetic expression 1 - (-@offset). + var names = MySqlParameterScanner.ExtractNames("SELECT 1--@offset"); + + Assert.Equal(["offset"], names); + } + + [Fact] + public void Should_TreatDoubleDashAtEndOfText_AsComment() + { + var names = MySqlParameterScanner.ExtractNames("SELECT id FROM items WHERE id = @id --"); + + Assert.Equal(["id"], names); + } + [Fact] public void Should_IgnoreSystemVariable_When_CommandUsesDoubleAtSign() { From b2d8fc5327dcca2498104ecb3c6b2627040f3421 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Mon, 13 Jul 2026 04:14:16 +0100 Subject: [PATCH 18/22] Report scalar conversion failures with both types named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FetchScalarAsync fell back to Convert.ChangeType, which leaks a bare InvalidCastException for non-IConvertible targets such as Guid; wrap it (and Format/Overflow failures) in an InvalidOperationException naming the actual and requested types, keeping the method's one documented failure exception. The no-value message also claimed "no value" when the database may have returned a NULL — say so. While here, align the command disposal with the rest of the codebase (await using). Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 ++++ src/SqlBound/SqlSession.cs | 21 +++++++++++++++------ test/SqlBound.UnitTests/SqlSessionTests.cs | 20 +++++++++++++++++++- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0da6bc..427ba53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -44,6 +44,10 @@ the MySQL scanner fix below. `--` starts a comment only when followed by whitespace or the end of the statement, so an expression like `1--@x` no longer swallows the rest of the line. +- `SqlSession.FetchScalarAsync` reports a failed scalar conversion as an + `InvalidOperationException` naming the actual and requested types (instead + of leaking a bare `InvalidCastException`), and its no-value error message + now acknowledges that the result may have been a database NULL. ### Changed diff --git a/src/SqlBound/SqlSession.cs b/src/SqlBound/SqlSession.cs index f895a07..3431571 100644 --- a/src/SqlBound/SqlSession.cs +++ b/src/SqlBound/SqlSession.cs @@ -38,7 +38,7 @@ public async Task RunAsync( { ValidateAndPrepare(sql, cancellationToken); - using var command = CreateCommand(sql, parameters); + await using var command = CreateCommand(sql, parameters); return await command.ExecuteNonQueryAsync(cancellationToken).ConfigureAwait(false); } @@ -49,8 +49,9 @@ public async Task RunAsync( /// /// is null or whitespace. /// - /// The connection is not open, or the result is a database null and - /// is a non-nullable value type. + /// The connection is not open, the result is a database null and + /// is a non-nullable value type, or the result cannot be converted to + /// . /// public async Task FetchScalarAsync( string sql, @@ -59,7 +60,7 @@ public async Task RunAsync( { ValidateAndPrepare(sql, cancellationToken); - using var command = CreateCommand(sql, parameters); + await using var command = CreateCommand(sql, parameters); var result = await command.ExecuteScalarAsync(cancellationToken).ConfigureAwait(false); return ConvertScalar(result); @@ -72,7 +73,7 @@ public async Task RunAsync( if (default(T) is not null) { throw new InvalidOperationException( - $"The query returned no value, but '{typeof(T)}' is a non-nullable value type."); + $"The query returned no rows or a NULL value, but '{typeof(T)}' is a non-nullable value type."); } return default; @@ -84,7 +85,15 @@ public async Task RunAsync( } var targetType = Nullable.GetUnderlyingType(typeof(T)) ?? typeof(T); - return (T)Convert.ChangeType(result, targetType); + try + { + return (T)Convert.ChangeType(result, targetType); + } + catch (Exception exception) when (exception is InvalidCastException or FormatException or OverflowException) + { + throw new InvalidOperationException( + $"The scalar result of type '{result.GetType()}' cannot be converted to '{typeof(T)}'.", exception); + } } private void ValidateAndPrepare(string sql, CancellationToken cancellationToken) diff --git a/test/SqlBound.UnitTests/SqlSessionTests.cs b/test/SqlBound.UnitTests/SqlSessionTests.cs index 34dea76..0d3a340 100644 --- a/test/SqlBound.UnitTests/SqlSessionTests.cs +++ b/test/SqlBound.UnitTests/SqlSessionTests.cs @@ -183,8 +183,26 @@ public async Task Should_ThrowInvalidOperationException_When_ResultIsDBNullAndTy var connection = new FakeDbConnection { ExecuteScalarResult = DBNull.Value }; var session = new SqlSession(connection); - await Assert.ThrowsAsync( + var exception = await Assert.ThrowsAsync( () => session.FetchScalarAsync("SELECT MAX(x) FROM t", cancellationToken: TestContext.Current.CancellationToken)); + + // The same code path covers both an empty result set and a NULL value; the message + // must not claim "no value" when the database may well have returned a NULL. + Assert.Contains("NULL", exception.Message); + } + + [Fact] + public async Task Should_ThrowInvalidOperationException_When_ScalarResultCannotConvertToRequestedType() + { + var connection = new FakeDbConnection { ExecuteScalarResult = "0e984725-c51c-4bf4-9960-e1c80e27aba0" }; + var session = new SqlSession(connection); + + var exception = await Assert.ThrowsAsync( + () => session.FetchScalarAsync("SELECT id FROM t", cancellationToken: TestContext.Current.CancellationToken)); + + Assert.Contains(nameof(String), exception.Message); + Assert.Contains(nameof(Guid), exception.Message); + Assert.IsType(exception.InnerException); } [Fact] From 4107abfa41a9680804d00acdd8064230a4dc7b94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Mon, 13 Jul 2026 04:14:34 +0100 Subject: [PATCH 19/22] Delete WAL sidecar files when dropping a SQLite database DropAsync deleted only the main database file. The -wal/-shm sidecars are checkpointed away on a clean close, but a crashed process leaves them behind, and stale ones would poison a database later created at the same path. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 2 ++ src/SqlBound.Sqlite/SqliteDatabaseAdmin.cs | 14 ++++++++---- .../SqliteDatabaseAdminTests.cs | 22 +++++++++++++++++-- 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 427ba53..eced30b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,8 @@ the MySQL scanner fix below. `InvalidOperationException` naming the actual and requested types (instead of leaking a bare `InvalidCastException`), and its no-value error message now acknowledges that the result may have been a database NULL. +- `sqlbound database drop` for SQLite also removes stale `-wal`/`-shm` + sidecar files a crashed process may have left beside the database file. ### Changed diff --git a/src/SqlBound.Sqlite/SqliteDatabaseAdmin.cs b/src/SqlBound.Sqlite/SqliteDatabaseAdmin.cs index d07250e..636d3ba 100644 --- a/src/SqlBound.Sqlite/SqliteDatabaseAdmin.cs +++ b/src/SqlBound.Sqlite/SqliteDatabaseAdmin.cs @@ -6,7 +6,8 @@ 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. +/// (which materializes the file); dropping it deletes the file and any -wal/-shm +/// sidecars a crashed process may have left beside it. Both are idempotent. /// public sealed class SqliteDatabaseAdmin : IDatabaseAdmin { @@ -24,11 +25,16 @@ public Task DropAsync(string connectionString, CancellationToken cancell { var dataSource = DataSourceOf(connectionString); - // Release any pooled handles so the file is not locked when we delete it. + // Release any pooled handles so the files are not locked when we delete them. The WAL + // sidecars are normally checkpointed away on a clean close, but a crashed process leaves + // them behind — and stale ones would poison a database later created at the same path. SqliteConnection.ClearAllPools(); - if (File.Exists(dataSource)) + foreach (var path in new[] { dataSource, $"{dataSource}-wal", $"{dataSource}-shm" }) { - File.Delete(dataSource); + if (File.Exists(path)) + { + File.Delete(path); + } } return Task.FromResult(dataSource); diff --git a/test/SqlBound.Sqlite.IntegrationTests/SqliteDatabaseAdminTests.cs b/test/SqlBound.Sqlite.IntegrationTests/SqliteDatabaseAdminTests.cs index 141117e..529ea12 100644 --- a/test/SqlBound.Sqlite.IntegrationTests/SqliteDatabaseAdminTests.cs +++ b/test/SqlBound.Sqlite.IntegrationTests/SqliteDatabaseAdminTests.cs @@ -13,9 +13,12 @@ public sealed class SqliteDatabaseAdminTests : IDisposable public void Dispose() { SqliteConnection.ClearAllPools(); - if (File.Exists(_path)) + foreach (var path in new[] { _path, $"{_path}-wal", $"{_path}-shm" }) { - File.Delete(_path); + if (File.Exists(path)) + { + File.Delete(path); + } } } @@ -37,6 +40,21 @@ public async Task Should_DeleteDatabaseFile_When_Drop() Assert.False(File.Exists(_path)); } + [Fact] + public async Task Should_DeleteWalAndShmSidecarFiles_When_Drop() + { + // A crashed process can leave the WAL sidecars behind even after every connection is + // gone; a drop that keeps them would poison the next database created at the same path. + await new SqliteDatabaseAdmin().CreateAsync(ConnectionString, Token); + File.WriteAllText($"{_path}-wal", ""); + File.WriteAllText($"{_path}-shm", ""); + + await new SqliteDatabaseAdmin().DropAsync(ConnectionString, Token); + + Assert.False(File.Exists($"{_path}-wal")); + Assert.False(File.Exists($"{_path}-shm")); + } + [Fact] public async Task Should_NotThrow_When_DropCalledForMissingFile() { From ec822051978150330aed2ef6571ef94e13403697 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Mon, 13 Jul 2026 04:14:48 +0100 Subject: [PATCH 20/22] Complete the using directives in both README examples Neither quick-start snippet compiled as pasted: the repo README was missing 'using SqlBound;' for the attributes, the NuGet README was missing 'using System.Data.Common;' for DbConnection (not covered by implicit usings). Co-Authored-By: Claude Fable 5 --- README.md | 1 + docs/nuget/README.md | 1 + 2 files changed, 2 insertions(+) diff --git a/README.md b/README.md index f15b005..7208782 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ methods that would collide with Dapper's. See ```csharp using System.Data.Common; +using SqlBound; public static partial class ItemQueries { diff --git a/docs/nuget/README.md b/docs/nuget/README.md index 50b9534..55b3fc2 100644 --- a/docs/nuget/README.md +++ b/docs/nuget/README.md @@ -16,6 +16,7 @@ libraries share a project without conflict. ## Example ```csharp +using System.Data.Common; using SqlBound; public static partial class ItemQueries From e6e5ccf802b45f98ae5cffd3eb991d2a62ed67a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Mon, 13 Jul 2026 12:27:43 +0100 Subject: [PATCH 21/22] Use Markdown image syntax for the NuGet README logo NuGet.org sanitizes raw HTML when rendering the README, so the

/ block was stripped and the logo never appeared. Markdown image syntax renders; the width and centering it drops were HTML-only and were being stripped regardless. Co-Authored-By: Claude Fable 5 --- docs/nuget/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/nuget/README.md b/docs/nuget/README.md index 55b3fc2..acc223f 100644 --- a/docs/nuget/README.md +++ b/docs/nuget/README.md @@ -1,6 +1,4 @@ -

- SqlBound -

+![SqlBound](https://raw.githubusercontent.com/lgamorim/sqlbound/master/assets/logo.png) # SqlBound From c0b55523fcce517aed9027f1cf767479c5e32ed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Mon, 13 Jul 2026 22:14:22 +0100 Subject: [PATCH 22/22] Use a dedicated square glyph for the NuGet package icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NuGet renders the package icon in a square slot, where the 2:1 wordmark letterboxed to an illegibly small mark. Point PackageIcon at a purpose- built 128x128 square glyph and pack that instead. The wordmark logo is no longer packed at all — both READMEs load it by absolute URL, so it need not travel inside every package. Co-Authored-By: Claude Fable 5 --- Directory.Build.props | 7 ++++--- assets/icon.png | Bin 0 -> 33870 bytes 2 files changed, 4 insertions(+), 3 deletions(-) create mode 100644 assets/icon.png diff --git a/Directory.Build.props b/Directory.Build.props index 8423d7b..84fa5dc 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -40,7 +40,7 @@ 1.0.0-rc.1 Apache-2.0 README.md - logo.png + icon.png sql;ado.net;dapper;source-generator;native-aot;sqlx;query;database;migrations https://github.com/lgamorim/sqlbound https://github.com/lgamorim/sqlbound @@ -52,10 +52,11 @@
+ PackageReadmeFile / PackageIcon properties above). The wordmark logo is not packed: + both READMEs load it by absolute URL, so it need not travel inside every package. --> - +