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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/rules/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<ProjectName>/`, unit tests under `test/<ProjectName>.UnitTests/` (singular `test`), integration tests requiring a real dependency (e.g. a database provider) under `test/<ProjectName>.IntegrationTests/`, one solution file (`.slnx`) per repo or example, at its root, referencing every project beneath it.
- Repo/solution layout: source under `src/<ProjectName>/`, unit tests under `test/<ProjectName>.UnitTests/` (singular `test`), integration tests requiring a real dependency (e.g. a database provider) under `test/<ProjectName>.IntegrationTests/`, benchmark projects (BenchmarkDotNet) under `bench/<ProjectName>/`, 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.
2 changes: 1 addition & 1 deletion .claude/rules/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — <Name> (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.
5 changes: 5 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

<PropertyGroup Label="Package metadata">
<Authors>Luís Amorim</Authors>
<PackageVersion>0.2.0-preview.2</PackageVersion>
<PackageVersion>0.2.0</PackageVersion>
<PackageLicenseExpression>Apache-2.0</PackageLicenseExpression>
<PackageProjectUrl>https://github.com/lgamorim/sqlbound</PackageProjectUrl>
<RepositoryUrl>https://github.com/lgamorim/sqlbound</RepositoryUrl>
Expand Down
26 changes: 26 additions & 0 deletions bench/SqlBound.Benchmarks/BenchmarkQueries.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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<IReadOnlyList<Item>> 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<Item> GetByIdAsync(
DbConnection connection, int id, CancellationToken cancellationToken = default);

[SqlQuery("SELECT COUNT(*) FROM items")]
public static partial Task<int> CountAsync(
DbConnection connection, CancellationToken cancellationToken = default);

[SqlExecute("UPDATE items SET price = @price WHERE id = @id")]
public static partial Task<int> RepriceAsync(
DbConnection connection, int id, decimal price, CancellationToken cancellationToken = default);
}

// 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);
11 changes: 11 additions & 0 deletions bench/SqlBound.Benchmarks/Directory.Build.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<Project>

<!--
Deliberately empty and deliberately NOT importing the repository root Directory.Build.props:
BenchmarkDotNet generates throwaway host projects under bin/ at run time, and the root
defaults (net8.0;net10.0 multi-targeting, TreatWarningsAsErrors, doc-file generation) break
those generated projects. This file stops MSBuild's upward search so generated projects build
the way BenchmarkDotNet expects; SqlBound.Benchmarks.csproj redeclares the settings it needs.
-->

</Project>
3 changes: 3 additions & 0 deletions bench/SqlBound.Benchmarks/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
using BenchmarkDotNet.Running;

BenchmarkSwitcher.FromAssembly(typeof(SqlBound.Benchmarks.QueryBenchmarks).Assembly).Run(args);
150 changes: 150 additions & 0 deletions bench/SqlBound.Benchmarks/QueryBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using Dapper;
using Microsoft.Data.Sqlite;

namespace SqlBound.Benchmarks;

/// <summary>
/// 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.
/// </summary>
[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<IReadOnlyList<Item>> 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<Item>();
while (await reader.ReadAsync())
{
items.Add(new Item(
reader.GetInt64(idOrdinal),
reader.GetString(nameOrdinal),
reader.IsDBNull(priceOrdinal) ? null : reader.GetDouble(priceOrdinal)));
}

return items;
}

[Benchmark]
[BenchmarkCategory("List1000")]
public Task<IReadOnlyList<Item>> SqlBound_List() => BenchmarkQueries.GetAllAsync(_connection);

[Benchmark]
[BenchmarkCategory("List1000")]
public async Task<List<Item>> Dapper_List() =>
(await _connection.QueryAsync<Item>("SELECT id AS Id, name AS Name, price AS Price FROM items")).AsList();

[Benchmark(Baseline = true)]
[BenchmarkCategory("SingleRow")]
public async Task<Item> 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.GetInt64(reader.GetOrdinal("Id")),
reader.GetString(reader.GetOrdinal("Name")),
reader.IsDBNull(reader.GetOrdinal("Price")) ? null : reader.GetDouble(reader.GetOrdinal("Price")));
}

[Benchmark]
[BenchmarkCategory("SingleRow")]
public Task<Item> SqlBound_Single() => BenchmarkQueries.GetByIdAsync(_connection, 500);

[Benchmark]
[BenchmarkCategory("SingleRow")]
public Task<Item> Dapper_Single() =>
_connection.QuerySingleAsync<Item>(
"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<int> 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<int> SqlBound_Scalar() => BenchmarkQueries.CountAsync(_connection);

[Benchmark]
[BenchmarkCategory("Scalar")]
public Task<int> Dapper_Scalar() => _connection.ExecuteScalarAsync<int>("SELECT COUNT(*) FROM items");

[Benchmark(Baseline = true)]
[BenchmarkCategory("Execute")]
public async Task<int> 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<int> SqlBound_Execute() => BenchmarkQueries.RepriceAsync(_connection, 500, 1.25m);

[Benchmark]
[BenchmarkCategory("Execute")]
public Task<int> Dapper_Execute() =>
_connection.ExecuteAsync(
"UPDATE items SET price = @price WHERE id = @id", new { price = 1.25m, id = 500 });
}
33 changes: 33 additions & 0 deletions bench/SqlBound.Benchmarks/SqlBound.Benchmarks.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<Project Sdk="Microsoft.NET.Sdk">

<!--
The repo root Directory.Build.props is shielded off by the bench-local one (see the comment
there), so the relevant repo-wide settings are redeclared here for the benchmark code itself.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>latest</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<!-- Benchmark classes must be public for BenchmarkDotNet; they are not a documented API surface. -->
<NoWarn>$(NoWarn);CS1591</NoWarn>
<IsPackable>false</IsPackable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\SqlBound\SqlBound.csproj" />
<ProjectReference Include="..\..\src\SqlBound.Generators\SqlBound.Generators.csproj" OutputItemType="Analyzer" ReferenceOutputAssembly="false" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" Version="0.15.8" />
<PackageReference Include="Dapper" Version="2.1.79" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.9" />
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="3.53.3" />
</ItemGroup>

</Project>
62 changes: 62 additions & 0 deletions docs/benchmarks.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions sqlbound.slnx
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
<Solution>
<Folder Name="/bench/">
<Project Path="bench/SqlBound.Benchmarks/SqlBound.Benchmarks.csproj" />
</Folder>
<Folder Name="/src/">
<Project Path="src/SqlBound.Generators/SqlBound.Generators.csproj" />
<Project Path="src/SqlBound/SqlBound.csproj" />
</Folder>
<Folder Name="/test/">
<Project Path="test/SqlBound.AotSmokeTest/SqlBound.AotSmokeTest.csproj" />
<Project Path="test/SqlBound.Generators.UnitTests/SqlBound.Generators.UnitTests.csproj" />
<Project Path="test/SqlBound.IntegrationTests/SqlBound.IntegrationTests.csproj" />
<Project Path="test/SqlBound.UnitTests/SqlBound.UnitTests.csproj" />
Expand Down
5 changes: 5 additions & 0 deletions src/SqlBound/SqlBound.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@

<PropertyGroup>
<Description>Compile-time verified SQL for .NET.</Description>
<!--
Enables the trim, single-file, and AOT analyzers (failing the build on any IL2xxx/IL3xxx
finding, since TreatWarningsAsErrors is on) and stamps the package as AOT-compatible.
-->
<IsAotCompatible>true</IsAotCompatible>
</PropertyGroup>

</Project>
17 changes: 17 additions & 0 deletions test/SqlBound.AotSmokeTest/ItemEntityQueries.cs
Original file line number Diff line number Diff line change
@@ -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<IReadOnlyList<ItemEntity>> 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; }
}
Loading
Loading