From 477055e9143660086b15d64818c478d02e539a0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 17:56:21 +0100 Subject: [PATCH 1/6] Mark SqlBound AOT and trim compatible IsAotCompatible turns on the trim, single-file, and AOT analyzers, which report zero findings; with TreatWarningsAsErrors on, any future regression fails the build. The flag also stamps the package metadata consumers'' tooling reads when they publish trimmed or Native AOT apps. Co-Authored-By: Claude Fable 5 --- src/SqlBound/SqlBound.csproj | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/SqlBound/SqlBound.csproj b/src/SqlBound/SqlBound.csproj index 3089b53..0d6574b 100644 --- a/src/SqlBound/SqlBound.csproj +++ b/src/SqlBound/SqlBound.csproj @@ -2,6 +2,11 @@ Compile-time verified SQL for .NET. + + true From 7958e497377f54496a99106f8499603d33a0c35e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 17:59:04 +0100 Subject: [PATCH 2/6] Add Native AOT smoke test app and CI publish-and-run job Trim analysis alone cannot prove AOT compatibility, so CI now publishes a console app with PublishAot and executes the native binary: it exercises every generated shape (list, single, optional, scalar, scalar list, streaming, execute, property-mapped rows) plus SqlSession against real SQLite, with the exit code as the verdict. PublishAot in the project file also keeps the AOT analyzers on for every solution build. Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 18 +++++++ sqlbound.slnx | 1 + .../ItemEntityQueries.cs | 17 ++++++ test/SqlBound.AotSmokeTest/ItemQueries.cs | 36 +++++++++++++ test/SqlBound.AotSmokeTest/Program.cs | 52 +++++++++++++++++++ .../SqlBound.AotSmokeTest.csproj | 26 ++++++++++ 6 files changed, 150 insertions(+) create mode 100644 test/SqlBound.AotSmokeTest/ItemEntityQueries.cs create mode 100644 test/SqlBound.AotSmokeTest/ItemQueries.cs create mode 100644 test/SqlBound.AotSmokeTest/Program.cs create mode 100644 test/SqlBound.AotSmokeTest/SqlBound.AotSmokeTest.csproj diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc8bfb9..2b96899 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,3 +42,21 @@ jobs: with: name: nuget-packages path: artifacts/*.nupkg + + aot-smoke-test: + 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: Publish Native AOT smoke test + run: dotnet publish test/SqlBound.AotSmokeTest --configuration Release --runtime linux-x64 + + - name: Run Native AOT smoke test + run: ./test/SqlBound.AotSmokeTest/bin/Release/net10.0/linux-x64/publish/SqlBound.AotSmokeTest diff --git a/sqlbound.slnx b/sqlbound.slnx index bc03119..a0fa664 100644 --- a/sqlbound.slnx +++ b/sqlbound.slnx @@ -4,6 +4,7 @@ + diff --git a/test/SqlBound.AotSmokeTest/ItemEntityQueries.cs b/test/SqlBound.AotSmokeTest/ItemEntityQueries.cs new file mode 100644 index 0000000..55a2178 --- /dev/null +++ b/test/SqlBound.AotSmokeTest/ItemEntityQueries.cs @@ -0,0 +1,17 @@ +using System.Data.Common; + +namespace SqlBound.AotSmokeTest; + +internal static partial class ItemEntityQueries +{ + [SqlQuery("SELECT id AS Id, name AS Name, price AS Price FROM items WHERE category = @category ORDER BY id")] + internal static partial Task> GetByCategoryAsync( + DbConnection connection, string category, CancellationToken cancellationToken = default); +} + +internal sealed class ItemEntity +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public decimal? Price { get; set; } +} diff --git a/test/SqlBound.AotSmokeTest/ItemQueries.cs b/test/SqlBound.AotSmokeTest/ItemQueries.cs new file mode 100644 index 0000000..e690116 --- /dev/null +++ b/test/SqlBound.AotSmokeTest/ItemQueries.cs @@ -0,0 +1,36 @@ +using System.Data.Common; + +namespace SqlBound.AotSmokeTest; + +internal static partial class ItemQueries +{ + [SqlQuery("SELECT id AS Id, name AS Name, price AS Price FROM items WHERE category = @category ORDER BY id")] + internal static partial Task> GetByCategoryAsync( + DbConnection connection, string category, CancellationToken cancellationToken = default); + + [SqlQuery("SELECT id AS Id, name AS Name, price AS Price FROM items WHERE id = @id")] + internal static partial Task GetByIdAsync( + DbConnection connection, int id, CancellationToken cancellationToken = default); + + [SqlQuery("SELECT id AS Id, name AS Name, price AS Price FROM items WHERE id = @id")] + internal static partial Task FindByIdAsync( + DbConnection connection, int id, CancellationToken cancellationToken = default); + + [SqlQuery("SELECT COUNT(*) FROM items WHERE category = @category")] + internal static partial Task CountByCategoryAsync( + DbConnection connection, string category, CancellationToken cancellationToken = default); + + [SqlQuery("SELECT name FROM items WHERE category = @category ORDER BY id")] + internal static partial Task> GetNamesByCategoryAsync( + DbConnection connection, string category, CancellationToken cancellationToken = default); + + [SqlQuery("SELECT id AS Id, name AS Name, price AS Price FROM items WHERE category = @category ORDER BY id")] + internal static partial IAsyncEnumerable StreamByCategoryAsync( + DbConnection connection, string category, CancellationToken cancellationToken = default); + + [SqlExecute("DELETE FROM items WHERE category = @category")] + internal static partial Task DeleteByCategoryAsync( + DbConnection connection, string category, CancellationToken cancellationToken = default); +} + +internal sealed record Item(int Id, string Name, decimal? Price); diff --git a/test/SqlBound.AotSmokeTest/Program.cs b/test/SqlBound.AotSmokeTest/Program.cs new file mode 100644 index 0000000..2529f5c --- /dev/null +++ b/test/SqlBound.AotSmokeTest/Program.cs @@ -0,0 +1,52 @@ +using Microsoft.Data.Sqlite; +using SqlBound; +using SqlBound.AotSmokeTest; + +await using var connection = new SqliteConnection("Data Source=:memory:"); +await connection.OpenAsync(); + +var session = new SqlSession(connection); +await session.RunAsync( + "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL, price REAL NULL, category TEXT NOT NULL)"); +await session.RunAsync( + "INSERT INTO items (id, name, price, category) VALUES (1, 'hammer', 9.5, 'tools'), (2, 'nails', NULL, 'tools'), (3, 'apple', 0.5, 'food')"); + +var tools = await ItemQueries.GetByCategoryAsync(connection, "tools"); +Verify(tools.Count == 2 && tools[0] == new Item(1, "hammer", 9.5m) && tools[1].Price is null, "row list"); + +var single = await ItemQueries.GetByIdAsync(connection, 1); +Verify(single.Name == "hammer", "single row"); + +Verify(await ItemQueries.FindByIdAsync(connection, 99) is null, "optional row miss"); + +Verify(await ItemQueries.CountByCategoryAsync(connection, "tools") == 2, "scalar"); + +var names = await ItemQueries.GetNamesByCategoryAsync(connection, "tools"); +Verify(names.Count == 2 && names[0] == "hammer" && names[1] == "nails", "scalar list"); + +var streamed = 0; +await foreach (var item in ItemQueries.StreamByCategoryAsync(connection, "tools")) +{ + streamed++; +} + +Verify(streamed == 2, "streaming"); + +var entities = await ItemEntityQueries.GetByCategoryAsync(connection, "tools"); +Verify(entities.Count == 2 && entities[0].Name == "hammer" && entities[1].Price is null, "property-mapped rows"); + +Verify(await ItemQueries.DeleteByCategoryAsync(connection, "food") == 1, "execute rows affected"); + +Verify(await session.FetchScalarAsync("SELECT COUNT(*) FROM items") == 2, "SqlSession scalar"); + +Console.WriteLine("Smoke test passed: every query shape executed."); +return 0; + +static void Verify(bool condition, string check) +{ + if (!condition) + { + Console.Error.WriteLine($"AOT smoke test FAILED: {check}"); + Environment.Exit(1); + } +} diff --git a/test/SqlBound.AotSmokeTest/SqlBound.AotSmokeTest.csproj b/test/SqlBound.AotSmokeTest/SqlBound.AotSmokeTest.csproj new file mode 100644 index 0000000..152aed4 --- /dev/null +++ b/test/SqlBound.AotSmokeTest/SqlBound.AotSmokeTest.csproj @@ -0,0 +1,26 @@ + + + + Exe + + net10.0 + + true + true + false + + + + + + + + + + + + + From 1e03eb0026937a187728995688fa94f43c21070d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 18:07:53 +0100 Subject: [PATCH 3/6] Add BenchmarkDotNet suite vs Dapper and raw ADO.NET Four categories (1000-row list, single row, scalar, execute) run the identical SQL over in-memory SQLite three ways: hand-written ADO.NET as the baseline ceiling, SqlBound generated code, and Dapper, with MemoryDiagnoser since allocations are where reflection-free codegen should show. Benchmarks live under bench/ (convention recorded in architecture.md), join the solution so CI builds them, and are never run in CI. A bench-local Directory.Build.props shields BenchmarkDotNet generated host projects from the repo root defaults, which would otherwise break them with multi-targeting and warning escalation. Co-Authored-By: Claude Fable 5 --- .claude/rules/architecture.md | 3 +- .editorconfig | 5 + bench/SqlBound.Benchmarks/BenchmarkQueries.cs | 24 +++ .../SqlBound.Benchmarks/Directory.Build.props | 11 ++ bench/SqlBound.Benchmarks/Program.cs | 3 + bench/SqlBound.Benchmarks/QueryBenchmarks.cs | 150 ++++++++++++++++++ .../SqlBound.Benchmarks.csproj | 33 ++++ sqlbound.slnx | 3 + 8 files changed, 231 insertions(+), 1 deletion(-) create mode 100644 bench/SqlBound.Benchmarks/BenchmarkQueries.cs create mode 100644 bench/SqlBound.Benchmarks/Directory.Build.props create mode 100644 bench/SqlBound.Benchmarks/Program.cs create mode 100644 bench/SqlBound.Benchmarks/QueryBenchmarks.cs create mode 100644 bench/SqlBound.Benchmarks/SqlBound.Benchmarks.csproj diff --git a/.claude/rules/architecture.md b/.claude/rules/architecture.md index b8a203e..1b87216 100644 --- a/.claude/rules/architecture.md +++ b/.claude/rules/architecture.md @@ -2,6 +2,7 @@ Rules governing physical project and solution structure (as opposed to logical design, which lives in `design-principles.md`): -- Repo/solution layout: source under `src//`, unit tests under `test/.UnitTests/` (singular `test`), integration tests requiring a real dependency (e.g. a database provider) under `test/.IntegrationTests/`, one solution file (`.slnx`) per repo or example, at its root, referencing every project beneath it. +- Repo/solution layout: source under `src//`, unit tests under `test/.UnitTests/` (singular `test`), integration tests requiring a real dependency (e.g. a database provider) under `test/.IntegrationTests/`, benchmark projects (BenchmarkDotNet) under `bench//`, one solution file (`.slnx`) per repo or example, at its root, referencing every project beneath it. +- Benchmark projects join the solution so CI builds them, but CI never runs them — benchmark numbers from shared runners are noise; baselines are produced locally and documented in `docs/`. - Centralize shared MSBuild properties (`TargetFramework`, `Nullable`, `TreatWarningsAsErrors`, etc.) in a `Directory.Build.props` at that same root instead of repeating them per `.csproj`. - `dotnet pack` runs in CI from day one so packaging bugs surface early. diff --git a/.editorconfig b/.editorconfig index 577e68b..dd3a131 100644 --- a/.editorconfig +++ b/.editorconfig @@ -100,3 +100,8 @@ dotnet_naming_style.camel_case_style.capitalization = camel_case # the Async suffix rule above, even when they are themselves async. [test/**/*.cs] dotnet_naming_rule.async_methods_should_be_async_suffixed.severity = none + +# Benchmark method names are the labels BenchmarkDotNet prints in result tables; an Async suffix +# is noise there, so benchmarks are exempt from the suffix rule like tests are. +[bench/**/*.cs] +dotnet_naming_rule.async_methods_should_be_async_suffixed.severity = none diff --git a/bench/SqlBound.Benchmarks/BenchmarkQueries.cs b/bench/SqlBound.Benchmarks/BenchmarkQueries.cs new file mode 100644 index 0000000..f15d58e --- /dev/null +++ b/bench/SqlBound.Benchmarks/BenchmarkQueries.cs @@ -0,0 +1,24 @@ +using System.Data.Common; + +namespace SqlBound.Benchmarks; + +public static partial class BenchmarkQueries +{ + [SqlQuery("SELECT id AS Id, name AS Name, price AS Price FROM items")] + public static partial Task> GetAllAsync( + DbConnection connection, CancellationToken cancellationToken = default); + + [SqlQuery("SELECT id AS Id, name AS Name, price AS Price FROM items WHERE id = @id")] + public static partial Task GetByIdAsync( + DbConnection connection, int id, CancellationToken cancellationToken = default); + + [SqlQuery("SELECT COUNT(*) FROM items")] + public static partial Task CountAsync( + DbConnection connection, CancellationToken cancellationToken = default); + + [SqlExecute("UPDATE items SET price = @price WHERE id = @id")] + public static partial Task RepriceAsync( + DbConnection connection, int id, decimal price, CancellationToken cancellationToken = default); +} + +public sealed record Item(int Id, string Name, decimal? Price); diff --git a/bench/SqlBound.Benchmarks/Directory.Build.props b/bench/SqlBound.Benchmarks/Directory.Build.props new file mode 100644 index 0000000..97c4086 --- /dev/null +++ b/bench/SqlBound.Benchmarks/Directory.Build.props @@ -0,0 +1,11 @@ + + + + + diff --git a/bench/SqlBound.Benchmarks/Program.cs b/bench/SqlBound.Benchmarks/Program.cs new file mode 100644 index 0000000..e6dd7a7 --- /dev/null +++ b/bench/SqlBound.Benchmarks/Program.cs @@ -0,0 +1,3 @@ +using BenchmarkDotNet.Running; + +BenchmarkSwitcher.FromAssembly(typeof(SqlBound.Benchmarks.QueryBenchmarks).Assembly).Run(args); diff --git a/bench/SqlBound.Benchmarks/QueryBenchmarks.cs b/bench/SqlBound.Benchmarks/QueryBenchmarks.cs new file mode 100644 index 0000000..588a31e --- /dev/null +++ b/bench/SqlBound.Benchmarks/QueryBenchmarks.cs @@ -0,0 +1,150 @@ +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Jobs; +using Dapper; +using Microsoft.Data.Sqlite; + +namespace SqlBound.Benchmarks; + +/// +/// Compares SqlBound's generated code against Dapper and hand-written ADO.NET (the ceiling) on +/// identical work over in-memory SQLite. Absolute numbers are dominated by SQLite itself; the +/// meaningful signal is the relative time and allocation across the three approaches. +/// +[MemoryDiagnoser] +[SimpleJob(RuntimeMoniker.Net10_0)] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public class QueryBenchmarks +{ + private const int RowCount = 1_000; + private SqliteConnection _connection = null!; + + [GlobalSetup] + public void Setup() + { + _connection = new SqliteConnection("Data Source=:memory:"); + _connection.Open(); + using var create = _connection.CreateCommand(); + create.CommandText = + "CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT NOT NULL, price REAL NULL, category TEXT NOT NULL)"; + create.ExecuteNonQuery(); + + using var transaction = _connection.BeginTransaction(); + for (var i = 1; i <= RowCount; i++) + { + using var insert = _connection.CreateCommand(); + insert.Transaction = transaction; + insert.CommandText = "INSERT INTO items (id, name, price, category) VALUES (@id, @name, @price, @category)"; + insert.Parameters.Add(new SqliteParameter("@id", i)); + insert.Parameters.Add(new SqliteParameter("@name", $"item-{i}")); + insert.Parameters.Add(new SqliteParameter("@price", i % 10 == 0 ? DBNull.Value : i * 0.5)); + insert.Parameters.Add(new SqliteParameter("@category", i % 2 == 0 ? "even" : "odd")); + insert.ExecuteNonQuery(); + } + + transaction.Commit(); + } + + [GlobalCleanup] + public void Cleanup() => _connection.Dispose(); + + [Benchmark(Baseline = true)] + [BenchmarkCategory("List1000")] + public async Task> RawAdoNet_List() + { + await using var command = _connection.CreateCommand(); + command.CommandText = "SELECT id AS Id, name AS Name, price AS Price FROM items"; + await using var reader = await command.ExecuteReaderAsync(); + var idOrdinal = reader.GetOrdinal("Id"); + var nameOrdinal = reader.GetOrdinal("Name"); + var priceOrdinal = reader.GetOrdinal("Price"); + var items = new List(); + while (await reader.ReadAsync()) + { + items.Add(new Item( + reader.GetInt32(idOrdinal), + reader.GetString(nameOrdinal), + reader.IsDBNull(priceOrdinal) ? null : reader.GetDecimal(priceOrdinal))); + } + + return items; + } + + [Benchmark] + [BenchmarkCategory("List1000")] + public Task> SqlBound_List() => BenchmarkQueries.GetAllAsync(_connection); + + [Benchmark] + [BenchmarkCategory("List1000")] + public async Task> Dapper_List() => + (await _connection.QueryAsync("SELECT id AS Id, name AS Name, price AS Price FROM items")).AsList(); + + [Benchmark(Baseline = true)] + [BenchmarkCategory("SingleRow")] + public async Task RawAdoNet_Single() + { + await using var command = _connection.CreateCommand(); + command.CommandText = "SELECT id AS Id, name AS Name, price AS Price FROM items WHERE id = @id"; + command.Parameters.Add(new SqliteParameter("@id", 500)); + await using var reader = await command.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) + { + throw new InvalidOperationException("No row."); + } + + return new Item( + reader.GetInt32(reader.GetOrdinal("Id")), + reader.GetString(reader.GetOrdinal("Name")), + reader.IsDBNull(reader.GetOrdinal("Price")) ? null : reader.GetDecimal(reader.GetOrdinal("Price"))); + } + + [Benchmark] + [BenchmarkCategory("SingleRow")] + public Task SqlBound_Single() => BenchmarkQueries.GetByIdAsync(_connection, 500); + + [Benchmark] + [BenchmarkCategory("SingleRow")] + public Task Dapper_Single() => + _connection.QuerySingleAsync( + "SELECT id AS Id, name AS Name, price AS Price FROM items WHERE id = @id", new { id = 500 }); + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Scalar")] + public async Task RawAdoNet_Scalar() + { + await using var command = _connection.CreateCommand(); + command.CommandText = "SELECT COUNT(*) FROM items"; + var value = await command.ExecuteScalarAsync(); + return Convert.ToInt32(value, System.Globalization.CultureInfo.InvariantCulture); + } + + [Benchmark] + [BenchmarkCategory("Scalar")] + public Task SqlBound_Scalar() => BenchmarkQueries.CountAsync(_connection); + + [Benchmark] + [BenchmarkCategory("Scalar")] + public Task Dapper_Scalar() => _connection.ExecuteScalarAsync("SELECT COUNT(*) FROM items"); + + [Benchmark(Baseline = true)] + [BenchmarkCategory("Execute")] + public async Task RawAdoNet_Execute() + { + await using var command = _connection.CreateCommand(); + command.CommandText = "UPDATE items SET price = @price WHERE id = @id"; + command.Parameters.Add(new SqliteParameter("@price", 1.25)); + command.Parameters.Add(new SqliteParameter("@id", 500)); + return await command.ExecuteNonQueryAsync(); + } + + [Benchmark] + [BenchmarkCategory("Execute")] + public Task SqlBound_Execute() => BenchmarkQueries.RepriceAsync(_connection, 500, 1.25m); + + [Benchmark] + [BenchmarkCategory("Execute")] + public Task Dapper_Execute() => + _connection.ExecuteAsync( + "UPDATE items SET price = @price WHERE id = @id", new { price = 1.25m, id = 500 }); +} diff --git a/bench/SqlBound.Benchmarks/SqlBound.Benchmarks.csproj b/bench/SqlBound.Benchmarks/SqlBound.Benchmarks.csproj new file mode 100644 index 0000000..de70dfc --- /dev/null +++ b/bench/SqlBound.Benchmarks/SqlBound.Benchmarks.csproj @@ -0,0 +1,33 @@ + + + + + Exe + net10.0 + enable + enable + latest + true + true + true + + $(NoWarn);CS1591 + false + + + + + + + + + + + + + + + diff --git a/sqlbound.slnx b/sqlbound.slnx index a0fa664..e8d5aad 100644 --- a/sqlbound.slnx +++ b/sqlbound.slnx @@ -1,4 +1,7 @@ + + + From b2be508f832c33e8505f2165d6699ef4239120d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 18:18:00 +0100 Subject: [PATCH 4/6] Document the benchmark baseline A full local run backs the docs: SqlBound stays within 1-9% of hand-written ADO.NET on point operations, allocates exactly what the hand-written loop allocates on 1000-row lists, and beats Dapper in every category on both time and allocations. The row type switches to SQLite''s natural provider types (long/double) because Dapper''s constructor mapping requires exact type matches and crashed on narrowing - noted in the docs as a fairness decision so all three contenders do identical work. The generated null guards cost 20% on list time versus raw; documented as the deliberate trade for descriptive NULL errors. Co-Authored-By: Claude Fable 5 --- bench/SqlBound.Benchmarks/BenchmarkQueries.cs | 4 +- bench/SqlBound.Benchmarks/QueryBenchmarks.cs | 8 +-- docs/benchmarks.md | 62 +++++++++++++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) create mode 100644 docs/benchmarks.md diff --git a/bench/SqlBound.Benchmarks/BenchmarkQueries.cs b/bench/SqlBound.Benchmarks/BenchmarkQueries.cs index f15d58e..6e48b3d 100644 --- a/bench/SqlBound.Benchmarks/BenchmarkQueries.cs +++ b/bench/SqlBound.Benchmarks/BenchmarkQueries.cs @@ -21,4 +21,6 @@ public static partial Task RepriceAsync( DbConnection connection, int id, decimal price, CancellationToken cancellationToken = default); } -public sealed record Item(int Id, string Name, decimal? Price); +// Uses SQLite's natural provider types (INTEGER => long, REAL => double) so all three contenders +// do identical work: Dapper's constructor mapping requires exact type matches and cannot narrow. +public sealed record Item(long Id, string Name, double? Price); diff --git a/bench/SqlBound.Benchmarks/QueryBenchmarks.cs b/bench/SqlBound.Benchmarks/QueryBenchmarks.cs index 588a31e..34c865f 100644 --- a/bench/SqlBound.Benchmarks/QueryBenchmarks.cs +++ b/bench/SqlBound.Benchmarks/QueryBenchmarks.cs @@ -63,9 +63,9 @@ public async Task> RawAdoNet_List() while (await reader.ReadAsync()) { items.Add(new Item( - reader.GetInt32(idOrdinal), + reader.GetInt64(idOrdinal), reader.GetString(nameOrdinal), - reader.IsDBNull(priceOrdinal) ? null : reader.GetDecimal(priceOrdinal))); + reader.IsDBNull(priceOrdinal) ? null : reader.GetDouble(priceOrdinal))); } return items; @@ -94,9 +94,9 @@ public async Task RawAdoNet_Single() } return new Item( - reader.GetInt32(reader.GetOrdinal("Id")), + reader.GetInt64(reader.GetOrdinal("Id")), reader.GetString(reader.GetOrdinal("Name")), - reader.IsDBNull(reader.GetOrdinal("Price")) ? null : reader.GetDecimal(reader.GetOrdinal("Price"))); + reader.IsDBNull(reader.GetOrdinal("Price")) ? null : reader.GetDouble(reader.GetOrdinal("Price"))); } [Benchmark] diff --git a/docs/benchmarks.md b/docs/benchmarks.md new file mode 100644 index 0000000..315e8fe --- /dev/null +++ b/docs/benchmarks.md @@ -0,0 +1,62 @@ +# Benchmarks + +`bench/SqlBound.Benchmarks` compares SqlBound's generated code against [Dapper](https://github.com/DapperLib/Dapper) +and hand-written raw ADO.NET (the practical ceiling) on identical SQL over an in-memory SQLite +database, with `MemoryDiagnoser` capturing allocations. + +## How to run + +```bash +dotnet run --project bench/SqlBound.Benchmarks -c Release -- --filter "*" +``` + +Benchmarks are built — never run — by CI: numbers from shared runners are noise. The baseline +below comes from a local run; re-run locally to compare like for like. + +## Reading the numbers + +- **Raw ADO.NET is the baseline** (ratio 1.00) in every category: it is what a human writes by + hand with `GetOrdinal` + typed getters, i.e. the same straight-line code the generator emits. + SqlBound's goal is to match it, not beat it. +- Absolute numbers are dominated by SQLite itself; the meaningful signal is the **relative** time + and allocation across the three approaches doing identical work. +- Allocations are where reflection-free codegen shows most clearly — Dapper's materializer is + fast, but its dynamic parameter and mapping machinery allocates more. + +## Baseline results + +Captured 2026-07-11 with the command above: + +``` +BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.8737/25H2) +AMD Ryzen Threadripper 3960X 3.80GHz, 1 CPU, 48 logical and 24 physical cores +.NET SDK 10.0.301, .NET 10.0.9, X64 RyuJIT x86-64-v3 +``` + +| Method | Categories | Mean | Ratio | Allocated | Alloc Ratio | +|------------------ |----------- |-----------:|------:|----------:|------------:| +| RawAdoNet_Execute | Execute | 3.349 us | 1.00 | 960 B | 1.00 | +| SqlBound_Execute | Execute | 3.652 us | 1.09 | 1000 B | 1.04 | +| Dapper_Execute | Execute | 4.234 us | 1.26 | 1536 B | 1.60 | +| RawAdoNet_List | List1000 | 432.710 us | 1.00 | 105832 B | 1.00 | +| SqlBound_List | List1000 | 517.365 us | 1.20 | 105904 B | 1.00 | +| Dapper_List | List1000 | 592.187 us | 1.37 | 151496 B | 1.43 | +| RawAdoNet_Scalar | Scalar | 2.865 us | 1.00 | 864 B | 1.00 | +| SqlBound_Scalar | Scalar | 2.881 us | 1.01 | 912 B | 1.06 | +| Dapper_Scalar | Scalar | 2.987 us | 1.04 | 864 B | 1.00 | +| RawAdoNet_Single | SingleRow | 4.572 us | 1.00 | 1624 B | 1.00 | +| SqlBound_Single | SingleRow | 4.792 us | 1.05 | 1696 B | 1.04 | +| Dapper_Single | SingleRow | 5.607 us | 1.23 | 2080 B | 1.28 | + +Takeaways from this baseline: + +- SqlBound stays within 1–9% of hand-written ADO.NET on point operations and beats Dapper in + every category on both time and allocations. +- List materialization allocates **exactly** what the hand-written loop allocates (1.00 ratio; + Dapper allocates 1.43×) — the reflection-free straight-line codegen doing its job. +- The 1.20× list time (vs raw's 1.00) is the price of generated null guards: the generated code + checks `IsDBNull` on *every* column to throw a descriptive error on unexpected NULLs, where the + hand-written loop checks only the nullable one. That trade is deliberate. +- Two benchmark-fairness notes: the row type uses SQLite's natural provider types (`long`, + `double`) because Dapper's constructor mapping requires exact type matches; and Dapper's strict + `QuerySingleAsync` was chosen to match SqlBound's strict-single semantics. From 8fa28e329dfd05d5e4193c24287e916a7d3e3f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 18:18:39 +0100 Subject: [PATCH 5/6] Close Phase 2 with version 0.2.0 Dropping the prerelease suffix in the milestone''s final commit keeps the v0.2.0 tag and the package version it marks identical, per the versioning convention. Phase 3 (Verification) opens the 0.3.0-preview line with its first commit. Co-Authored-By: Claude Fable 5 --- Directory.Build.props | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Directory.Build.props b/Directory.Build.props index be4679d..c671bab 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -12,7 +12,7 @@ Luís Amorim - 0.2.0-preview.2 + 0.2.0 Apache-2.0 https://github.com/lgamorim/sqlbound https://github.com/lgamorim/sqlbound From b1cc5605192a348a67462728951418b0b4db3c5b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lu=C3=ADs=20Amorim?= Date: Sat, 11 Jul 2026 19:00:46 +0100 Subject: [PATCH 6/6] Document phase-level GitHub milestones instead of per-M-number Restructured GitHub milestones so each phase gets one milestone (matching its minor version) instead of one per M-number, keeping all of a phase's PRs under a single milestone and making the milestone description a compounded summary of its M-numbers. Co-Authored-By: Claude Sonnet 5 --- .claude/rules/workflow.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/rules/workflow.md b/.claude/rules/workflow.md index 793a9d6..2f18348 100644 --- a/.claude/rules/workflow.md +++ b/.claude/rules/workflow.md @@ -11,5 +11,5 @@ - Versioning follows semantic versioning: each phase gets its own minor version (Phase 1 → `0.1.x`, Phase 2 → `0.2.x`, …; Phase 6 ships `1.0.0`). - When a phase completes, tag it on the default branch with an annotated tag (e.g., `git tag -a v0.1.0 -m "..."`) and push the tag to GitHub for reference. - `PackageVersion` carries a prerelease suffix (`X.Y.0-preview.N`) during a phase's active development. Closing the phase drops the suffix to the clean `X.Y.0` in the same commit that gets tagged, so the tag always matches the package version it marks exactly. The next phase's first commit starts the new prerelease line (`X.(Y+1).0-preview.1`). -- Each roadmap milestone has a matching GitHub milestone (created per phase); the milestone's PR is associated on creation and the milestone closed on merge. +- Each phase has one matching GitHub milestone (titled `Phase N — (0.Y.x)`), not one per M-number; every M-number's PR in that phase is associated with the phase's milestone on creation, and the milestone is closed when the phase's final PR merges. The milestone's description lists each composing M-number with its own description as a bullet, so the phase-level summary and the per-milestone detail both stay visible in one place. - Update this file when a new convention or correction is established.