From 5ae159ab0031e0ea3ca373eb1c53495d3c633eb1 Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 03:25:15 +0200 Subject: [PATCH 01/17] style: reindent the MSBuild and configuration files with two spaces .editorconfig is about to require two-space indentation for .csproj, .props, .targets, .slnx, .config, .xml and .DotSettings. Nothing in the tidy pipeline applies that: .csharpierignore keeps CSharpier away from XML, `dotnet format` does not touch XML whitespace, and the ReSharper profile only reorders C# members. So the transformation is done here, on its own, before the content changes land. Leading tabs only, two spaces each. No other byte moved: no reflow, no tab elsewhere on a line, and in DbConnectionPlus.slnx.DotSettings the XML-encoded file-layout string is the value of an element and is left exactly as it was. Every file still parses and every evaluated MSBuild property is unchanged. This commit is mechanical and belongs in .git-blame-ignore-revs; a later commit records it. Refs #28 Co-Authored-By: Claude Opus 5 --- DbConnectionPlus.slnx.DotSettings | 58 +++--- Directory.Build.props | 182 ++++++++--------- .../DbConnectionPlus.Benchmarks.csproj | 80 ++++---- ...nnectionPlus.DatabaseAdapters.MySql.csproj | 26 +-- ...nectionPlus.DatabaseAdapters.Oracle.csproj | 26 +-- ...ionPlus.DatabaseAdapters.PostgreSql.csproj | 26 +-- ...tionPlus.DatabaseAdapters.SqlServer.csproj | 26 +-- ...nectionPlus.DatabaseAdapters.Sqlite.csproj | 26 +-- src/DbConnectionPlus/DbConnectionPlus.csproj | 42 ++-- src/DbConnectionPlus/ILLink.Descriptors.xml | 20 +- src/Directory.Build.props | 184 +++++++++--------- .../DbConnectionPlus.IntegrationTests.csproj | 96 ++++----- .../DbConnectionPlus.UnitTests.csproj | 104 +++++----- .../AllAdaptersConsumer.csproj | 86 ++++---- .../AotConsumer/AotConsumer.csproj | 98 +++++----- .../package-consumption/Directory.Build.props | 16 +- .../Directory.Build.targets | 2 +- 17 files changed, 549 insertions(+), 549 deletions(-) diff --git a/DbConnectionPlus.slnx.DotSettings b/DbConnectionPlus.slnx.DotSettings index 54a5ad3..86daade 100644 --- a/DbConnectionPlus.slnx.DotSettings +++ b/DbConnectionPlus.slnx.DotSettings @@ -1,5 +1,5 @@ - <?xml version="1.0" encoding="utf-16"?> + <?xml version="1.0" encoding="utf-16"?> <Patterns xmlns="urn:schemas-jetbrains-com:member-reordering-patterns"> <TypePattern DisplayName="Types marked [NoReorder]" Priority="100"> <TypePattern.Match> @@ -225,31 +225,31 @@ </Entry> </TypePattern> </Patterns> - <?xml version="1.0" encoding="utf-16"?><Profile name="ReorderMembers"><CSReorderTypeMembers>True</CSReorderTypeMembers></Profile> - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True - True + <?xml version="1.0" encoding="utf-16"?><Profile name="ReorderMembers"><CSReorderTypeMembers>True</CSReorderTypeMembers></Profile> + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True + True diff --git a/Directory.Build.props b/Directory.Build.props index e457e58..1e40a9b 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,107 +1,107 @@ - + Anything that only the shipping libraries need belongs in src/Directory.Build.props instead, which + imports this file. MSBuild uses the NEAREST Directory.Build.props and stops there, so that import is + what keeps these properties applying under src/ as well. + --> - - David Liebeherr - rent-a-developer - Copyright © rent-a-developer / David Liebeherr - + + David Liebeherr + rent-a-developer + Copyright © rent-a-developer / David Liebeherr + - - enable - latest - enable - true - + + enable + latest + enable + true + - - True - true + + True + true - - true - + The tests and benchmarks do not document their public members, so CS1591 and CS1574 are switched + off for them in .editorconfig. In src/ both stay errors - that is what keeps the public API + documented. + --> + true + - - - true - + CSharpier_Bypass=true skips it, for the rare case of needing a build out of a tree mid-edit. + --> + true + - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + This is the NewStyleCop.Analyzers fork, not StyleCop.Analyzers. Upstream's last release is + 1.2.0-beta.556 from December 2023 and predates the C# this repository writes; the fork ships + stable releases and handles C# 14 extension declarations and collection expressions. The rule + IDs and stylecop.json are identical, so going back to upstream is a one-line change here. + --> + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - - - - + + + + diff --git a/benchmarks/DbConnectionPlus.Benchmarks/DbConnectionPlus.Benchmarks.csproj b/benchmarks/DbConnectionPlus.Benchmarks/DbConnectionPlus.Benchmarks.csproj index 7d9fcb8..b7e8ed3 100644 --- a/benchmarks/DbConnectionPlus.Benchmarks/DbConnectionPlus.Benchmarks.csproj +++ b/benchmarks/DbConnectionPlus.Benchmarks/DbConnectionPlus.Benchmarks.csproj @@ -1,48 +1,48 @@ - - Exe - net10.0 - RentADeveloper.DbConnectionPlus.Benchmarks - rent-a-developer DbConnectionPlus.Benchmarks - DbConnectionPlus.Benchmarks - RentADeveloper.DbConnectionPlus.Benchmarks.Program - false - $(InterceptorsNamespaces);Dapper.AOT - $(InterceptorsPreviewNamespaces);Dapper.AOT + + Exe + net10.0 + RentADeveloper.DbConnectionPlus.Benchmarks + rent-a-developer DbConnectionPlus.Benchmarks + DbConnectionPlus.Benchmarks + RentADeveloper.DbConnectionPlus.Benchmarks.Program + false + $(InterceptorsNamespaces);Dapper.AOT + $(InterceptorsPreviewNamespaces);Dapper.AOT - - - $(NoWarn);DAP028;RCS1181 + + + $(NoWarn);DAP028;RCS1181 - - + + - - - - - - + + + + + + - - - - + + + + diff --git a/src/DbConnectionPlus.DatabaseAdapters.MySql/DbConnectionPlus.DatabaseAdapters.MySql.csproj b/src/DbConnectionPlus.DatabaseAdapters.MySql/DbConnectionPlus.DatabaseAdapters.MySql.csproj index 8b818ba..af1333a 100644 --- a/src/DbConnectionPlus.DatabaseAdapters.MySql/DbConnectionPlus.DatabaseAdapters.MySql.csproj +++ b/src/DbConnectionPlus.DatabaseAdapters.MySql/DbConnectionPlus.DatabaseAdapters.MySql.csproj @@ -1,19 +1,19 @@ - - RentADeveloper.DbConnectionPlus.DatabaseAdapters.MySql - rent-a-developer DbConnectionPlus MySql Database Adapter - MySQL database adapter for DbConnectionPlus. - DbConnectionPlus.DatabaseAdapters.MySql - RentADeveloper.DbConnectionPlus.DatabaseAdapters.MySql - + + RentADeveloper.DbConnectionPlus.DatabaseAdapters.MySql + rent-a-developer DbConnectionPlus MySql Database Adapter + MySQL database adapter for DbConnectionPlus. + DbConnectionPlus.DatabaseAdapters.MySql + RentADeveloper.DbConnectionPlus.DatabaseAdapters.MySql + - - - + + + - - - + + + diff --git a/src/DbConnectionPlus.DatabaseAdapters.Oracle/DbConnectionPlus.DatabaseAdapters.Oracle.csproj b/src/DbConnectionPlus.DatabaseAdapters.Oracle/DbConnectionPlus.DatabaseAdapters.Oracle.csproj index 6e6641c..bb0b097 100644 --- a/src/DbConnectionPlus.DatabaseAdapters.Oracle/DbConnectionPlus.DatabaseAdapters.Oracle.csproj +++ b/src/DbConnectionPlus.DatabaseAdapters.Oracle/DbConnectionPlus.DatabaseAdapters.Oracle.csproj @@ -1,19 +1,19 @@ - - RentADeveloper.DbConnectionPlus.DatabaseAdapters.Oracle - rent-a-developer DbConnectionPlus Oracle Database Adapter - Oracle database adapter for DbConnectionPlus. - DbConnectionPlus.DatabaseAdapters.Oracle - RentADeveloper.DbConnectionPlus.DatabaseAdapters.Oracle - + + RentADeveloper.DbConnectionPlus.DatabaseAdapters.Oracle + rent-a-developer DbConnectionPlus Oracle Database Adapter + Oracle database adapter for DbConnectionPlus. + DbConnectionPlus.DatabaseAdapters.Oracle + RentADeveloper.DbConnectionPlus.DatabaseAdapters.Oracle + - - - + + + - - - + + + diff --git a/src/DbConnectionPlus.DatabaseAdapters.PostgreSql/DbConnectionPlus.DatabaseAdapters.PostgreSql.csproj b/src/DbConnectionPlus.DatabaseAdapters.PostgreSql/DbConnectionPlus.DatabaseAdapters.PostgreSql.csproj index f748a46..a0ec633 100644 --- a/src/DbConnectionPlus.DatabaseAdapters.PostgreSql/DbConnectionPlus.DatabaseAdapters.PostgreSql.csproj +++ b/src/DbConnectionPlus.DatabaseAdapters.PostgreSql/DbConnectionPlus.DatabaseAdapters.PostgreSql.csproj @@ -1,19 +1,19 @@ - - RentADeveloper.DbConnectionPlus.DatabaseAdapters.PostgreSql - rent-a-developer DbConnectionPlus PostgreSQL Database Adapter - PostgreSQL database adapter for DbConnectionPlus. - DbConnectionPlus.DatabaseAdapters.PostgreSql - RentADeveloper.DbConnectionPlus.DatabaseAdapters.PostgreSql - + + RentADeveloper.DbConnectionPlus.DatabaseAdapters.PostgreSql + rent-a-developer DbConnectionPlus PostgreSQL Database Adapter + PostgreSQL database adapter for DbConnectionPlus. + DbConnectionPlus.DatabaseAdapters.PostgreSql + RentADeveloper.DbConnectionPlus.DatabaseAdapters.PostgreSql + - - - + + + - - - + + + diff --git a/src/DbConnectionPlus.DatabaseAdapters.SqlServer/DbConnectionPlus.DatabaseAdapters.SqlServer.csproj b/src/DbConnectionPlus.DatabaseAdapters.SqlServer/DbConnectionPlus.DatabaseAdapters.SqlServer.csproj index aa3acb5..9afcc23 100644 --- a/src/DbConnectionPlus.DatabaseAdapters.SqlServer/DbConnectionPlus.DatabaseAdapters.SqlServer.csproj +++ b/src/DbConnectionPlus.DatabaseAdapters.SqlServer/DbConnectionPlus.DatabaseAdapters.SqlServer.csproj @@ -1,19 +1,19 @@ - - RentADeveloper.DbConnectionPlus.DatabaseAdapters.SqlServer - rent-a-developer DbConnectionPlus SQL Server Database Adapter - SQL Server database adapter for DbConnectionPlus. - DbConnectionPlus.DatabaseAdapters.SqlServer - RentADeveloper.DbConnectionPlus.DatabaseAdapters.SqlServer - + + RentADeveloper.DbConnectionPlus.DatabaseAdapters.SqlServer + rent-a-developer DbConnectionPlus SQL Server Database Adapter + SQL Server database adapter for DbConnectionPlus. + DbConnectionPlus.DatabaseAdapters.SqlServer + RentADeveloper.DbConnectionPlus.DatabaseAdapters.SqlServer + - - - + + + - - - + + + diff --git a/src/DbConnectionPlus.DatabaseAdapters.Sqlite/DbConnectionPlus.DatabaseAdapters.Sqlite.csproj b/src/DbConnectionPlus.DatabaseAdapters.Sqlite/DbConnectionPlus.DatabaseAdapters.Sqlite.csproj index 485445e..e9ca464 100644 --- a/src/DbConnectionPlus.DatabaseAdapters.Sqlite/DbConnectionPlus.DatabaseAdapters.Sqlite.csproj +++ b/src/DbConnectionPlus.DatabaseAdapters.Sqlite/DbConnectionPlus.DatabaseAdapters.Sqlite.csproj @@ -1,19 +1,19 @@ - - RentADeveloper.DbConnectionPlus.DatabaseAdapters.Sqlite - rent-a-developer DbConnectionPlus SQLite Database Adapter - SQLite database adapter for DbConnectionPlus. - DbConnectionPlus.DatabaseAdapters.Sqlite - RentADeveloper.DbConnectionPlus.DatabaseAdapters.Sqlite - + + RentADeveloper.DbConnectionPlus.DatabaseAdapters.Sqlite + rent-a-developer DbConnectionPlus SQLite Database Adapter + SQLite database adapter for DbConnectionPlus. + DbConnectionPlus.DatabaseAdapters.Sqlite + RentADeveloper.DbConnectionPlus.DatabaseAdapters.Sqlite + - - - + + + - - - + + + diff --git a/src/DbConnectionPlus/DbConnectionPlus.csproj b/src/DbConnectionPlus/DbConnectionPlus.csproj index 11868b3..163f1c8 100644 --- a/src/DbConnectionPlus/DbConnectionPlus.csproj +++ b/src/DbConnectionPlus/DbConnectionPlus.csproj @@ -1,27 +1,27 @@ - - RentADeveloper.DbConnectionPlus - rent-a-developer DbConnectionPlus - A lightweight .NET ORM and extension library for the type DbConnection that adds high-performance, type-safe helpers to reduce boilerplate code, boost productivity, and make working with SQL databases in C# more enjoyable. - DbConnectionPlus - ORM sql server mysql postgresql sqlite oracle SqlConnection DbConnection extensions connection entity insert delete update CRUD - RentADeveloper.DbConnectionPlus - + + RentADeveloper.DbConnectionPlus + rent-a-developer DbConnectionPlus + A lightweight .NET ORM and extension library for the type DbConnection that adds high-performance, type-safe helpers to reduce boilerplate code, boost productivity, and make working with SQL databases in C# more enjoyable. + DbConnectionPlus + ORM sql server mysql postgresql sqlite oracle SqlConnection DbConnection extensions connection entity insert delete update CRUD + RentADeveloper.DbConnectionPlus + - - - - ILLink.Descriptors.xml - - + + + + ILLink.Descriptors.xml + + - - - - + + + + diff --git a/src/DbConnectionPlus/ILLink.Descriptors.xml b/src/DbConnectionPlus/ILLink.Descriptors.xml index 585dc71..3859a25 100644 --- a/src/DbConnectionPlus/ILLink.Descriptors.xml +++ b/src/DbConnectionPlus/ILLink.Descriptors.xml @@ -43,14 +43,14 @@ --> - - - - - - - - - - + + + + + + + + + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 32008c3..1b98ec8 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,109 +1,109 @@ - + What lives here is what only a shipping, packable library needs: multi-targeting, the AOT, CA and + public-API analyzers, and the NuGet package metadata. Per-project identity - AssemblyName, + AssemblyTitle, RootNamespace, PackageId, Description, PackageTags - stays in each .csproj. + --> - + - - - 4.0.0 - + + + 4.0.0 + - - - net8.0;net10.0 - + The unit tests multi-target the same pair, because the two builds of these libraries are not the + same code - net8.0 carries an IL3050 suppression net10.0 does not. The integration tests stay + net8.0 (they are bound by database containers, not by the runtime) and the benchmarks are net10.0 + (BenchmarkDotNet's NativeAotToolchain.Net10_0 publishes that TFM). + --> + net8.0;net10.0 + - - - true - + + + true + - - - latest-all - True - + + + latest-all + True + - - - false - true - true - snupkg - true - + + + false + true + true + snupkg + true + - - - logo-128.png - LICENSE.md - https://github.com/rent-a-developer/DbConnectionPlus - PACKAGE_README.md - See CHANGELOG.md. - True - GIT - https://github.com/rent-a-developer/DbConnectionPlus.git - + + + logo-128.png + LICENSE.md + https://github.com/rent-a-developer/DbConnectionPlus + PACKAGE_README.md + See CHANGELOG.md. + True + GIT + https://github.com/rent-a-developer/DbConnectionPlus.git + - - - - - - - + + + + + + + - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - - - - + + + + diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionPlus.IntegrationTests.csproj b/tests/DbConnectionPlus.IntegrationTests/DbConnectionPlus.IntegrationTests.csproj index 0f92514..7a0d6c8 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionPlus.IntegrationTests.csproj +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionPlus.IntegrationTests.csproj @@ -1,55 +1,55 @@ - - Exe - net8.0 - RentADeveloper.DbConnectionPlus.IntegrationTests - rent-a-developer DbConnectionPlus.IntegrationTests - RentADeveloper.DbConnectionPlus.IntegrationTests - + + Exe + net8.0 + RentADeveloper.DbConnectionPlus.IntegrationTests + rent-a-developer DbConnectionPlus.IntegrationTests + RentADeveloper.DbConnectionPlus.IntegrationTests + - - - + + + - - - - - - - - - - - - - - - - - - - all - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + + + + + + + + + + + + + + + + + all + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - - - - - - - - - + + + + + + + + + diff --git a/tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj b/tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj index 2aabc14..3f3aba7 100644 --- a/tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj +++ b/tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj @@ -1,60 +1,60 @@ - - Exe - - net8.0;net10.0 - RentADeveloper.DbConnectionPlus.UnitTests - rent-a-developer DbConnectionPlus.UnitTests - RentADeveloper.DbConnectionPlus.UnitTests - + The integration suite deliberately stays single-target - it is bound by four database containers, + not by the runtime, and doubling a large test suite buys nothing the materializer tests here do not + already cover on both runtimes. + --> + net8.0;net10.0 + RentADeveloper.DbConnectionPlus.UnitTests + rent-a-developer DbConnectionPlus.UnitTests + RentADeveloper.DbConnectionPlus.UnitTests + - - - + + + - - - - - - - - - - - - - - - - - - all - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - + + + + + + + + + + + + + + + + + + all + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + - - - - - - - - + + + + + + + + diff --git a/tests/package-consumption/AllAdaptersConsumer/AllAdaptersConsumer.csproj b/tests/package-consumption/AllAdaptersConsumer/AllAdaptersConsumer.csproj index 8824d12..8898e89 100644 --- a/tests/package-consumption/AllAdaptersConsumer/AllAdaptersConsumer.csproj +++ b/tests/package-consumption/AllAdaptersConsumer/AllAdaptersConsumer.csproj @@ -1,47 +1,47 @@ - - Exe - - - net8.0 - - RentADeveloper.DbConnectionPlus.PackageConsumption.AllAdapters - AllAdaptersConsumer - - - enable - latest - enable - - false - false - - - 4.0.0 - - - - - - - - - - - - - - - - + + Exe + + + net8.0 + + RentADeveloper.DbConnectionPlus.PackageConsumption.AllAdapters + AllAdaptersConsumer + + + enable + latest + enable + + false + false + + + 4.0.0 + + + + + + + + + + + + + + + + diff --git a/tests/package-consumption/AotConsumer/AotConsumer.csproj b/tests/package-consumption/AotConsumer/AotConsumer.csproj index e1d4612..0c6c6de 100644 --- a/tests/package-consumption/AotConsumer/AotConsumer.csproj +++ b/tests/package-consumption/AotConsumer/AotConsumer.csproj @@ -1,53 +1,53 @@ - - Exe - - - net8.0;net10.0 - - RentADeveloper.DbConnectionPlus.PackageConsumption.Aot - AotConsumer - - - enable - latest - enable - - false - false - full - - - 4.0.0 - - - - - - - - - - - - - - + + Exe + + + net8.0;net10.0 + + RentADeveloper.DbConnectionPlus.PackageConsumption.Aot + AotConsumer + + + enable + latest + enable + + false + false + full + + + 4.0.0 + + + + + + + + + + + + + + diff --git a/tests/package-consumption/Directory.Build.props b/tests/package-consumption/Directory.Build.props index 2e59230..59fdaf3 100644 --- a/tests/package-consumption/Directory.Build.props +++ b/tests/package-consumption/Directory.Build.props @@ -1,13 +1,13 @@ - + That is the whole point of these projects. They must receive the library, the adapters and their + driver dependencies exclusively from the packed NuGet packages - if the repository's authorship, + analyzers and multi-targeting leaked in, a consumer test would be testing the repository's build + rather than the packages, and would prove nothing about what a real consumer installs. + --> diff --git a/tests/package-consumption/Directory.Build.targets b/tests/package-consumption/Directory.Build.targets index bee537c..6ef96d2 100644 --- a/tests/package-consumption/Directory.Build.targets +++ b/tests/package-consumption/Directory.Build.targets @@ -1,5 +1,5 @@ - + From 2e24977cd8ca61de7f05e6eb7741832589ed3dc8 Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 03:25:50 +0200 Subject: [PATCH 02/17] build: manage package versions centrally and make packing opt-in Every version in the repository now lives in Directory.Packages.props, grouped by what it is for, so a bump is one edit in one file instead of a hunt through eleven projects that could disagree with each other. The package-consumption projects opt out of central management on purpose: they exist to consume the produced packages the way a stranger would, and pinning their versions centrally would make that test lie. Packing is opt-in. Every project is IsPackable=false at the root and the six shipping projects say yes themselves, so a new test, sample or tool project cannot ship by forgetting to opt out. The four packed files move to the repository-root Directory.Build.targets, because IsPackable is set by the project after every .props file has been read and a condition on it in a .props file is always false. Packaging changes that follow from that: - moves to the repository-root Directory.Build.props, so one file answers what version the repository is at, and scripts read it from there. - The licence is the SPDX expression MIT rather than a file reference, which nuget.org and licence tooling can read without opening the package. LICENSE.md is still packed, unchanged. - PackageRequireLicenseAcceptance is false: acceptance prompts are for restricted licences, and one in front of an MIT package trains people to click through the prompts that matter. - Package validation runs against the published 4.0.0, so removing or changing a public signature fails the pack instead of reaching nuget.org. - NuGetAudit runs at "all" scope, where transitive advisories are. NU1901-NU1904 stay warnings rather than errors, so a newly published advisory in the transitive graph cannot break a build that touched nothing. The shared test settings move to tests/Directory.Build.props, which imports the repository root explicitly because MSBuild stops at the first Directory.Build.props it finds. No version, framework floor, package id, assembly name or dependency was changed. The six packages, their contents and their dependency groups are byte-identical apart from the two licence lines and the release notes URL above. Refs #28 Co-Authored-By: Claude Opus 5 --- Directory.Build.props | 64 +++++++++-- Directory.Build.targets | 36 ++++++ Directory.Packages.props | 106 ++++++++++++++++++ .../DbConnectionPlus.Benchmarks.csproj | 8 +- ...nnectionPlus.DatabaseAdapters.MySql.csproj | 4 +- ...nectionPlus.DatabaseAdapters.Oracle.csproj | 4 +- ...ionPlus.DatabaseAdapters.PostgreSql.csproj | 4 +- ...tionPlus.DatabaseAdapters.SqlServer.csproj | 4 +- ...nectionPlus.DatabaseAdapters.Sqlite.csproj | 4 +- src/DbConnectionPlus/DbConnectionPlus.csproj | 6 +- src/Directory.Build.props | 61 ++++++---- .../DbConnectionPlus.IntegrationTests.csproj | 40 +++---- .../DbConnectionPlus.UnitTests.csproj | 40 +++---- tests/Directory.Build.props | 49 ++++++++ .../Directory.Packages.props | 20 ++++ 15 files changed, 354 insertions(+), 96 deletions(-) create mode 100644 Directory.Build.targets create mode 100644 Directory.Packages.props create mode 100644 tests/Directory.Build.props create mode 100644 tests/package-consumption/Directory.Packages.props diff --git a/Directory.Build.props b/Directory.Build.props index 1e40a9b..87886fa 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -10,6 +10,20 @@ what keeps these properties applying under src/ as well. --> + + + 4.0.0 + + David Liebeherr rent-a-developer @@ -23,6 +37,39 @@ true + + + false + + + + + true + all + moderate + NU1901;NU1902;NU1903;NU1904 + + True true @@ -63,19 +110,20 @@ consumer of the packages. --> - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -89,11 +137,11 @@ stable releases and handles C# 14 extension declarations and collection expressions. The rule IDs and stylecop.json are identical, so going back to upstream is a one-line change here. --> - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..d3d22d0 --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,36 @@ + + + + + + + + + + + + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..dbb79cd --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,106 @@ + + + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/benchmarks/DbConnectionPlus.Benchmarks/DbConnectionPlus.Benchmarks.csproj b/benchmarks/DbConnectionPlus.Benchmarks/DbConnectionPlus.Benchmarks.csproj index b7e8ed3..3f7ce76 100644 --- a/benchmarks/DbConnectionPlus.Benchmarks/DbConnectionPlus.Benchmarks.csproj +++ b/benchmarks/DbConnectionPlus.Benchmarks/DbConnectionPlus.Benchmarks.csproj @@ -34,10 +34,10 @@ - - - - + + + + diff --git a/src/DbConnectionPlus.DatabaseAdapters.MySql/DbConnectionPlus.DatabaseAdapters.MySql.csproj b/src/DbConnectionPlus.DatabaseAdapters.MySql/DbConnectionPlus.DatabaseAdapters.MySql.csproj index af1333a..346f068 100644 --- a/src/DbConnectionPlus.DatabaseAdapters.MySql/DbConnectionPlus.DatabaseAdapters.MySql.csproj +++ b/src/DbConnectionPlus.DatabaseAdapters.MySql/DbConnectionPlus.DatabaseAdapters.MySql.csproj @@ -4,6 +4,8 @@ RentADeveloper.DbConnectionPlus.DatabaseAdapters.MySql rent-a-developer DbConnectionPlus MySql Database Adapter MySQL database adapter for DbConnectionPlus. + + true DbConnectionPlus.DatabaseAdapters.MySql RentADeveloper.DbConnectionPlus.DatabaseAdapters.MySql @@ -13,7 +15,7 @@ - + diff --git a/src/DbConnectionPlus.DatabaseAdapters.Oracle/DbConnectionPlus.DatabaseAdapters.Oracle.csproj b/src/DbConnectionPlus.DatabaseAdapters.Oracle/DbConnectionPlus.DatabaseAdapters.Oracle.csproj index bb0b097..9852f5f 100644 --- a/src/DbConnectionPlus.DatabaseAdapters.Oracle/DbConnectionPlus.DatabaseAdapters.Oracle.csproj +++ b/src/DbConnectionPlus.DatabaseAdapters.Oracle/DbConnectionPlus.DatabaseAdapters.Oracle.csproj @@ -4,6 +4,8 @@ RentADeveloper.DbConnectionPlus.DatabaseAdapters.Oracle rent-a-developer DbConnectionPlus Oracle Database Adapter Oracle database adapter for DbConnectionPlus. + + true DbConnectionPlus.DatabaseAdapters.Oracle RentADeveloper.DbConnectionPlus.DatabaseAdapters.Oracle @@ -13,7 +15,7 @@ - + diff --git a/src/DbConnectionPlus.DatabaseAdapters.PostgreSql/DbConnectionPlus.DatabaseAdapters.PostgreSql.csproj b/src/DbConnectionPlus.DatabaseAdapters.PostgreSql/DbConnectionPlus.DatabaseAdapters.PostgreSql.csproj index a0ec633..3b31df5 100644 --- a/src/DbConnectionPlus.DatabaseAdapters.PostgreSql/DbConnectionPlus.DatabaseAdapters.PostgreSql.csproj +++ b/src/DbConnectionPlus.DatabaseAdapters.PostgreSql/DbConnectionPlus.DatabaseAdapters.PostgreSql.csproj @@ -4,6 +4,8 @@ RentADeveloper.DbConnectionPlus.DatabaseAdapters.PostgreSql rent-a-developer DbConnectionPlus PostgreSQL Database Adapter PostgreSQL database adapter for DbConnectionPlus. + + true DbConnectionPlus.DatabaseAdapters.PostgreSql RentADeveloper.DbConnectionPlus.DatabaseAdapters.PostgreSql @@ -13,7 +15,7 @@ - + diff --git a/src/DbConnectionPlus.DatabaseAdapters.SqlServer/DbConnectionPlus.DatabaseAdapters.SqlServer.csproj b/src/DbConnectionPlus.DatabaseAdapters.SqlServer/DbConnectionPlus.DatabaseAdapters.SqlServer.csproj index 9afcc23..c768343 100644 --- a/src/DbConnectionPlus.DatabaseAdapters.SqlServer/DbConnectionPlus.DatabaseAdapters.SqlServer.csproj +++ b/src/DbConnectionPlus.DatabaseAdapters.SqlServer/DbConnectionPlus.DatabaseAdapters.SqlServer.csproj @@ -4,6 +4,8 @@ RentADeveloper.DbConnectionPlus.DatabaseAdapters.SqlServer rent-a-developer DbConnectionPlus SQL Server Database Adapter SQL Server database adapter for DbConnectionPlus. + + true DbConnectionPlus.DatabaseAdapters.SqlServer RentADeveloper.DbConnectionPlus.DatabaseAdapters.SqlServer @@ -13,7 +15,7 @@ - + diff --git a/src/DbConnectionPlus.DatabaseAdapters.Sqlite/DbConnectionPlus.DatabaseAdapters.Sqlite.csproj b/src/DbConnectionPlus.DatabaseAdapters.Sqlite/DbConnectionPlus.DatabaseAdapters.Sqlite.csproj index e9ca464..cf92ed8 100644 --- a/src/DbConnectionPlus.DatabaseAdapters.Sqlite/DbConnectionPlus.DatabaseAdapters.Sqlite.csproj +++ b/src/DbConnectionPlus.DatabaseAdapters.Sqlite/DbConnectionPlus.DatabaseAdapters.Sqlite.csproj @@ -4,6 +4,8 @@ RentADeveloper.DbConnectionPlus.DatabaseAdapters.Sqlite rent-a-developer DbConnectionPlus SQLite Database Adapter SQLite database adapter for DbConnectionPlus. + + true DbConnectionPlus.DatabaseAdapters.Sqlite RentADeveloper.DbConnectionPlus.DatabaseAdapters.Sqlite @@ -13,7 +15,7 @@ - + diff --git a/src/DbConnectionPlus/DbConnectionPlus.csproj b/src/DbConnectionPlus/DbConnectionPlus.csproj index 163f1c8..7072bae 100644 --- a/src/DbConnectionPlus/DbConnectionPlus.csproj +++ b/src/DbConnectionPlus/DbConnectionPlus.csproj @@ -4,6 +4,8 @@ RentADeveloper.DbConnectionPlus rent-a-developer DbConnectionPlus A lightweight .NET ORM and extension library for the type DbConnection that adds high-performance, type-safe helpers to reduce boilerplate code, boost productivity, and make working with SQL databases in C# more enjoyable. + + true DbConnectionPlus ORM sql server mysql postgresql sqlite oracle SqlConnection DbConnection extensions connection entity insert delete update CRUD RentADeveloper.DbConnectionPlus @@ -20,8 +22,8 @@ - - + + diff --git a/src/Directory.Build.props b/src/Directory.Build.props index 1b98ec8..c5326a0 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -10,20 +10,17 @@ What lives here is what only a shipping, packable library needs: multi-targeting, the AOT, CA and public-API analyzers, and the NuGet package metadata. Per-project identity - AssemblyName, - AssemblyTitle, RootNamespace, PackageId, Description, PackageTags - stays in each .csproj. + AssemblyTitle, RootNamespace, PackageId, Description, PackageTags - stays in each .csproj, and so does + IsPackable=true, because packing is opt-in. + + Two things are deliberately NOT here any more. is in the repository-root + Directory.Build.props, so one file answers "what version is this repository at". The files the packages + carry are in the repository-root Directory.Build.targets, because they are conditioned on IsPackable, + which a project sets after every .props file has been read. --> - - - 4.0.0 - - + logo-128.png - LICENSE.md + MIT https://github.com/rent-a-developer/DbConnectionPlus PACKAGE_README.md - See CHANGELOG.md. - True + https://github.com/rent-a-developer/DbConnectionPlus/blob/main/CHANGELOG.md + false GIT https://github.com/rent-a-developer/DbConnectionPlus.git - + - - - - - + true + 4.0.0 + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionPlus.IntegrationTests.csproj b/tests/DbConnectionPlus.IntegrationTests/DbConnectionPlus.IntegrationTests.csproj index 7a0d6c8..85605f8 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionPlus.IntegrationTests.csproj +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionPlus.IntegrationTests.csproj @@ -1,7 +1,6 @@ - Exe net8.0 RentADeveloper.DbConnectionPlus.IntegrationTests rent-a-developer DbConnectionPlus.IntegrationTests @@ -9,37 +8,24 @@ - - - - - - - - - - - - + + + + + + + + - - - - - - - - - all - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + diff --git a/tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj b/tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj index 3f3aba7..48c05a5 100644 --- a/tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj +++ b/tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj @@ -1,7 +1,6 @@ - Exe - - all - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - + + + + + + + + + + + + + diff --git a/tests/Directory.Build.props b/tests/Directory.Build.props new file mode 100644 index 0000000..966682f --- /dev/null +++ b/tests/Directory.Build.props @@ -0,0 +1,49 @@ + + + + + + + + + Exe + + + + + + + + + + + all + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/tests/package-consumption/Directory.Packages.props b/tests/package-consumption/Directory.Packages.props new file mode 100644 index 0000000..26e6f0c --- /dev/null +++ b/tests/package-consumption/Directory.Packages.props @@ -0,0 +1,20 @@ + + + + + + false + + + From 5aea5fd7f012bfb6cb8563303af537ab48616cda Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 03:26:06 +0200 Subject: [PATCH 03/17] build: state the style contract once and drop the rules that decided nothing The `var` decision had two of its three halves written down; the third was left to the analyzer default, so what the repository wanted depended on a tool version. It is now stated - explicitly typed where nothing on the line names the type - and deliberately silent rather than an error, because raising it would rewrite every such declaration and that is a code change, not a configuration one. The rest is the same kind of tidying: - One indentation rule for every structured configuration format, two spaces, matching what Visual Studio and Rider do to XML by themselves. The files were re-indented in the previous commit. - The top-level-statements preference said the opposite of what all three executables here do, each of which has an explicit Program class and one of which names it in . - The naming rule for non-field members demanded plain PascalCase, which rejects every Method_Scenario_ShouldExpectedOutcome test name AGENTS.md requires. It now uses the same style as the type rule instead of contradicting a documented convention, and pascal_case, referenced by nothing afterwards, is gone. - usingDirectivesPlacement in stylecop.json was read by no enabled rule - SA1200 is off, because CSharpier decides placement - so it stated a policy nothing enforced. - The ReSharper file layout now pins StaticFieldReorderingPolicy explicitly. It was the implicit default; saying it means a ReSharper release cannot change member order without a commit that says so. Measured: byte-identical output on a full cleanup run before and after. No .cs file changes as a result of this - the C# half of the contract already held. The comment about "zero violations across 283 files" is replaced by the fact that makes it true: the rules are errors and the build is green. Refs #28 Co-Authored-By: Claude Opus 5 --- .editorconfig | 55 ++++++++++++++++++++----------- DbConnectionPlus.slnx.DotSettings | 2 +- stylecop.json | 3 +- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/.editorconfig b/.editorconfig index b47816e..8515903 100644 --- a/.editorconfig +++ b/.editorconfig @@ -4,8 +4,9 @@ root = true # Every file # # Line endings are LF everywhere, on every OS. .gitattributes stores LF and checks out LF - that is the -# guarantee, and it holds whatever core.autocrlf a contributor has. end_of_line below is what makes the -# editors and formatters write LF too, so the working tree never drifts. +# guarantee, and it holds whatever core.autocrlf a contributor has. end_of_line below ASKS editors and +# formatters to write LF as well. A tool that ignores it cannot change what git stores, but it does leave +# the file listed as modified with an empty diff; CONTRIBUTING.md says how to clear that. # ====================================================================================================== [*] @@ -16,11 +17,10 @@ indent_size = 4 insert_final_newline = true trim_trailing_whitespace = true -[*.{csproj,props,targets,DotSettings}] -indent_style = tab -tab_width = 4 - -[*.{slnx,config,xml,json,yml,yaml}] +# Two spaces for every structured configuration format, including the MSBuild files. That is the Visual +# Studio and Rider default for XML, so an editor that reindents one of these files agrees with the +# repository instead of fighting it. CSharpier does not format XML here - see .csharpierignore. +[*.{csproj,props,targets,slnx,config,xml,DotSettings,json,yml,yaml}] indent_size = 2 # Two trailing spaces are a hard line break in Markdown, so they must survive. @@ -56,10 +56,20 @@ max_line_length = 120 dotnet_style_predefined_type_for_locals_parameters_members = true:error dotnet_style_predefined_type_for_member_access = true:error -# `var` wherever the type is obvious from the right-hand side. +# `var` wherever the type is obvious from the right-hand side - a built-in type, or a right-hand side that +# names the type. Both are errors. csharp_style_var_for_built_in_types = true:error csharp_style_var_when_type_is_apparent = true:error +# Everywhere else - `var x = Compute();`, where nothing on the line says what the type is - the preference +# is the explicit type. It is stated here rather than left to the analyzer's default so that all three +# halves of the `var` decision are answered in one place and none of them depends on a tool version. +# +# It is deliberately NOT an error. Raising it would be a rewrite of every such declaration in the +# repository, which is a code change rather than a configuration one; the two rules above already decide +# the cases this repository actually argues about. +csharp_style_var_elsewhere = false:silent + #### Member access: always `this.` #### # Instance members are always read and written through `this.`, and fields are never `_camelCase`. @@ -100,7 +110,10 @@ dotnet_style_namespace_match_folder = true:error csharp_prefer_braces = true:error csharp_style_prefer_primary_constructors = true:error -csharp_style_prefer_top_level_statements = true:suggestion +# All three executables here - the benchmarks and the two package consumers - have an explicit `Program` +# class, and the benchmark project names it in . Preferring top-level statements would +# suggest undoing that on every one of them, so the preference states what the repository does. +csharp_style_prefer_top_level_statements = false:silent dotnet_style_prefer_auto_properties = true:error #### Modern language features #### @@ -168,9 +181,12 @@ dotnet_naming_rule.types_should_be_pascal_with_underscores.severity = warning dotnet_naming_rule.types_should_be_pascal_with_underscores.symbols = types dotnet_naming_rule.types_should_be_pascal_with_underscores.style = pascal_with_underscores -dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion -dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members -dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case +# Underscores, because the mandated test method name is Method_Scenario_ShouldExpectedOutcome. A plain +# pascal_case style rejects every one of those - thousands of members - which is why this rule uses the +# same style as the type rule above rather than contradicting a convention AGENTS.md requires. +dotnet_naming_rule.non_field_members_should_be_pascal_with_underscores.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_with_underscores.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_with_underscores.style = pascal_with_underscores # Symbol specifications @@ -193,11 +209,6 @@ dotnet_naming_style.begins_with_i.required_suffix = dotnet_naming_style.begins_with_i.word_separator = dotnet_naming_style.begins_with_i.capitalization = pascal_case -dotnet_naming_style.pascal_case.required_prefix = -dotnet_naming_style.pascal_case.required_suffix = -dotnet_naming_style.pascal_case.word_separator = -dotnet_naming_style.pascal_case.capitalization = pascal_case - dotnet_naming_style.pascal_with_underscores.required_prefix = dotnet_naming_style.pascal_with_underscores.required_suffix = dotnet_naming_style.pascal_with_underscores.word_separator = _ @@ -226,6 +237,12 @@ dotnet_diagnostic.RCS1037.severity = none # order is defined twice, and the two definitions have to stay in step: # stylecop.json -> what is checked # DbConnectionPlus.slnx.DotSettings -> what is applied +# +# They do not cover the same ground. StyleCop checks kind, access, constant, static and readonly; it has +# no notion of alphabetical order WITHIN one of those groups, which the file layout applies and nothing +# checks. A misplaced member that is otherwise in the right group is therefore visible only by running the +# full pipeline and looking at what it changes - which is what scripts/tidy-code.ps1 -Check -Scope all +# does, and why CI runs it. dotnet_analyzer_diagnostic.category-StyleCop.CSharp.DocumentationRules.severity = none dotnet_analyzer_diagnostic.category-StyleCop.CSharp.LayoutRules.severity = none dotnet_analyzer_diagnostic.category-StyleCop.CSharp.MaintainabilityRules.severity = none @@ -238,8 +255,8 @@ dotnet_analyzer_diagnostic.category-StyleCop.CSharp.SpecialRules.severity = none # SA1208, SA1209, SA1210, SA1211, SA1217: the order of using directives. # # CSharpier already sorts using directives, so these five do not fix anything - they CHECK that what -# CSharpier produced is what we want, and catch a file that reached the repository without it. All five -# report zero violations across the 283 files, which is what says CSharpier and StyleCop agree here. +# CSharpier produced is what we want, and catch a file that reached the repository without it. They are +# errors and the build is green, which is what says CSharpier and StyleCop agree on this. # # SA1208 is also what makes `systemUsingDirectivesFirst` in stylecop.json mean something. With the rule # off, that setting was read by nothing. diff --git a/DbConnectionPlus.slnx.DotSettings b/DbConnectionPlus.slnx.DotSettings index 86daade..25b1475 100644 --- a/DbConnectionPlus.slnx.DotSettings +++ b/DbConnectionPlus.slnx.DotSettings @@ -1,6 +1,6 @@ <?xml version="1.0" encoding="utf-16"?> -<Patterns xmlns="urn:schemas-jetbrains-com:member-reordering-patterns"> +<Patterns xmlns="urn:schemas-jetbrains-com:member-reordering-patterns" StaticFieldReorderingPolicy="Strict"> <TypePattern DisplayName="Types marked [NoReorder]" Priority="100"> <TypePattern.Match> <HasAttribute Name="JetBrains.Annotations.NoReorderAttribute" /> diff --git a/stylecop.json b/stylecop.json index fea9d81..e9f7425 100644 --- a/stylecop.json +++ b/stylecop.json @@ -3,8 +3,7 @@ "settings": { "orderingRules": { "elementOrder": ["kind", "accessibility", "constant", "static", "readonly"], - "systemUsingDirectivesFirst": true, - "usingDirectivesPlacement": "outsideNamespace" + "systemUsingDirectivesFirst": true } } } From 75911e090a3b93a3e1ebee0c673ba456221c795d Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 03:26:29 +0200 Subject: [PATCH 04/17] build: make the repository scripts check by default and fix on request preflight.ps1 was a formatter that also verified: it rewrote the working tree and then reported on what it had written. It now checks by default and writes nothing but build output and caches, and -Fix is what applies. That is the order a pre-commit gate needs - you look at what is wrong before something changes it under you - and it is what lets CI run the same script the contributor runs. tidy-code.ps1 -Check honours that too. Checking the whole solution needs the formatters to run, and they edit in place, so -Check -Scope all now copies the tracked and untracked tree to a temporary directory, initializes a throwaway repository there, runs the pipeline against the copy and reports the diff. The real working tree is never touched, and the temporary directory is deleted only when its path starts with the prefix the script itself created. While measuring that, a defect: `dotnet format style --no-restore` on a tree whose projects are not restored DELETES using directives it believes are unused, reports success, and leaves source that does not compile. On a fresh clone it removed `using Humanizer;` and `using LinkDotNet.StringBuilder;` from the shipping library - twelve compiler errors from a formatting step that said it had done nothing wrong. --no-restore is gone from that step and the reason is written into the function, so it is not put back as a performance optimization. The rest: - clean-build-artifacts.ps1 deleted .xml files next to a .dll by a name heuristic, which reaches documents that are not build output; it now deletes only what it can attribute to a build, skips reparse points rather than following a junction out of the tree, and refuses any path that resolves outside it. - extract-release-notes.ps1 writes to artifacts/release/ with the rest of the generated output instead of the repository root, and strips the link definitions that a changelog section carries and a release page cannot resolve. - public-api-guard.ps1 no longer asks a contributor for a version bump. The version is the maintainer's, at release time. - benchmarks.ps1 and update-public-api.ps1 resolve the repository root and restore the caller's location and PATH in a finally block, so an interrupted run leaves the shell as it found it. - Every script requires PowerShell 7 explicitly rather than failing later with a syntax error. No behaviour a package consumer can see changes here. Refs #28 Co-Authored-By: Claude Opus 5 --- scripts/benchmarks.ps1 | 52 ++- scripts/clean-build-artifacts.ps1 | 128 ++++--- scripts/extract-release-notes.ps1 | 38 +- scripts/preflight.ps1 | 209 ++++++++--- scripts/public-api-guard.ps1 | 21 +- scripts/tidy-code.ps1 | 568 +++++++++++++++++++++++------- scripts/update-public-api.ps1 | 29 +- 7 files changed, 783 insertions(+), 262 deletions(-) diff --git a/scripts/benchmarks.ps1 b/scripts/benchmarks.ps1 index 4d2f125..6592b9c 100644 --- a/scripts/benchmarks.ps1 +++ b/scripts/benchmarks.ps1 @@ -36,6 +36,7 @@ .EXAMPLE pwsh -File scripts/benchmarks.ps1 --filter *Query_Entities* #> +#requires -Version 7.0 [CmdletBinding()] param( [Parameter(ValueFromRemainingArguments = $true)] @@ -44,31 +45,52 @@ param( $ErrorActionPreference = 'Stop' -$repositoryRoot = Split-Path -Parent $PSScriptRoot +# scripts/ - the repository root is one level up, whatever the current directory is. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path $project = Join-Path $repositoryRoot 'benchmarks/DbConnectionPlus.Benchmarks/DbConnectionPlus.Benchmarks.csproj' -if ($IsWindows) +if (-not (Test-Path -LiteralPath $project)) { - # Without vswhere.exe on PATH the native link step fails with MSB3073 and a misleading error message. - $visualStudioInstaller = 'C:\Program Files (x86)\Microsoft Visual Studio\Installer' + Write-Host "FAILED. $project does not exist." -ForegroundColor Red - if ((Test-Path $visualStudioInstaller) -and ($env:PATH -notlike "*$visualStudioInstaller*")) + exit 1 +} + +# Both are restored in the finally block below, so an interrupted run leaves the caller's shell as it +# found it - the location and the PATH alike. +$originalPath = $env:PATH + +Push-Location -LiteralPath $repositoryRoot +try +{ + if ($IsWindows) { - $env:PATH = "$visualStudioInstaller;$env:PATH" + # Without vswhere.exe on PATH the native link step fails with MSB3073 and a misleading error message. + $visualStudioInstaller = 'C:\Program Files (x86)\Microsoft Visual Studio\Installer' + + if ((Test-Path -LiteralPath $visualStudioInstaller) -and ($env:PATH -notlike "*$visualStudioInstaller*")) + { + $env:PATH = "$visualStudioInstaller;$env:PATH" + } } -} -Write-Host 'Running the benchmarks (JIT and Native AOT)...' -ForegroundColor Cyan -Write-Host '' + Write-Host 'Running the benchmarks (JIT and Native AOT)...' -ForegroundColor Cyan + Write-Host '' -# The "--" separator is part of the argument array rather than a literal token in the invocation, because -# PowerShell consumes a bare "--" as its own end-of-parameters marker and everything after it would then be handed -# to "dotnet run" instead of to BenchmarkDotNet. That turned a second --filter value into an output path. -$arguments = @('run', '--project', $project, '--configuration', 'Release', '--') + $BenchmarkDotNetArguments + # The "--" separator is part of the argument array rather than a literal token in the invocation, because + # PowerShell consumes a bare "--" as its own end-of-parameters marker and everything after it would then be handed + # to "dotnet run" instead of to BenchmarkDotNet. That turned a second --filter value into an output path. + $arguments = @('run', '--project', $project, '--configuration', 'Release', '--') + $BenchmarkDotNetArguments -& dotnet @arguments + & dotnet @arguments -$exitCode = $LASTEXITCODE + $exitCode = $LASTEXITCODE +} +finally +{ + $env:PATH = $originalPath + Pop-Location +} if ($exitCode -ne 0) { diff --git a/scripts/clean-build-artifacts.ps1 b/scripts/clean-build-artifacts.ps1 index 34086c5..3cb8e45 100644 --- a/scripts/clean-build-artifacts.ps1 +++ b/scripts/clean-build-artifacts.ps1 @@ -11,11 +11,23 @@ The scan is anchored to the repository root - the parent of this script's directory - not to the current working directory, so it deletes the same set no matter where you run it from. - Generated XML documentation files are identified by matching each src project AssemblyName to an XML file - beside its project file. Authored XML files with other names are left untouched. + WHAT IT DELETES, and nothing else: + + - a fixed list of known generated directories, named below; + - every bin/ and obj/ directory found INSIDE the resolved repository root. + + It does not guess. It used to also delete any XML file beside a project file whose name matched that + project's AssemblyName, on the theory that such a file could only be a generated documentation file. That + theory is not provable from the file: an authored XML file is allowed to have that name, and a delete based + on a name pattern cannot tell the two apart. The leftovers it targeted are covered by .gitignore, so + `git clean -X` removes them with git's own knowledge of what is generated. + + Directories that are reparse points - symbolic links, junctions, mount points - are skipped rather than + followed: deleting "recursively" through one deletes the target, which may be anywhere on the machine. The + .git directory is skipped too; it holds no build output and walking it is pure cost. .PARAMETER WhatIf - List the folders and files that would be deleted without deleting anything. + List the folders that would be deleted without deleting anything. .EXAMPLE pwsh -File scripts/clean-build-artifacts.ps1 @@ -23,49 +35,74 @@ .EXAMPLE pwsh -File scripts/clean-build-artifacts.ps1 -WhatIf #> +#requires -Version 7.0 [CmdletBinding(SupportsShouldProcess = $true)] param() $ErrorActionPreference = 'Stop' -$repositoryRoot = Split-Path -Parent $PSScriptRoot +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path Write-Output "Cleaning build artifacts under $repositoryRoot..." -$additionalArtifactDirectories = - 'BenchmarkDotNet.Artifacts', - 'artifacts', - 'docs/api', - 'docs/_site', - 'tests/package-consumption/.packages' | - ForEach-Object { Get-Item -LiteralPath (Join-Path $repositoryRoot $_) -Force -ErrorAction SilentlyContinue } | - Where-Object { $_ -is [System.IO.DirectoryInfo] } - -# -Force so that hidden or system directories are enumerated too; .git is skipped because it never holds -# build output and walking it is pure cost. -$artifactDirectories = - @($additionalArtifactDirectories) + - @(Get-ChildItem -LiteralPath $repositoryRoot -Directory -Recurse -Force | - Where-Object { $_.Name -in 'bin', 'obj' -and $_.FullName -notmatch '(^|\\|/)\.git(\\|/)' }) - -$documentationFiles = - Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src') -Filter '*.csproj' -File -Recurse | - ForEach-Object { - [xml] $project = Get-Content -LiteralPath $_.FullName - $assemblyName = $project.Project.PropertyGroup.AssemblyName | Select-Object -First 1 - - if ($assemblyName) - { - $documentationFilePath = Join-Path $_.DirectoryName "$assemblyName.xml" - Get-Item -LiteralPath $documentationFilePath -Force -ErrorAction SilentlyContinue - } - } | - Where-Object { $_ -is [System.IO.FileInfo] } +# The generated directories this repository creates by name. Each one is written by a tool and holds nothing +# authored. Keep this list in step with .gitignore. +$knownGeneratedDirectories = @( + # Everything this repository generates on purpose lives under artifacts/: the packages, the Native AOT + # publish, the generated API metadata, the documentation site and the extracted release notes. + 'artifacts' + 'BenchmarkDotNet.Artifacts' + 'tests/package-consumption/.packages' +) + +function Test-IsReparsePoint +{ + param([Parameter(Mandatory)] [System.IO.DirectoryInfo] $Directory) + + return $Directory.Attributes.HasFlag([System.IO.FileAttributes]::ReparsePoint) +} + +function Test-IsInsideRepository +{ + <# + A last check before a recursive delete: the resolved full path has to sit under the resolved + repository root. A reparse point that was somehow followed, or a path assembled from a variable that + turned out to be empty, cannot get past this. + #> + param([Parameter(Mandatory)] [String] $FullPath) + + $normalizedRoot = $repositoryRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + + [System.IO.Path]::DirectorySeparatorChar + + return $FullPath.StartsWith($normalizedRoot, [StringComparison]::OrdinalIgnoreCase) +} + +$candidates = New-Object System.Collections.Generic.List[System.IO.DirectoryInfo] + +foreach ($relativePath in $knownGeneratedDirectories) +{ + $directory = Get-Item -LiteralPath (Join-Path $repositoryRoot $relativePath) -Force -ErrorAction SilentlyContinue + + if ($directory -is [System.IO.DirectoryInfo] -and -not (Test-IsReparsePoint -Directory $directory)) + { + $candidates.Add($directory) + } +} + +# -Force so that hidden or system directories are enumerated too. -Attributes !ReparsePoint stops the walk +# from descending through a link, which is what keeps a recursive delete inside this repository. +$discovered = Get-ChildItem -LiteralPath $repositoryRoot -Directory -Recurse -Force -Attributes !ReparsePoint | + Where-Object { $_.Name -in 'bin', 'obj' } | + Where-Object { $_.FullName -notmatch '(^|\\|/)\.git(\\|/)' } + +foreach ($directory in $discovered) +{ + $candidates.Add($directory) +} $deletedDirectories = 0 -$deletedFiles = 0 -foreach ($directory in $artifactDirectories) +foreach ($directory in $candidates) { # Already removed as part of an ancestor that matched earlier in the enumeration. if (-not (Test-Path -LiteralPath $directory.FullName)) @@ -73,28 +110,23 @@ foreach ($directory in $artifactDirectories) continue } - if ($PSCmdlet.ShouldProcess($directory.FullName, 'Delete folder')) + if (-not (Test-IsInsideRepository -FullPath $directory.FullName)) { - Write-Output "Deleting folder: $($directory.FullName)" - - Remove-Item -LiteralPath $directory.FullName -Recurse -Force + Write-Output "Skipping (outside the repository): $($directory.FullName)" - $deletedDirectories++ + continue } -} -foreach ($file in $documentationFiles) -{ - if ($PSCmdlet.ShouldProcess($file.FullName, 'Delete generated documentation file')) + if ($PSCmdlet.ShouldProcess($directory.FullName, 'Delete folder')) { - Write-Output "Deleting generated documentation file: $($file.FullName)" + Write-Output "Deleting folder: $($directory.FullName)" - Remove-Item -LiteralPath $file.FullName -Force + Remove-Item -LiteralPath $directory.FullName -Recurse -Force - $deletedFiles++ + $deletedDirectories++ } } -Write-Output "Done. Deleted $deletedDirectories folder(s) and $deletedFiles generated documentation file(s)." +Write-Output "Done. Deleted $deletedDirectories folder(s)." exit 0 diff --git a/scripts/extract-release-notes.ps1 b/scripts/extract-release-notes.ps1 index b99cc8b..985da9f 100644 --- a/scripts/extract-release-notes.ps1 +++ b/scripts/extract-release-notes.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Validates the CHANGELOG entry for a release and writes its section to release-notes.md. + Validates the CHANGELOG entry for a release and writes its section to artifacts/release/release-notes.md. .DESCRIPTION Shared by the CI publish and GitHub-release jobs, so the notes attached to a release and the checks that @@ -11,18 +11,28 @@ before its changelog entry was finished - and it is much cheaper to fail here than to publish six immutable packages pointing at an empty section. + The output goes under artifacts/, which is generated output and is ignored by git. Writing it to the + repository root, as this script used to, meant a file that looked authored, needed its own .gitignore + entry, and could be committed by accident. + + Keep-a-Changelog link reference definitions - the `[4.1.0]: https://...` lines that some changelogs + collect at the end of the file, and that a section can also carry - are dropped from the extracted notes. + They resolve against the changelog, not against a GitHub release page, where they render as nothing or as + a broken link. + On failure the script emits a GitHub Actions ::error annotation and exits non-zero. .PARAMETER Version The version to extract, without the leading "v" - for example 4.1.0. .PARAMETER OutputFile - Where to write the extracted section. Defaults to release-notes.md in the repository root, which is what - the workflow attaches to the GitHub release. + Where to write the extracted section. Defaults to artifacts/release/release-notes.md in the repository, + which is what the workflow attaches to the GitHub release. .EXAMPLE pwsh -File scripts/extract-release-notes.ps1 -Version 4.1.0 #> +#requires -Version 7.0 [CmdletBinding()] param( [Parameter(Mandatory = $true)] @@ -33,22 +43,23 @@ param( $ErrorActionPreference = 'Stop' -$repositoryRoot = Split-Path -Parent $PSScriptRoot +# scripts/ - the repository root is one level up, whatever the current directory is. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path $changelog = Join-Path $repositoryRoot 'CHANGELOG.md' if (-not $OutputFile) { - $OutputFile = Join-Path $repositoryRoot 'release-notes.md' + $OutputFile = Join-Path $repositoryRoot 'artifacts/release/release-notes.md' } -if (-not (Test-Path $changelog)) +if (-not (Test-Path -LiteralPath $changelog)) { Write-Host "::error title=Missing changelog::$changelog does not exist." exit 1 } -$lines = Get-Content -Path $changelog -Encoding utf8 +$lines = Get-Content -LiteralPath $changelog -Encoding utf8 # --- 1. Exactly one dated heading, and no TBD --------------------------------------------------------- @@ -68,6 +79,9 @@ if ($datedHeadingCount -ne 1 -or $hasTbd) # --- 2. The section body ------------------------------------------------------------------------------ +# A link reference definition: `[4.1.0]: https://github.com/...`, at the start of a line. +$linkDefinition = '^\[[^\]]+\]:\s' + $section = New-Object System.Collections.Generic.List[String] $inSection = $false @@ -86,7 +100,7 @@ foreach ($line in $lines) break } - if ($inSection) + if ($inSection -and ($line -notmatch $linkDefinition)) { $section.Add($line) } @@ -99,7 +113,13 @@ if (-not ($section | Where-Object { $_.Trim() })) exit 1 } -Set-Content -Path $OutputFile -Value $section -Encoding utf8 +$outputDirectory = Split-Path -Parent $OutputFile +if ($outputDirectory -and -not (Test-Path -LiteralPath $outputDirectory)) +{ + New-Item -ItemType Directory -Force -Path $outputDirectory | Out-Null +} + +Set-Content -LiteralPath $OutputFile -Value $section -Encoding utf8 Write-Host "Wrote the CHANGELOG section for $Version to $OutputFile ($($section.Count) line(s))." diff --git a/scripts/preflight.ps1 b/scripts/preflight.ps1 index 3bef234..b3c0f13 100644 --- a/scripts/preflight.ps1 +++ b/scripts/preflight.ps1 @@ -1,7 +1,7 @@ <# .SYNOPSIS - The pre-commit gate: repo-hygiene checks, style/formatting/ordering, a Release build, and the unit - test suite. + The pre-commit gate: repository hygiene, style/formatting/ordering, a Release build, and the unit + test suite on both target frameworks. .DESCRIPTION CONTRIBUTING.md requires that all tests pass and the build succeeds with no warnings. Because @@ -12,15 +12,31 @@ through a tool - and Codex only runs its hooks once they are trusted. This script repeats that check over the whole working tree. Run it before every commit, whichever agent you are. - Two gates are deliberately NOT run here, because both take minutes: + WHAT THIS SCRIPT WRITES. By default: build output and package caches, and nothing else. It does not + edit your source files and it does not touch the git index - the tidiness step runs as a CHECK, on a + disposable copy of the tree. Pass -Fix to have it tidy the working tree first. - - Integration tests, which need four Docker containers. See .agents/skills/integration-db/SKILL.md. - - The Native AOT gate, which packs the six projects and publishes a package consumer natively. Run - scripts/verify-package-aot.ps1 -Pack after any change to a reflection path; nothing else in the - repository can see silent trimming damage. + TWO GATES ARE DELIBERATELY NOT RUN HERE, because both take minutes and neither applies to every + change. Run them when their trigger applies: + + Integration tests Trigger: SQL generation, an adapter, the CRUD or temporary-table paths, type + mapping - anything a substituted DbDataReader cannot exercise honestly. Needs + Docker. Scope the run; the default scope is SQLite + SQL Server, and a change + to the shared adapter seam obliges all five. + See .agents/skills/integration-db/SKILL.md. + + Native AOT gate Trigger: any change to reflection, the [DynamicallyAccessedMembers] + annotations, the materializers or the temporary-table readers. Needs a C++ + toolchain. It is the ONLY check in the repository that can see silent trimming + damage, because nothing is trimmed on the JIT. + Run: pwsh -File scripts/verify-package-aot.ps1 -Pack + +.PARAMETER Fix + Apply style, formatting and member ordering to the working tree before the checks, instead of only + reporting what would change. This rewrites your files; review the diff and include it in your commit. .PARAMETER SkipTidy - Skip applying style, formatting and member ordering. The build still fails on any of them. + Skip the style, formatting and ordering step entirely. The build still fails on any of them. .PARAMETER SkipBuild Skip the Release build (implies -SkipTests). @@ -33,9 +49,14 @@ .EXAMPLE pwsh -File scripts/preflight.ps1 + +.EXAMPLE + pwsh -File scripts/preflight.ps1 -Fix #> +#requires -Version 7.0 [CmdletBinding()] param( + [Switch] $Fix, [Switch] $SkipBuild, [Switch] $SkipTests, [Switch] $SkipTidy, @@ -44,122 +65,214 @@ param( $ErrorActionPreference = 'Stop' -$repositoryRoot = Split-Path -Parent $PSScriptRoot -$solution = Join-Path $repositoryRoot 'DbConnectionPlus.slnx' -$unitTests = Join-Path $repositoryRoot 'tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj' +# scripts/ - the repository root is one level up. Everything below is anchored to it, so the +# script behaves the same whatever the current directory is. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path +$solutionFileName = 'DbConnectionPlus.slnx' +$unitTestProject = 'tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj' $publicApiGuard = Join-Path $repositoryRoot 'scripts/public-api-guard.ps1' $tidy = Join-Path $repositoryRoot 'scripts/tidy-code.ps1' $failures = New-Object System.Collections.Generic.List[String] -function Write-Section { - param([String] $Title) +function Write-Section +{ + param([Parameter(Mandatory)] [String] $Title) Write-Output '' Write-Output "=== $Title ===" } +function Invoke-FromRepositoryRoot +{ + <# + Runs a native command with the repository root as the working directory. The location is restored + in a finally block, so an interrupted run does not leave the caller's shell somewhere else. + + It deliberately returns NOTHING and the caller reads $LASTEXITCODE afterwards. Returning the exit + code would put it on the pipeline together with everything the command printed, so the caller + would receive an array of build output with a number on the end - and the build log would vanish + into a variable instead of reaching the screen, which is exactly where it is needed when the build + is what failed. + + The working directory is not cosmetic here. `dotnet test` resolves global.json from the CURRENT + directory upward, and this repository's global.json is what selects the Microsoft.Testing.Platform + runner. Run it from anywhere else and the setting is silently lost, along with every option that + depends on it. + #> + param([Parameter(Mandatory)] [ScriptBlock] $Command) + + Push-Location -LiteralPath $repositoryRoot + try + { + & $Command + } + finally + { + Pop-Location + } +} + +foreach ($required in @($publicApiGuard, $tidy)) +{ + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) + { + Write-Output "preflight: FAILED - $required does not exist." + + exit 1 + } +} + # --- 1. Public API ------------------------------------------------------------------------------------ # Not a failure - a reminder. An accidental public-surface change is already a build error (RS0016 / # RS0017 from Microsoft.CodeAnalysis.PublicApiAnalyzers, below); what this catches is a deliberate one -# that arrived without the companion edits CONTRIBUTING.md requires. +# that arrived without the companion edits CONTRIBUTING.md requires. It reads; it never writes. Write-Section 'Hygiene: public API' -$guardOutput = & $publicApiGuard +$guardOutput = & pwsh -NoProfile -NonInteractive -File $publicApiGuard +$guardExitCode = $LASTEXITCODE -if ($guardOutput) { +if ($guardExitCode -ne 0) +{ + $failures.Add('public-api-guard') + Write-Output "FAIL - public-api-guard.ps1 exited with code $guardExitCode." +} +elseif ($guardOutput) +{ $guardOutput | ForEach-Object { Write-Output $_ } } -else { +else +{ Write-Output 'Unchanged.' } # --- 2. Style, formatting and member ordering --------------------------------------------------------- -# Applied, not just checked. All three are build errors, so leaving them to step 3 only means a slower -# way of finding out. The editor hooks format on every edit, but they deliberately skip the two slow -# tools - the code-style fixers and the member reordering - and this is where those run. +# All three are build errors, so leaving them to step 3 only means a slower way of finding out - and the +# check prints the exact diff that would fix things, where the build only names the file. # -# It rewrites files. That is the point, and it is why this runs before the build. +# By default this REPORTS. -Fix applies. The editor hooks format on every edit, but they deliberately skip +# the two slow tools - the code-style fixers and the member reordering - and this is where those run. -if (-not $SkipTidy) { - Write-Section 'Style, formatting and ordering' +Write-Section 'Style, formatting and ordering' +if ($SkipTidy) +{ + Write-Output 'Skipped (-SkipTidy).' +} +elseif ($Fix) +{ # Snapshot the dirty .cs files BEFORE tidying. `git diff` afterwards lists your own edits too, so # reporting its count would say "tidied 40 files" when the tools touched one of them. What the run # actually changed is the difference between the two lists. $dirtyBefore = @(& git -C $repositoryRoot diff --name-only -- '*.cs' 2>$null | Where-Object { $_ }) & pwsh -NoProfile -NonInteractive -File $tidy -Scope all - if ($LASTEXITCODE -ne 0) { + $tidyExitCode = $LASTEXITCODE + + if ($tidyExitCode -ne 0) + { $failures.Add('tidy') Write-Output 'FAIL - tidy-code could not finish. Run `dotnet tool restore` if the tools are missing.' } - else { + else + { $dirtyAfter = @(& git -C $repositoryRoot diff --name-only -- '*.cs' 2>$null | Where-Object { $_ }) $tidied = @($dirtyAfter | Where-Object { $_ -notin $dirtyBefore }) Write-Output '' - if ($tidied) { + if ($tidied) + { Write-Output "Tidied $($tidied.Count) file(s) that you had not already changed:" $tidied | ForEach-Object { Write-Output " $_" } Write-Output 'Review the diff and include it in your commit.' } - elseif ($dirtyBefore) { + elseif ($dirtyBefore) + { # Everything the tools touched was already in your diff, so there is nothing new to point at - # but the tools may still have rewritten those files, and that is worth one line. Write-Output "Tidy ran clean. Your $($dirtyBefore.Count) changed .cs file(s) may have been rewritten - review the diff." } - else { + else + { Write-Output 'Already tidy.' } } } -else { - Write-Section 'Style, formatting and ordering' - Write-Output 'Skipped (-SkipTidy).' +else +{ + & pwsh -NoProfile -NonInteractive -File $tidy -Scope all -Check + $tidyExitCode = $LASTEXITCODE + + if ($tidyExitCode -ne 0) + { + $failures.Add('tidy') + Write-Output '' + Write-Output 'FAIL - the tree is not tidy, or tidy-code could not finish. The diff above is what' + Write-Output 'would fix it. Apply it with: pwsh -File scripts/preflight.ps1 -Fix' + } } # --- 3. Build ---------------------------------------------------------------------------------------- -if (-not $SkipBuild) { +if ($SkipBuild) +{ + Write-Section 'Build' + Write-Output 'Skipped (-SkipBuild).' +} +else +{ Write-Section "Build ($Configuration)" - & dotnet build $solution -c $Configuration - if ($LASTEXITCODE -ne 0) { + Invoke-FromRepositoryRoot { & dotnet build $solutionFileName -c $Configuration } + $buildExitCode = $LASTEXITCODE + + if ($buildExitCode -ne 0) + { $failures.Add('build') Write-Output 'FAIL - build did not succeed. TreatWarningsAsErrors=true, so a style slip or an' Write-Output 'IL2xxx/IL3050 trim diagnostic fails here too. Never suppress an IL warning to get green.' } } -else { - Write-Section 'Build' - Write-Output 'Skipped (-SkipBuild).' -} # --- 4. Unit tests ----------------------------------------------------------------------------------- +# Both target frameworks, because the two builds of the shipping libraries are not the same code: the +# net8.0 build carries an IL3050 suppression the net10.0 build does not. The project multi-targets, so a +# single `dotnet test` covers both - the summary names each one. -if (-not $SkipBuild -and -not $SkipTests -and -not $failures.Contains('build')) { - Write-Section 'Unit tests' - - & dotnet test --project $unitTests -c $Configuration --no-build - if ($LASTEXITCODE -ne 0) { $failures.Add('unit tests') } -} -else { +if ($SkipBuild -or $SkipTests -or $failures.Contains('build')) +{ Write-Section 'Unit tests' Write-Output 'Skipped.' } +else +{ + Write-Section 'Unit tests (net8.0 and net10.0)' + + Invoke-FromRepositoryRoot { + & dotnet test --project $unitTestProject -c $Configuration --no-build + } + $testExitCode = $LASTEXITCODE + + if ($testExitCode -ne 0) { $failures.Add('unit tests') } +} # --- Summary ----------------------------------------------------------------------------------------- Write-Section 'Preflight summary' -if ($failures.Count -gt 0) { +if ($failures.Count -gt 0) +{ Write-Output "FAILED: $($failures -join ', ')" Write-Output 'Do not commit over this - fix it and re-run.' + exit 1 } -Write-Output 'PASSED. Not covered here: integration tests (.agents/skills/integration-db/SKILL.md) and the' -Write-Output 'Native AOT gate (scripts/verify-package-aot.ps1 -Pack, after a reflection-path change).' +Write-Output 'PASSED. Not covered here, and not needed for every change:' +Write-Output ' - integration tests, when the change can only be proven against a real database' +Write-Output ' (.agents/skills/integration-db/SKILL.md)' +Write-Output ' - the Native AOT gate, after a change to a reflection path' +Write-Output ' (pwsh -File scripts/verify-package-aot.ps1 -Pack)' + exit 0 diff --git a/scripts/public-api-guard.ps1 b/scripts/public-api-guard.ps1 index 563c5ac..8d6c52e 100644 --- a/scripts/public-api-guard.ps1 +++ b/scripts/public-api-guard.ps1 @@ -9,8 +9,9 @@ already stops an *accidental* change. What the build cannot know is whether a *deliberate* one was accompanied by its companion edits. - CONTRIBUTING.md requires three (CHANGELOG entry, README update, SemVer bump) and they are easy to - forget, so this reminds you when one of those files moves. + CONTRIBUTING.md requires an Unreleased changelog entry and a documentation update, and both are easy + to forget, so this reminds you when one of those files moves. It never asks for a version bump: the + version belongs to the maintainer and moves at release time. This is the shared implementation. AI agents call it from a PostToolUse hook - Claude Code through .claude/hooks/public-api-guard.ps1 and Codex through .codex/hooks/public-api-guard.ps1 - and @@ -28,6 +29,7 @@ .EXAMPLE pwsh -File scripts/public-api-guard.ps1 src/DbConnectionPlus/PublicAPI.Unshipped.txt #> +#requires -Version 7.0 [CmdletBinding()] param( [Parameter(Position = 0, ValueFromRemainingArguments = $true)] @@ -36,8 +38,8 @@ param( $ErrorActionPreference = 'Stop' -# scripts/ - the repository root is one level up. -$repositoryRoot = Split-Path -Parent $PSScriptRoot +# scripts/ - the repository root is one level up, whatever the current directory is. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path function Get-ChangedFile { param([String] $RepositoryRoot) @@ -68,10 +70,13 @@ $(($changedApiFiles | ForEach-Object { " $_" }) -join "`n") Per CONTRIBUTING.md a public-surface change also requires: - 1. CHANGELOG.md - entry under [Unreleased] / the next version, Keep-a-Changelog format. - Prefix breaking changes with BREAKING. - 2. README.md - update the 'API summary' section and any affected examples. - 3. - SemVer bump in src/Directory.Build.props (one edit for all six projects). + 1. CHANGELOG.md - an entry under '## [Unreleased]', in Keep-a-Changelog format. Write a breaking + change as '- **BREAKING:** ...'. + 2. Documentation - the API reference under docs/, and any example the change makes wrong. + +You do NOT bump a version. in the repository-root Directory.Build.props, the release date in +the CHANGELOG, promoting PublicAPI.Unshipped.txt to Shipped, and the tag are all the maintainer's, at +release time. Describing the change accurately under Unreleased is what lets them choose the number. Review the diff line by line first - it is the guard that this change is deliberate, not accidental. An entry starting with *REMOVED* is a break: it means a member that shipped is gone. diff --git a/scripts/tidy-code.ps1 b/scripts/tidy-code.ps1 index 8e8bef3..ecebfca 100644 --- a/scripts/tidy-code.ps1 +++ b/scripts/tidy-code.ps1 @@ -17,12 +17,15 @@ error, and CSharpier.MsBuild does the same for an unformatted file. Running this first is much cheaper than finding out at build time. - Needs the local tools: run `dotnet tool restore` once per clone. + Needs the local tools: run `dotnet tool restore` once per clone. This script never installs them. .PARAMETER Path One or more .cs files. With no Path, every .cs file git reports as changed - tracked modifications plus untracked files. Ignored by -Scope all, which always covers the whole solution. + A path given explicitly must exist and must be a .cs file. It is an error if it does not, rather than + a silent skip: a typo that formats nothing looks exactly like a file that needed nothing. + .PARAMETER Scope How much runs: @@ -34,11 +37,11 @@ .PARAMETER Check Report violations instead of fixing them, and exit non-zero if there are any. This is what CI runs. - -Check -Scope all is the exception: it still WRITES. ReSharper has no check mode, and neither it nor - CSharpier is idempotent alone - cleanupcode re-indents the content of raw string literals and - CSharpier puts it back - so the only honest question is whether tidying the whole tree changes it. - That is what -Check -Scope all asks: it tidies for real, then compares. Do not point it at a working - tree you are not ready to have tidied. + -Check NEVER writes to this working tree, at any scope, and never touches the git index. At the + format and style scopes the tools have a verify mode of their own. At the `all` scope they do not - + ReSharper has no check mode, and neither it nor CSharpier is idempotent alone - so the pipeline runs + for real on a DISPOSABLE COPY of the current tree, outside the repository, and the diff it produced + there is printed here. Nothing is ever copied back. .EXAMPLE pwsh -File scripts/tidy-code.ps1 @@ -49,6 +52,7 @@ .EXAMPLE pwsh -File scripts/tidy-code.ps1 src/DbConnectionPlus/Entities/EntityHelper.cs #> +#requires -Version 7.0 [CmdletBinding()] param( [Parameter(Position = 0, ValueFromRemainingArguments = $true)] @@ -62,25 +66,176 @@ param( $ErrorActionPreference = 'Stop' -# scripts/ - the repository root is one level up. -$repositoryRoot = Split-Path -Parent $PSScriptRoot -$solution = Join-Path $repositoryRoot 'DbConnectionPlus.slnx' +# scripts/ - the repository root is one level up. Every path below is anchored to this, so the +# script behaves the same whatever the current directory is. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path +$solutionFileName = 'DbConnectionPlus.slnx' + +$failures = New-Object System.Collections.Generic.List[String] + +# --- Running things ------------------------------------------------------------------------------- + +function Invoke-Git +{ + <# + Runs git and returns its output lines. Arguments are passed as an ARRAY, never as a command + string, so a path with a space or a quote in it cannot become two arguments or a shell fragment. + #> + param( + [Parameter(Mandatory)] [String] $WorkingDirectory, + [Parameter(Mandatory)] [String[]] $Arguments, + [Switch] $AllowFailure + ) + + $previous = $ErrorActionPreference + $ErrorActionPreference = 'Continue' + try + { + # 2>$null: git warns about CRLF normalization per file, which is noise here. + $output = & git -C $WorkingDirectory @Arguments 2>$null + } + finally + { + $ErrorActionPreference = $previous + } + + if ($LASTEXITCODE -ne 0 -and -not $AllowFailure) + { + throw "tidy-code: git $($Arguments -join ' ') failed with exit code $LASTEXITCODE." + } + + return @($output) +} + +function Invoke-Tool +{ + <# + Runs `dotnet ...` from $WorkingDirectory and returns everything it printed. The caller decides + success from $LASTEXITCODE, which is checked immediately after the call. + + The working directory matters and is not cosmetic: `dotnet` resolves global.json from the current + directory upward, and this repository's global.json is what pins the SDK and selects the + Microsoft.Testing.Platform runner. Run from somewhere else and a different SDK answers. + + $ErrorActionPreference is deliberately relaxed for the call. With it at 'Stop', PowerShell turns + anything a native program writes to stderr into a terminating error, so a harmless warning aborts + the whole script with NativeCommandError. ReSharper prints one every run: + + Warning: Roslyn Source Generator error from DapperInterceptorGenerator from Dapper.AOT + handled 1 of 1 possible call-sites ... + + That is a notice, not a failure - cleanupcode still exits 0 - but it was enough to kill the script. + Exit codes decide success here, not stderr. + #> + param( + [Parameter(Mandatory)] [String[]] $Arguments, + [Parameter(Mandatory)] [String] $WorkingDirectory + ) + + $previous = $ErrorActionPreference + Push-Location -LiteralPath $WorkingDirectory + try + { + $ErrorActionPreference = 'Continue' + + return (& dotnet @Arguments 2>&1 | Out-String) + } + finally + { + $ErrorActionPreference = $previous + Pop-Location + } +} + +function Assert-Prerequisite +{ + <# + A missing prerequisite is an explicit failure, never a silent skip. This script does not install + anything: `dotnet tool restore` is a deliberate act, and a formatting hook that installs software + behind your back is a worse problem than an unformatted file. + + The tool manifest is READ rather than `dotnet tool list` being run. This function is on the critical + path of every editor hook, which formats a single file in about a second; spawning a dotnet process + just to be told what the manifest already says would roughly double that. A tool that is declared but + not restored is caught by the invocation that needs it, and Add-RestoreHint says what to do about it. + #> + param([Parameter(Mandatory)] [String] $Root) + + foreach ($executable in @('git', 'dotnet')) + { + if (-not (Get-Command -Name $executable -CommandType Application -ErrorAction SilentlyContinue)) + { + throw "tidy-code: $executable is not on PATH." + } + } + + $manifestPath = Join-Path $Root '.config/dotnet-tools.json' + if (-not (Test-Path -LiteralPath $manifestPath -PathType Leaf)) + { + throw "tidy-code: $manifestPath does not exist. The formatter and the reordering tool are declared there." + } + + try + { + $manifest = Get-Content -LiteralPath $manifestPath -Raw | ConvertFrom-Json + } + catch + { + throw "tidy-code: $manifestPath is not valid JSON: $($_.Exception.Message)" + } + + $declared = @($manifest.tools.PSObject.Properties.Name) + + foreach ($tool in @('csharpier', 'jetbrains.resharper.globaltools')) + { + if ($tool -notin $declared) + { + throw "tidy-code: '$tool' is not declared in $manifestPath." + } + } +} + +function Add-RestoreHint +{ + <# + A tool that is declared in the manifest but not restored fails with a message about the command not + being found, which reads like a bug in this script rather than a missing `dotnet tool restore`. + #> + param([Parameter(Mandatory)] [String] $Output) + + if ($Output -match 'was not found|could not be found|is not recognized') + { + $hint = 'Run "dotnet tool restore" once per clone - this script never installs tools.' -function Get-ChangedCSharpFile { - # 2>$null: git warns about CRLF normalization per file, which is noise here. - $tracked = & git -C $repositoryRoot diff --name-only HEAD -- '*.cs' 2>$null - $untracked = & git -C $repositoryRoot ls-files --others --exclude-standard -- '*.cs' 2>$null + return "$Output`n$hint" + } + + return $Output +} + +# --- Selecting files ------------------------------------------------------------------------------ + +function Get-ChangedCSharpFile +{ + param([Parameter(Mandatory)] [String] $Root) + + $tracked = Invoke-Git -WorkingDirectory $Root -Arguments @('diff', '--name-only', 'HEAD', '--', '*.cs') + $untracked = Invoke-Git -WorkingDirectory $Root -Arguments @('ls-files', '--others', '--exclude-standard', '--', '*.cs') return @($tracked) + @($untracked) | Where-Object { $_ } | - ForEach-Object { Join-Path $repositoryRoot $_ } + ForEach-Object { Join-Path $Root $_ } | + # A file that git reports as changed can be one that was DELETED. Nothing to format there. + Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } } -function Get-OwningProject { - param([String] $FilePath) +function Get-OwningProject +{ + param([Parameter(Mandatory)] [String] $FilePath) $directory = Split-Path -Parent $FilePath - while ($directory) { + while ($directory) + { $candidate = Get-ChildItem -LiteralPath $directory -Filter '*.csproj' -File -ErrorAction SilentlyContinue | Select-Object -First 1 if ($candidate) { return $candidate.FullName } @@ -90,7 +245,50 @@ function Get-OwningProject { return $null } -function Resolve-TargetFile { +function Resolve-ExplicitFile +{ + <# + Paths the caller typed. Every one of them has to be a .cs file that exists - a typo must fail + rather than quietly format nothing. + #> + param([Parameter(Mandatory)] [String[]] $Candidates) + + $resolved = New-Object System.Collections.Generic.List[String] + + foreach ($candidate in $Candidates) + { + if (-not $candidate) { continue } + + if ([System.IO.Path]::GetExtension($candidate) -ne '.cs') + { + throw "tidy-code: '$candidate' is not a .cs file." + } + + if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) + { + throw "tidy-code: '$candidate' does not exist." + } + + $full = (Resolve-Path -LiteralPath $candidate).Path + + # Generated and build output are not ours to touch. + if ($full -match '[\\/](bin|obj)[\\/]') + { + throw "tidy-code: '$candidate' is build output. Nothing under bin/ or obj/ is formatted." + } + + if (-not $resolved.Contains($full)) { $resolved.Add($full) } + } + + return $resolved.ToArray() +} + +function Resolve-DerivedFile +{ + <# + Paths this script worked out for itself, from git. Anything unsuitable is dropped rather than + reported: git listing a deleted or generated file is normal, not a mistake the caller made. + #> param([String[]] $Candidates) return @($Candidates) | @@ -98,48 +296,42 @@ function Resolve-TargetFile { Where-Object { [System.IO.Path]::GetExtension($_) -eq '.cs' } | Where-Object { Test-Path -LiteralPath $_ -PathType Leaf } | ForEach-Object { (Resolve-Path -LiteralPath $_).Path } | - # Generated and build output are not ours to touch. Where-Object { $_ -notmatch '[\\/](bin|obj)[\\/]' } | Select-Object -Unique } -$failures = New-Object System.Collections.Generic.List[String] +# --- Style ---------------------------------------------------------------------------------------- +# The IDExxxx code-style rules, and the third-party analyzer fixers that have one. Whitespace rules are +# deliberately not included: IDE0055 is off in .editorconfig, because whitespace belongs to CSharpier. -function Invoke-Tool { +function Invoke-StyleFix +{ <# - Runs `dotnet ...` and returns everything it printed. The caller decides success from $LASTEXITCODE. - - $ErrorActionPreference is deliberately relaxed for the call. With it at 'Stop', PowerShell turns - anything a native program writes to stderr into a terminating error, so a harmless warning aborts - the whole script with NativeCommandError. ReSharper prints one every run: + `--no-restore` is deliberately NOT passed, and that is not a performance oversight. - Warning: Roslyn Source Generator error from DapperInterceptorGenerator from Dapper.AOT - handled 1 of 1 possible call-sites ... + `dotnet format style` fixes IDE0005 - "unnecessary using directive" - by DELETING the directive. It + decides what is unnecessary from the compilation, and on an unrestored project the compilation has no + package references at all, so every using of a type from a package looks unnecessary. Measured on a + fresh tree: it removed `using Humanizer;` and `using LinkDotNet.StringBuilder;` from the shipping + library and left source that does not compile. It reported success while doing it. - That is a notice, not a failure - cleanupcode still exits 0 - but it was enough to kill the script. - Exit codes decide success here, not stderr. + A restore is a second or two on a warm cache and is a no-op when the tree is already restored. That is + the entire cost of the guarantee that this step cannot delete code it only thinks is unused. #> - param([Parameter(Mandatory)] [String[]] $Arguments) - - $previous = $ErrorActionPreference - $ErrorActionPreference = 'Continue' - try { return (& dotnet @Arguments 2>&1 | Out-String) } - finally { $ErrorActionPreference = $previous } -} - -# --- Style ---------------------------------------------------------------------------------------- -# The IDExxxx code-style rules, and the third-party analyzer fixers that have one. Whitespace rules are -# deliberately not included: IDE0055 is off in .editorconfig, because whitespace belongs to CSharpier. - -function Invoke-StyleFix { - param([String[]] $Files, [Boolean] $VerifyOnly) + param( + [String[]] $Files, + [Boolean] $VerifyOnly, + [Parameter(Mandatory)] [String] $Root + ) $verify = if ($VerifyOnly) { @('--verify-no-changes') } else { @() } - if (-not $Files) { - $arguments = @('format', 'style', $solution, '--no-restore', '-v', 'q') + $verify - $output = Invoke-Tool -Arguments $arguments + if (-not $Files) + { + $arguments = @('format', 'style', $solutionFileName) + @('-v', 'q') + $verify + $output = Invoke-Tool -Arguments $arguments -WorkingDirectory $Root if ($LASTEXITCODE -ne 0) { $failures.Add("dotnet format style:`n$output") } + return } @@ -147,9 +339,10 @@ function Invoke-StyleFix { # than useful: those projects restore from the PACKED packages, so `dotnet format style` on one cannot # even load it until `dotnet pack` has run. CSharpier still formats them - it needs no project - and the # style rules there are on the author. See the code-style reference. - $consumers = [System.IO.Path]::Combine($repositoryRoot, 'tests', 'package-consumption') + $consumers = [System.IO.Path]::Combine($Root, 'tests', 'package-consumption') $skipped = @($Files) | Where-Object { $_.StartsWith($consumers, [StringComparison]::OrdinalIgnoreCase) } - if ($skipped) { + if ($skipped) + { Write-Output "tidy-code: $($skipped.Count) file(s) under tests/package-consumption - no style pass, see AGENTS.md." } @@ -159,8 +352,10 @@ function Invoke-StyleFix { # One invocation per owning project, so MSBuild loads one project rather than the whole solution. $Files | Group-Object { Get-OwningProject -FilePath $_ } | ForEach-Object { $project = $_.Name - if ([String]::IsNullOrWhiteSpace($project) -or -not (Test-Path -LiteralPath $project)) { - Write-Output "tidy-code: no owning .csproj for $($_.Group -join ', ') - skipped." + if ([String]::IsNullOrWhiteSpace($project) -or -not (Test-Path -LiteralPath $project)) + { + $failures.Add("no owning .csproj for $($_.Group -join ', ')") + return } @@ -180,15 +375,14 @@ function Invoke-StyleFix { $arguments = @('format', 'style', $projectFileName, '--include') + $relativePaths + - @('--no-restore', '-v', 'q') + + @('-v', 'q') + $verify - Push-Location -LiteralPath $projectDirectory - try { $output = Invoke-Tool -Arguments $arguments } - finally { Pop-Location } + $output = Invoke-Tool -Arguments $arguments -WorkingDirectory $projectDirectory - if ($LASTEXITCODE -ne 0) { - $failures.Add("dotnet format style ($(Split-Path -Leaf $project)):`n$output") + if ($LASTEXITCODE -ne 0) + { + $failures.Add("dotnet format style ($projectFileName):`n$output") } } } @@ -201,111 +395,233 @@ function Invoke-StyleFix { # profile in the same file enables member reordering and nothing else - no reformatting, because that # is CSharpier's job. -function Get-CSharpFingerprint { - # A hash over the content of every .cs file the tools actually touch, used by -Check to tell whether - # tidying changed anything. - # - # The file list comes from git, not from Get-ChildItem. Walking the directory tree finds 391 .cs - # files where the tools see 283: build leftovers and the package consumers' NuGet cache under - # tests/package-consumption/.packages/ are on disk but ignored, and CSharpier honours .gitignore. - # Hashing those makes the check fail on files nothing tidied. - # - # `git diff` is not used either: a human running -Check usually has a dirty tree, and their own - # edits are not an ordering violation. Comparing before against after answers the actual question. - $relativePaths = & git -C $repositoryRoot ls-files --cached --others --exclude-standard -- '*.cs' 2>$null | +function Invoke-ReorderMembers +{ + param([Parameter(Mandatory)] [String] $Root) + + $output = Invoke-Tool ` + -Arguments @('jb', 'cleanupcode', $solutionFileName, '--profile=ReorderMembers', '--no-build') ` + -WorkingDirectory $Root + if ($LASTEXITCODE -ne 0) { $failures.Add("jb cleanupcode:`n$(Add-RestoreHint -Output $output)") } +} + +# --- Formatting ----------------------------------------------------------------------------------- +# Always last: both steps above move code around and leave whitespace that is not CSharpier's. + +function Invoke-Format +{ + param( + [String[]] $Files, + [Boolean] $VerifyOnly, + [Parameter(Mandatory)] [String] $Root + ) + + # @(...) around the whole thing on purpose: an `if` writes its result to the pipeline, which + # enumerates a one-element array back down to a bare string. Splatting that passes "C" as the path. + $target = @(if ($Files) { $Files } else { $Root }) + $command = if ($VerifyOnly) { 'check' } else { 'format' } + + $output = Invoke-Tool -Arguments (@('csharpier', $command) + $target) -WorkingDirectory $Root + if ($LASTEXITCODE -ne 0) { $failures.Add("csharpier $command`:`n$(Add-RestoreHint -Output $output)") } +} + +# --- The disposable copy -------------------------------------------------------------------------- +# What -Check -Scope all runs on. It is a copy of the CURRENT tree - tracked content as it stands on +# disk, including your uncommitted edits, plus the untracked files git does not ignore - placed outside +# the repository so that nothing the tools do can reach the original. +# +# It is a git repository of its own, with one commit, because the tools want one: CSharpier reads +# .gitignore to decide what to skip, and the commit is what makes `git diff` inside the copy state the +# proposed change exactly. Staging and committing THERE is not the same act as staging in your +# repository, which this script never does. + +function New-DisposableTreeCopy +{ + param([Parameter(Mandatory)] [String] $Root) + + $temporaryDirectory = Join-Path ([System.IO.Path]::GetTempPath()) "dbconnectionplus-tidy-$([Guid]::NewGuid())" + New-Item -ItemType Directory -Path $temporaryDirectory -Force | Out-Null + + # Tracked content plus untracked-but-not-ignored files: exactly the files a commit from here could + # contain. Caches and build output are ignored, so they are excluded by construction rather than by a + # list this script would have to keep in step. + $relativePaths = Invoke-Git -WorkingDirectory $Root -Arguments @('ls-files', '--cached', '--others', '--exclude-standard') | Where-Object { $_ } | - Sort-Object - - # Fail loudly on an empty list rather than hashing nothing. git's stderr goes to $null just above, so a - # git that fails here returns no paths instead of an error - and two hashes of an empty stream compare - # equal, which would make -Check report a tidy tree without having looked at a single file. This is CI's - # only formatting gate; it must not be able to pass by accident. - if (-not $relativePaths) { - throw 'tidy-code: git listed no .cs files. Is this a git repository, and is git on PATH?' + Sort-Object -Unique + + if (-not $relativePaths) + { + throw 'tidy-code: git listed no files. Is this a git repository, and is git on PATH?' } - $sha = [System.Security.Cryptography.SHA256]::Create() - try { - $accumulator = New-Object System.IO.MemoryStream - foreach ($relativePath in $relativePaths) { - $fullPath = Join-Path $repositoryRoot $relativePath - if (-not (Test-Path -LiteralPath $fullPath -PathType Leaf)) { continue } + $copied = 0 + foreach ($relativePath in $relativePaths) + { + $source = Join-Path $Root $relativePath - # The path goes into the hash too, so that adding or removing a file is a change. - $pathBytes = [System.Text.Encoding]::UTF8.GetBytes($relativePath) - $accumulator.Write($pathBytes, 0, $pathBytes.Length) + # `--cached` lists a file that is tracked but deleted in the working tree. There is nothing to copy. + if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { continue } - $bytes = [System.IO.File]::ReadAllBytes($fullPath) - $accumulator.Write($bytes, 0, $bytes.Length) + $destination = Join-Path $temporaryDirectory $relativePath + $destinationDirectory = Split-Path -Parent $destination + if (-not (Test-Path -LiteralPath $destinationDirectory)) + { + New-Item -ItemType Directory -Path $destinationDirectory -Force | Out-Null } - return [System.BitConverter]::ToString($sha.ComputeHash($accumulator.ToArray())) + Copy-Item -LiteralPath $source -Destination $destination -Force + $copied++ + } + + if (-not (Get-ChildItem -LiteralPath $temporaryDirectory -Recurse -File -Filter '*.cs' | Select-Object -First 1)) + { + throw 'tidy-code: the copied tree contains no .cs file, so a check of it would prove nothing.' } - finally { $sha.Dispose() } + + # The identity is supplied per command rather than written into a config, and it never touches the + # original repository: -c applies to this invocation only, and the invocation runs in the copy. + $identity = @( + '-c', 'user.name=tidy-code', + '-c', 'user.email=tidy-code@localhost', + '-c', 'commit.gpgsign=false' + ) + + Invoke-Git -WorkingDirectory $temporaryDirectory -Arguments @('init', '--quiet') | Out-Null + Invoke-Git -WorkingDirectory $temporaryDirectory -Arguments (@('add', '--all')) | Out-Null + Invoke-Git -WorkingDirectory $temporaryDirectory -Arguments ($identity + @('commit', '--quiet', '-m', 'tidy-code baseline')) | Out-Null + + # Write-Host, not Write-Output: anything written to the pipeline here would be returned to the caller + # alongside the path, and the caller wants one string. + Write-Host "tidy-code: checking a disposable copy of $copied file(s) in $temporaryDirectory" + + return $temporaryDirectory } -function Invoke-ReorderMembers { - $output = Invoke-Tool -Arguments @('jb', 'cleanupcode', $solution, '--profile=ReorderMembers', '--no-build') - if ($LASTEXITCODE -ne 0) { $failures.Add("jb cleanupcode:`n$output") } +function Remove-DisposableTreeCopy +{ + param([Parameter(Mandatory)] [String] $TemporaryDirectory) + + # Only ever the directory this script created, identified by the prefix it created it with. A path + # that does not look like one is left alone rather than deleted on the strength of a variable. + $expectedPrefix = Join-Path ([System.IO.Path]::GetTempPath()) 'dbconnectionplus-tidy-' + + if (-not $TemporaryDirectory.StartsWith($expectedPrefix, [StringComparison]::OrdinalIgnoreCase)) + { + Write-Output "tidy-code: refusing to delete '$TemporaryDirectory' - it is not a directory this script created." + + return + } + + if (Test-Path -LiteralPath $TemporaryDirectory) + { + Remove-Item -LiteralPath $TemporaryDirectory -Recurse -Force -ErrorAction SilentlyContinue + } } -# --- Formatting ----------------------------------------------------------------------------------- -# Always last: both steps above move code around and leave whitespace that is not CSharpier's. +function Invoke-CheckOnCopy +{ + param([Parameter(Mandatory)] [String] $Root) + + $copy = New-DisposableTreeCopy -Root $Root + try + { + # The copy has no obj/, so nothing in it can load until it is restored. Restoring here rather than + # leaving it to the first tool that needs it turns "the copy could not restore" into its own, legible + # failure instead of a confusing formatter error. + $output = Invoke-Tool -Arguments @('restore', $solutionFileName) -WorkingDirectory $copy + if ($LASTEXITCODE -ne 0) + { + $failures.Add("dotnet restore (in the disposable copy):`n$output") -function Invoke-Format { - param([String[]] $Files, [Boolean] $VerifyOnly) + return + } - # @(...) around the whole thing on purpose: an `if` writes its result to the pipeline, which - # enumerates a one-element array back down to a bare string. Splatting that passes "C" as the path. - $target = @(if ($Files) { $Files } else { $repositoryRoot }) - $command = if ($VerifyOnly) { 'check' } else { 'format' } + Invoke-StyleFix -Files @() -VerifyOnly $false -Root $copy + if ($failures.Count) { return } + + Invoke-ReorderMembers -Root $copy + if ($failures.Count) { return } + + Invoke-Format -Files @() -VerifyOnly $false -Root $copy + if ($failures.Count) { return } + + $changed = Invoke-Git -WorkingDirectory $copy -Arguments @('diff', '--name-only') + if (-not $changed) + { + Write-Output 'tidy-code: solution checked - the tree is tidy.' + + return + } + + $statistics = Invoke-Git -WorkingDirectory $copy -Arguments @('diff', '--stat') + $diff = Invoke-Git -WorkingDirectory $copy -Arguments @('--no-pager', 'diff') - $output = Invoke-Tool -Arguments (@('csharpier', $command) + $target) - if ($LASTEXITCODE -ne 0) { $failures.Add("csharpier $command`:`n$output") } + $failures.Add( + "the tree is not tidy. $($changed.Count) file(s) would change:`n" + + "$($statistics -join "`n")`n`n" + + "$($diff -join "`n")`n`n" + + 'Apply it with: pwsh -File scripts/tidy-code.ps1 -Scope all' + ) + } + finally + { + Remove-DisposableTreeCopy -TemporaryDirectory $copy + } } # --- Run ------------------------------------------------------------------------------------------ -if ($Scope -eq 'all') { - if ($Path) { Write-Output 'tidy-code: -Scope all covers the whole solution; the paths given are ignored.' } +Assert-Prerequisite -Root $repositoryRoot - # -Check does not reach the individual tools here, because two of them are not idempotent on their - # own: cleanupcode re-indents the content of raw string literals and CSharpier puts it back. Asking - # cleanupcode alone whether it changed anything therefore always says yes. So the whole pipeline - # runs for real and the question is asked once, of the tree: did tidying it change anything? - $before = if ($Check) { Get-CSharpFingerprint } else { $null } - - Invoke-StyleFix -Files @() -VerifyOnly $false - Invoke-ReorderMembers - Invoke-Format -Files @() -VerifyOnly $false +if ($Scope -eq 'all') +{ + if ($Path) { Write-Output 'tidy-code: -Scope all covers the whole solution; the paths given are ignored.' } - if ($Check -and -not $failures.Count -and (Get-CSharpFingerprint) -ne $before) { - $failures.Add('the tree is not tidy. Run: pwsh -File scripts/tidy-code.ps1 -Scope all') + if ($Check) + { + Invoke-CheckOnCopy -Root $repositoryRoot } + else + { + Invoke-StyleFix -Files @() -VerifyOnly $false -Root $repositoryRoot + Invoke-ReorderMembers -Root $repositoryRoot + Invoke-Format -Files @() -VerifyOnly $false -Root $repositoryRoot - if (-not $failures.Count) { - Write-Output "tidy-code: solution $(if ($Check) { 'checked' } else { 'tidied' })." + if (-not $failures.Count) { Write-Output 'tidy-code: solution tidied.' } } } -else { - if (-not $Path) { $Path = Get-ChangedCSharpFile } +else +{ + if ($Path) + { + $files = Resolve-ExplicitFile -Candidates $Path + } + else + { + $files = Resolve-DerivedFile -Candidates (Get-ChangedCSharpFile -Root $repositoryRoot) + } - $files = Resolve-TargetFile -Candidates $Path - if (-not $files) { + if (-not $files) + { Write-Output 'tidy-code: nothing to do.' + exit 0 } - if ($Scope -eq 'style') { Invoke-StyleFix -Files $files -VerifyOnly $Check.IsPresent } - Invoke-Format -Files $files -VerifyOnly $Check.IsPresent + # Both tools have a verify mode that reads and reports without writing, so -Check needs no copy here. + if ($Scope -eq 'style') { Invoke-StyleFix -Files $files -VerifyOnly $Check.IsPresent -Root $repositoryRoot } + Invoke-Format -Files $files -VerifyOnly $Check.IsPresent -Root $repositoryRoot - if (-not $failures.Count) { + if (-not $failures.Count) + { Write-Output "tidy-code: $($files.Count) file(s) $(if ($Check) { 'checked' } else { 'tidied' })." } } -if ($failures.Count) { +if ($failures.Count) +{ $failures | ForEach-Object { Write-Output "tidy-code: $_" } + exit 1 } diff --git a/scripts/update-public-api.ps1 b/scripts/update-public-api.ps1 index 21438b9..fb6e9d3 100644 --- a/scripts/update-public-api.ps1 +++ b/scripts/update-public-api.ps1 @@ -13,17 +13,19 @@ the files, and without them the analyzer reports nothing at all. Review the diff it produces. That diff IS the public-API change, and per CONTRIBUTING.md a real one also - needs a CHANGELOG entry, a README update and a SemVer bump in src/Directory.Build.props. + needs a CHANGELOG entry under `## [Unreleased]` and a documentation update. It does NOT need a version + bump from you: the version, the release date and the tag are the maintainer's, at release time. At release time the accumulated entries move from PublicAPI.Unshipped.txt to PublicAPI.Shipped.txt, and - a removal is recorded in PublicAPI.Unshipped.txt as `*REMOVED*`. + a removal is recorded in PublicAPI.Unshipped.txt as `*REMOVED*`. That promotion is a + maintainer step - see -MarkShipped - and is never part of an ordinary contribution. .PARAMETER Project One or more project files to update. Defaults to all six shipping projects under src/. .PARAMETER MarkShipped - The release step instead of the edit step: fold PublicAPI.Unshipped.txt into PublicAPI.Shipped.txt and - leave Unshipped empty. `*REMOVED*` entries delete the matching Shipped line rather than being carried + The MAINTAINER's release step, not the edit step: fold PublicAPI.Unshipped.txt into + PublicAPI.Shipped.txt and leave Unshipped empty. `*REMOVED*` entries delete the matching Shipped line rather than being carried over. Run this when a version is released, so that the next release's Unshipped.txt again means "new since the last release". @@ -36,6 +38,7 @@ .EXAMPLE pwsh -File scripts/update-public-api.ps1 -MarkShipped #> +#requires -Version 7.0 [CmdletBinding()] param( [Parameter(Position = 0, ValueFromRemainingArguments = $true)] @@ -46,7 +49,8 @@ param( $ErrorActionPreference = 'Stop' -$repositoryRoot = Split-Path -Parent $PSScriptRoot +# scripts/ - the repository root is one level up, whatever the current directory is. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path if (-not $Project -or $Project.Count -eq 0) { $Project = Get-ChildItem -LiteralPath (Join-Path $repositoryRoot 'src') -Recurse -File -Filter '*.csproj' | @@ -114,7 +118,15 @@ foreach ($projectFile in $Project) { continue } - $output = & dotnet format analyzers $projectFile --diagnostics RS0016 --severity info -v q 2>&1 + # From the repository root: `dotnet` resolves global.json from the CURRENT directory upward, and this + # repository's global.json is what pins the SDK. The location is restored in the finally block. + Push-Location -LiteralPath $repositoryRoot + try { + $output = & dotnet format analyzers $projectFile --diagnostics RS0016 --severity info -v q 2>&1 + } + finally { + Pop-Location + } if ($LASTEXITCODE -ne 0) { $failed = $true @@ -133,8 +145,9 @@ if ($MarkShipped) { Write-Output 'PublicAPI.Unshipped.txt is empty again. The next entry that appears there is new since this release.' } else { - Write-Output 'Review the PublicAPI.*.txt diff - it is the public-API change, and a real one also needs a' - Write-Output 'CHANGELOG entry, a README update and a SemVer bump (see CONTRIBUTING.md).' + Write-Output 'Review the PublicAPI.*.txt diff - it is the public-API change, and a real one also needs an' + Write-Output 'entry under ## [Unreleased] in CHANGELOG.md and a documentation update (see CONTRIBUTING.md).' + Write-Output 'Do not bump a version: that is the maintainer''s, at release time.' } exit 0 From 0d813f4d3315e494c1d617c819055c9632db706d Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 03:27:23 +0200 Subject: [PATCH 05/17] docs: add a code of conduct and make CONTRIBUTING.md self-contained The code of conduct was referenced but did not exist: CONTRIBUTING.md said "please note we have a code of conduct" and there was nothing to follow. It is Contributor Covenant 2.1, with a real reporting address. The enforcement ladder is deliberately left out - a single-maintainer project cannot staff a four-stage escalation process, and publishing one it will not run is worse than not publishing it. CONTRIBUTING.md becomes the page a contributor can work from without opening another: what to install, what the integration tests need from Docker, what the Native AOT gate needs from a C++ toolchain, how branches and commits are named, which tool owns formatting, style and ordering, and what to run before pushing. Previously it deferred the working detail to AGENTS.md, a file written for agents, and left the human contributor reading around it. Two policy corrections, applied here and mirrored in the agent guidance and the pull-request template: - **Contributors never bump a version.** The old text asked for a SemVer bump in the pull request. The version, the changelog date, promoting PublicAPI.Unshipped.txt and the tag are the maintainer's, at release time. A contributor who bumps it makes every concurrent pull request conflict. - **An AI agent's branch is named after what it does**, not after who typed it. The `claude/` and `codex/` prefixes are gone; there is one branch convention. Refs #28 Co-Authored-By: Claude Opus 5 --- CODE_OF_CONDUCT.md | 78 ++++++++++++ CONTRIBUTING.md | 295 +++++++++++++++++++++++++-------------------- 2 files changed, 245 insertions(+), 128 deletions(-) create mode 100644 CODE_OF_CONDUCT.md diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..6357915 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,78 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a +harassment-free experience for everyone, regardless of age, body size, visible or invisible +disability, ethnicity, sex characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, race, caste, color, religion, +or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, +and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, and learning from the + experience +* Focusing on what is best not just for us as individuals, but for the overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, without their + explicit permission +* Other conduct which could reasonably be considered inappropriate in a professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior +and will take appropriate and fair corrective action in response to any behavior that they deem +inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, +code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and +will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is +officially representing the community in public spaces. Examples of representing our community +include using an official email address, posting via an official social media account, or acting as +an appointed representative at an online or offline event. + +## Reporting + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the +maintainer at [info@rent-a-developer.de](mailto:info@rent-a-developer.de). All complaints will be +reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the reporter of any +incident. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][mozilla]. They are deliberately **not** reproduced +here: this is a single-maintainer project, and a four-step ladder would describe a process that does +not exist. Reports are handled by the maintainer, case by case. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][faq]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[mozilla]: https://github.com/mozilla/diversity +[faq]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index bb0396d..4fb17eb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,176 +1,215 @@ # Contributing -When contributing to this repository, please first discuss the change you wish to make via issue, -email, or any other method with the owners of this repository before making a change. +Contributions and bug reports are welcome. For anything larger than a fix, please open an issue first so the +approach can be agreed before you write it. -Please note we have a code of conduct, please follow it in all your interactions with the project. +Please note we have a [code of conduct](CODE_OF_CONDUCT.md), and it applies to every interaction with the +project. -## Pull Request Process - -1. Branch from `main` following [Conventional Branch](https://conventionalbranch.org/): - `/`, or `/issue--` when there is an issue — for example - `feature/issue-42-bulk-insert`. The types are `feature/`, `bugfix/`, `hotfix/`, `release/` and - `chore/`; use the long forms, not `feat/` or `fix/`. Descriptions are lowercase letters, digits and - hyphens, with no hyphen at the start or end and never two in a row. An AI agent working on its own - branch may use `claude/` or `codex/` in place of a type. - - Use [Conventional Commits](https://www.conventionalcommits.org/) for the messages. -2. Make the change, with tests. New behavior and fixed bugs need coverage; a change to one database adapter - almost always has to be mirrored into the other four. -3. Run the pre-commit gate. It applies style, formatting and member ordering, then builds and runs the - unit suite: - ```shell - pwsh -File scripts/preflight.ps1 - ``` - It rewrites files — review what it changed and include it in your commit. To run just the tidying: - `pwsh -File scripts/tidy-code.ps1 -Scope all`. - - `TreatWarningsAsErrors` is on for **every** project, so the build is also the style, member-ordering, - trim-analyzer and public-API gate, and `CSharpier.MsBuild` makes an unformatted file a build error too. - The build never rewrites your files — it fails and names them; `scripts/tidy-code.ps1` is what fixes them. - **Never suppress an `IL2xxx` warning to get a green build** — it is the only - build-time evidence that the trimming annotations are complete. -4. If you touched a reflection path, also run the Native AOT gate. Nothing else in the repository can see - silent trimming damage: - ```shell - pwsh -File scripts/verify-package-aot.ps1 -Pack - ``` -5. If you changed a database adapter, run that adapter's integration tests against a real database — see - [.agents/skills/integration-db/SKILL.md](.agents/skills/integration-db/SKILL.md). -6. Update the companion files: `CHANGELOG.md` under the upcoming version following - [Keep a Changelog](https://keepachangelog.com/), `README.md` for any public API change, and the affected - project's `PublicAPI.Unshipped.txt` via `pwsh -File scripts/update-public-api.ps1`. -7. Open the pull request and work through the checklist in its template. CI runs the same gates plus CodeQL - and a dependency review; all of them must be green. -8. Your Pull Request will be reviewed by project maintainers. Address any feedback provided. -9. Once approved by the maintainers, your Pull Request will be merged. - -The full working guide — layout, the adapter seam, code style, the declared public API and Native AOT — is -[AGENTS.md](AGENTS.md). It is written for AI coding agents, but everything in it applies to people too. +This page is self-contained: everything you need to build, check and submit a change is here. ## Setting up a clone +You need the **.NET 10 SDK** (`global.json` pins `10.0.100` with `rollForward: latestFeature`) and +**PowerShell 7** (`pwsh`), which every script in `scripts/` requires. `git` must be on `PATH`. + Two things to do once, after cloning: ```shell dotnet tool restore -git config blame.ignoreRevsFile .git-blame-ignore-revs +git config --local blame.ignoreRevsFile .git-blame-ignore-revs ``` -The first installs CSharpier, the ReSharper command line tools and docfx, which `scripts/tidy-code.ps1` and the -documentation build need. The second makes `git blame` skip the commits listed in `.git-blame-ignore-revs`, -which reformatted and reordered the whole repository, so blame points at whoever wrote the logic rather than -at the tool that moved it. GitHub already does this on its own. +The first installs the pinned local tools — CSharpier, the ReSharper command-line tools and docfx — which the +tidying script and the documentation build need. **No script ever installs a tool for you**; they fail and +tell you to run this instead. + +The second is optional and per-clone. It makes `git blame` skip the commits listed in +`.git-blame-ignore-revs`, which reformatted and reordered the whole repository without changing what the code +does, so blame points at whoever wrote the logic rather than at the tool that moved it. GitHub already does +this on its own; this is only for your local `git blame`. If you use Rider, two settings make the tooling invisible: install the **CSharpier** plugin and switch on Settings | Tools | CSharpier | Run on Save, and use the shared **ReorderMembers** cleanup profile (from `DbConnectionPlus.slnx.DotSettings`) when you want members put back in order. -## Line endings +### Database prerequisites -Every text file is LF, in the repository and in the working tree, on every OS. `.gitattributes` -enforces this whatever your `core.autocrlf` is set to, so there is nothing to configure, and CI fails -if a wrongly stored file lands anyway. +The **unit tests need nothing but the SDK**. The integration tests need a running +[Docker](https://www.docker.com/) daemon and nothing else: [Testcontainers](https://dotnet.testcontainers.org/) +starts one container per database system, waits until it accepts connections, and removes it when the run +ends. There is no compose file to bring up, no connection string to configure, and no port to look up — every +container publishes to a free host port and the fixture builds its connection string from that. -`.editorconfig` also asks editors and formatters to write LF. If one does not, git still stores LF, but -`git status` lists the file as modified while `git diff` shows nothing. Run -`pwsh -File scripts/tidy-code.ps1` to fix it, or `git checkout -- `. +Oracle wants about 2 GB of memory for its container; if it exits or restarts repeatedly, check Docker's +resource limits. + +### Native prerequisites + +The Native AOT gate and the benchmarks' AOT job publish a native binary, which needs a C++ toolchain: + +| Platform | What you need | +|---|---| +| Windows | MSVC, and `vswhere.exe` resolvable — the scripts put the Visual Studio Installer directory on `PATH` for you, without which the link step fails with a misleading `MSB3073` | +| Linux | `clang` and `zlib1g-dev` | + +## The change + +### Branches + +Branch from `main` following [Conventional Branch](https://conventionalbranch.org/): + +```text +/issue-- feature/issue-42-bulk-insert +/ chore/tidy-the-release-notes (when there is no issue) +``` + +The types are `feature`, `bugfix`, `hotfix`, `release` and `chore` — the long forms, not `feat` or `fix`. +Slugs are lowercase letters, digits and hyphens, with no hyphen at the start or end and never two in a row. + +**The same rule applies to work done by an AI agent.** There is no `claude/` or `codex/` prefix: a branch is +named after what it does, not after who typed it. + +### Commits + +[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), with a **lowercase, imperative** +summary: + +```text +build: standardize repository tooling +feat: add bulk insert for value tuples +fix: stop NameHelper scanning past the closing bracket +docs: move the guides out of the README +``` + +Types in use: `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, `build`, `perf`, `ci`. + +A **breaking change** is `feat!:` or `fix!:` plus a `BREAKING CHANGE:` footer explaining what breaks and what +to do about it. `BREAKING CHANGE` is a footer, never a type — `BREAKING CHANGE: …` as a subject line is wrong. -If you have set `git config core.safecrlf true`, git refuses to add such a file with "CRLF would be -replaced by LF". Run the tidy script first, or use `core.safecrlf warn`. +Add a body whenever the *why* is not obvious from the subject, and reference the issue number there when the +branch has one. -To refresh a clone made before this policy (commit or stash your changes first - the second command -discards uncommitted work): +### Style, formatting and member ordering + +Three tools own three concerns, and all three are build errors: + +| Concern | Tool | +|---|---| +| Formatting — whitespace, line breaks, wrapping | CSharpier | +| Style — `var`, `=>`, `this.`, null checks, usings | the Roslyn analyzers | +| Ordering — types and their members | ReSharper applies it, NewStyleCop checks most of it | + +One command applies all three: ```shell -git rm -r --cached . -q -git reset --hard +pwsh -File scripts/tidy-code.ps1 -Scope all ``` -## Releasing +The narrower scopes are `-Scope style` (adds the code-style fixers to the formatter, about 15 seconds) and the +default (`format`, CSharpier only, about a second, on the files git reports as changed). `-Scope all` covers +the whole solution, because ReSharper loads all of it either way. -Releases are cut by CI from a pushed tag; nothing is packed or pushed by hand. The versioning scheme is -[SemVer](https://semver.org/). +`-Check` reports instead of fixing, and **never writes to your working tree** — at `-Scope all` it runs the +pipeline on a disposable copy and prints the diff it produced there. -1. Bump `` in `src/Directory.Build.props` — one edit for all six packages. -2. Give the `CHANGELOG.md` section for that version a real date (`## [4.1.0] - 2026-08-17`). CI reads this - section, uses it as the release notes, and refuses to publish if it is missing, undated or empty. -3. Merge to `main`, then push the tag: - ```shell - git tag v4.1.0 && git push origin v4.1.0 - ``` +### Verifying it + +```shell +pwsh -File scripts/preflight.ps1 +``` + +That is the gate to run before every commit. It checks the public API, checks style, formatting and ordering, +builds Release and runs the unit suite on both target frameworks. **By default it writes build output and +nothing else** — it does not edit your files and it does not touch the git index. Pass `-Fix` to have it apply +the tidying first: + +```shell +pwsh -File scripts/preflight.ps1 -Fix +``` -CI verifies the tag against the packed version, runs every gate, then pushes all six packages to NuGet.org and -creates the GitHub release. Details: [AGENTS.md](AGENTS.md#releases). +`TreatWarningsAsErrors` is on for **every** project, so the build is also the style, member-ordering, +trim-analyzer and public-API gate, and `CSharpier.MsBuild` makes an unformatted file a build error too. The +build never rewrites your files — it fails and names them. -## Code of Conduct +⚠️ **Never suppress an `IL2xxx` warning to get a green build.** It is the only build-time evidence that the +trimming annotations are complete, and an incomplete chain means entities that come back silently empty under +trimming. -### Our Pledge +Two gates are deliberately outside preflight, because each takes minutes and neither applies to every change: -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, gender identity and expression, level of experience, -nationality, personal appearance, race, religion, or sexual identity and -orientation. +| Gate | Run it when | Command | +|---|---|---| +| Integration tests | the change can only be proven against a real database: SQL generation, an adapter, CRUD, temporary tables, type mapping | `dotnet test --project tests/DbConnectionPlus.IntegrationTests/DbConnectionPlus.IntegrationTests.csproj -c Release` | +| Native AOT | the change touches reflection, the `[DynamicallyAccessedMembers]` annotations, the materializers or the temporary-table readers | `pwsh -File scripts/verify-package-aot.ps1 -Pack` | -### Our Standards +**Scope the integration run.** The default scope is SQLite + SQL Server (about 90 seconds, against about 10 +minutes for all five); add `--filter-class "*MySql*"` and the like only for an adapter you actually changed. +A change to `IDatabaseAdapter`, `IEntityManipulator` or `ITemporaryTableBuilder` obliges all five. -Examples of behavior that contributes to creating a positive environment -include: +The AOT gate runs both frameworks — `-Framework net8.0` for the documented floor, `net10.0` by default — and +CI runs both, on Linux and Windows, against the exact packages it will publish. -* Using welcoming and inclusive language -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members +### Database adapters -Examples of unacceptable behavior by participants include: +There are five adapter projects under `src/DbConnectionPlus.DatabaseAdapters.*`, each implementing +`IDatabaseAdapter`, `IEntityManipulator` and `ITemporaryTableBuilder` with per-dialect SQL. -* The use of sexualized language or imagery and unwelcome sexual attention or advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a professional setting +**A change to one adapter almost always has to be mirrored into the other four**, and only the integration +suite catches a miss. Some asymmetries are legitimate and should be left alone: identifier quoting, parameter +prefixes, temporary-table syntax, `GetDataType` mapping, generated-key readback, the bulk-insert paths, and +MySQL's separate enum handling in the temporary-table reader. -### Our Responsibilities +### Companion edits -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. +| Change | What else it needs | +|---|---| +| New behaviour, or a fixed bug | tests | +| Anything user-visible | an entry under `## [Unreleased]` in `CHANGELOG.md`, in [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) format. Write a breaking change as `- **BREAKING:** …` | +| A public API change | `pwsh -File scripts/update-public-api.ps1`, then **review the diff** — it *is* the API change, and a `*REMOVED*` line is a break. Update the XML docs and the affected pages under `docs/` | -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. +Internal formatting and tooling work needs no changelog entry: the changelog is for what a consumer sees. -### Scope +**Do not bump a version.** The version, the release date, the promotion of `PublicAPI.Unshipped.txt` to +`Shipped`, and the tag are the maintainer's, at release time. Describing your change accurately under +`## [Unreleased]` is what lets them choose the number. -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. +### Opening the pull request -### Enforcement +Work through the checklist in the template. Everything under **Always** applies; the rest applies only when +its trigger does. CI runs the same gates plus CodeQL and a dependency review, and all of them must be green. -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project owner. All complaints will be reviewed and -investigated and will result in a response that is deemed necessary and appropriate -to the circumstances. The project team is obligated to maintain confidentiality -with regard to the reporter of an incident. Further details of specific enforcement -policies may be posted separately. +A maintainer will review it, and may ask for changes. + +## Line endings -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. +Every text file is LF, in the repository and in the working tree, on every OS. `.gitattributes` enforces this +whatever your `core.autocrlf` is set to, so there is nothing to configure, and CI fails if a wrongly stored +file lands anyway. + +`.editorconfig` also asks editors and formatters to write LF. If one does not, git still stores LF, but +`git status` lists the file as modified while `git diff` shows nothing. Run +`pwsh -File scripts/tidy-code.ps1` to fix it, or `git checkout -- ` to discard it. -### Attribution +If you have set `git config core.safecrlf true`, git refuses to add such a file with "CRLF would be replaced +by LF". Run the tidy script first, or use `core.safecrlf warn`. -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at [https://contributor-covenant.org/version/1/4][version] +## Releasing + +Releases are cut by CI from a pushed tag; nothing is packed or pushed by hand. The versioning scheme is +[SemVer](https://semver.org/). **This is the maintainer's procedure**, not a contributor's. + +1. Bump `` in the repository-root `Directory.Build.props` — one edit for all six packages — and move + `PackageValidationBaselineVersion` in `src/Directory.Build.props` to the version being replaced. +2. Fold the accumulated public-API entries into the shipped snapshots: + `pwsh -File scripts/update-public-api.ps1 -MarkShipped`. +3. Turn `## [Unreleased]` in `CHANGELOG.md` into a dated section for that version (`## [4.1.0] - 2026-08-17`), + and open a fresh empty `## [Unreleased]` above it. CI reads the dated section, uses it as the release + notes, and refuses to publish if it is missing, undated or empty. +4. Merge to `main`, then push the tag: + ```shell + git tag v4.1.0 && git push origin v4.1.0 + ``` -[homepage]: https://contributor-covenant.org -[version]: https://contributor-covenant.org/version/1/4/ +CI verifies the tag against the packed version, runs every gate — including the package-consumption and Native +AOT jobs, which nothing can bypass — then pushes all six packages to NuGet.org and creates the GitHub release. From e869b2ab7d5316ffe0dc5a0ccb68d190dd950f40 Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 03:27:40 +0200 Subject: [PATCH 06/17] docs: split the documentation into guides and reference README.md was 1323 lines - the entire manual on the landing page, where a reader looking for "what is this and how do I install it" had to scroll past every feature to find out, and where anything that changed had to be found among a thousand lines of prose. It is now 181 lines: what the library is, how to install it, one example, and where to go next. The material is not lost, it is filed. Six guides carry the how-to - querying, parameters and temporary tables, entity mapping and CRUD, configuration, custom adapters, Native AOT - and three reference pages carry the lookups: a one-line index of the public API, the comparison table, and the benchmark numbers. Each page is one subject, so a change lands in one file. - PACKAGE_README.md is the NuGet package page and now reads like one: 92 lines, the pitch and the install line, then links. It is not a place to maintain a second copy of the manual. - DESIGN-DECISIONS.md moves under docs/, where the documentation lives. The links that pointed at the root copy - CHANGELOG.md and the AOT consumer's README - move with it. - docfx.json moves to build/docfx/, with the rest of the build configuration, so docs/ holds content and nothing else. Its content mappings are rewritten for the new layout; the generated API URLs are unchanged, so no published link breaks. - CHANGELOG.md gains the empty `## [Unreleased]` heading Keep a Changelog expects, so a contributor has a section to write into without inventing a version number. No entry is added by this branch. `docfx build --warningsAsErrors` is clean, and every relative link and anchor in the repository resolves. Refs #28 Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 11 +- PACKAGE_README.md | 181 +-- README.md | 1334 ++--------------- {docs => build/docfx}/docfx.json | 36 +- .../DESIGN-DECISIONS.md | 12 +- docs/guides/configuration.md | 51 + docs/guides/custom-adapters.md | 65 + docs/guides/entity-mapping-and-crud.md | 218 +++ docs/guides/native-aot.md | 77 + .../guides/parameters-and-temporary-tables.md | 179 +++ docs/guides/querying.md | 241 +++ docs/index.md | 43 +- docs/reference/api-summary.md | 87 ++ docs/reference/comparison.md | 179 +++ docs/reference/performance.md | 117 ++ docs/toc.yml | 36 +- .../package-consumption/AotConsumer/README.md | 5 +- 17 files changed, 1466 insertions(+), 1406 deletions(-) rename {docs => build/docfx}/docfx.json (50%) rename DESIGN-DECISIONS.md => docs/DESIGN-DECISIONS.md (98%) create mode 100644 docs/guides/configuration.md create mode 100644 docs/guides/custom-adapters.md create mode 100644 docs/guides/entity-mapping-and-crud.md create mode 100644 docs/guides/native-aot.md create mode 100644 docs/guides/parameters-and-temporary-tables.md create mode 100644 docs/guides/querying.md create mode 100644 docs/reference/api-summary.md create mode 100644 docs/reference/comparison.md create mode 100644 docs/reference/performance.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7840576..eec76a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/) and this project adheres to [Semantic Versioning](https://semver.org/). +## [Unreleased] + +Nothing yet. Add your entry here, under a Keep a Changelog category - `### Added`, `### Changed`, +`### Deprecated`, `### Removed`, `### Fixed` or `### Security` - and add the category only when you have +something to put in it. Write a breaking change as `- **BREAKING:** ...`. + +The maintainer turns this section into a dated release heading at release time; contributors never bump a +version. + ## [4.0.0] - 2026-08-22 ### Added @@ -14,7 +23,7 @@ this project adheres to [Semantic Versioning](https://semver.org/). materialize the same results under Native AOT as under the just-in-time compiler — constructor injection (records and other immutable entities), property setters and value tuples of any size all behave identically, down to exception types and messages. The reasoning is recorded in - [DESIGN-DECISIONS.md](DESIGN-DECISIONS.md#native-aot-and-trimming). + [docs/DESIGN-DECISIONS.md](docs/DESIGN-DECISIONS.md#native-aot-and-trimming). - The libraries now multi-target `net8.0` and `net10.0`. `net8.0` remains the supported floor; `net10.0` is recommended. - The packages are marked trimmable (`[assembly: AssemblyMetadata("IsTrimmable", "True")]`), which opts their diff --git a/PACKAGE_README.md b/PACKAGE_README.md index 4096efb..0e9bf2b 100644 --- a/PACKAGE_README.md +++ b/PACKAGE_README.md @@ -1,117 +1,32 @@ # DbConnectionPlus -A lightweight .NET ORM and extension library for [DbConnection](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbconnection) that adds high-performance, type-safe helpers to reduce boilerplate code. +A lightweight .NET ORM and extension library for +[DbConnection](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbconnection) that adds +high-performance, type-safe helpers to reduce boilerplate code. -Write SQL as an interpolated string, get parameters and entity mapping for free: +Write your own SQL as an interpolated string and get parameters, entity mapping and CRUD for free. No change +tracking, no LINQ provider, and nothing to opt into under Native AOT. -```csharp -using static RentADeveloper.DbConnectionPlus.DbConnectionExtensions; - -var lowStockProducts = connection.Query( - $"SELECT * FROM Product WHERE UnitsInStock < {Parameter(threshold)}" -); -``` - -- **Parameters via interpolated strings** — `{Parameter(value)}` becomes a real `DbParameter`, so there is no +- **Parameters in interpolated strings** — `{Parameter(value)}` becomes a real `DbParameter`, so there is no SQL injection surface and no `AddWithValue` boilerplate. - **On-the-fly temporary tables** — pass an `IEnumerable` straight into a statement with `{TemporaryTable(values)}`, populated by the provider's bulk-copy API where one exists. -- **Entity mapping** — `Query`, `InsertEntity`, `UpdateEntities`, `DeleteEntity` and their bulk and - `…Async` counterparts, with optimistic concurrency support. +- **Entity mapping and CRUD** — `Query`, `InsertEntity`, `UpdateEntities`, `DeleteEntity` and their bulk + and `…Async` counterparts, with optimistic concurrency. - **Native AOT and trimming ready** — no companion package, no source generator, nothing to opt into, and no `IL2xxx` / `IL3xxx` warnings in your publish. - **Minimal overhead** — close to hand-written ADO.NET, and minimal allocations. -## Why not just use Dapper? - -This library follows the same philosophy as Dapper: extension methods on `DbConnection`, your own SQL, no change tracking and no LINQ provider, but with the following differences and additions: - -- Querying, CRUD and AOT support in one package. For dapper you need Dapper, Dapper.Contrib and Dapper.AOT. -- Pass parameters directly inside interpolated strings. SQL injection safe. -- Pass collections as on-the-fly temporary tables directly inside interpolated strings. Also SQL injection safe. -- Optimistic concurrency out-of-the-box. -- Full native AOT compatibility (including support for ValueTuples, which Dapper.AOT lacks). -- Customizable Enum serialization (string/integer). -- Great performance. - -**Your values stay inside your query** - -```csharp -// Dapper -connection.Query( - "SELECT * FROM Product WHERE SupplierId = @SupplierId AND UnitsInStock < @Threshold", - new { SupplierId = supplierId, Threshold = threshold } -); - -// DbConnectionPlus -connection.Query( - $"SELECT * FROM Product WHERE SupplierId = {Parameter(supplierId)} AND UnitsInStock < {Parameter(threshold)}" -); -``` - -- No anonymous object to keep in sync with the SQL -- No renamed variable that compiles and then fails at run time -- Full auto-complete -- Full compiler verification - -**Throw a whole collection at the database** - -```csharp -connection.Query( - $"SELECT * FROM Product WHERE SupplierId IN (SELECT Value FROM {TemporaryTable(retiredSupplierIds)})" -); -``` - -A temporary table is created, your collection is bulk-loaded into it (`SqlBulkCopy` and the equivalent elsewhere) -and it is dropped afterwards. Objects work too, so you can `JOIN` against in-memory data. Dapper expands -`IN @ids` into one parameter per element instead - a new query plan per list length, and a hard stop at the -provider's parameter limit. - -**Full optimistic concurrence support** - -```csharp -connection.InsertEntity(newProduct); // database-generated values come back filled in -connection.UpdateEntity(product); // throws if someone else changed the row first -connection.DeleteEntities(discontinuedProducts); -``` - -One entity or a sequence, sync or `…Async` - and all of it supports optimistic concurrency: mark a property -`[Timestamp]` or `[ConcurrencyCheck]` and a stale write raises `DbUpdateConcurrencyException` instead of quietly -matching no rows. Composite keys included. Dapper has no CRUD at all; Dapper.Contrib adds some, but its `UPDATE` -matches on the key alone - so there is **no optimistic concurrency anywhere in the Dapper family** - and it has no -composite keys, no Oracle support and no release since 2020. - -**Native AOT support with no extra steps** - -```xml -true -``` - -That is the whole setup - no companion package, no source generator, no attributes, no `IL2xxx` / `IL3xxx` -warnings. Entities, records, **value tuples**, temporary tables, CRUD and enums all keep working. Dapper needs the -separate Dapper.AOT package plus a `[DapperAot]` opt-in per method, still has no value-tuple support and no -Dapper.Contrib support, and silently leaves call sites it cannot generate as ordinary Dapper - so the build -passes and the failure appears in your published binary. - -**Plus** the data annotations you already use (`[Table]`, `[Column]`, `[Key]`, `[NotMapped]`, … - or a fluent API), -one-line enum serialization as strings or integers, untyped rows through `row["Name"]` rather than `dynamic`, and -a single hook to log or tweak every command before it runs. - -Dapper is still the better pick for multi-mapping and `QueryMultiple`, custom `ITypeHandler` conversions, .NET -Framework / .NET Standard 2.0, and databases without an adapter here. The -[full comparison](https://github.com/rent-a-developer/DbConnectionPlus#why-not-just-use-dapper), with benchmark -numbers, is in the repository README. - ## Installation -Install the core package plus the adapter for the database you use: +Install this package plus the adapter for the database you use: ```shell dotnet add package DbConnectionPlus dotnet add package DbConnectionPlus.DatabaseAdapters.SqlServer ``` -| Database | Adapter package | Provider | +| Database | Adapter package | Driver it brings | |---|---|---| | SQL Server | `DbConnectionPlus.DatabaseAdapters.SqlServer` | `Microsoft.Data.SqlClient` | | MySQL | `DbConnectionPlus.DatabaseAdapters.MySql` | `MySqlConnector` | @@ -119,68 +34,58 @@ dotnet add package DbConnectionPlus.DatabaseAdapters.SqlServer | SQLite | `DbConnectionPlus.DatabaseAdapters.Sqlite` | `Microsoft.Data.Sqlite` | | Oracle | `DbConnectionPlus.DatabaseAdapters.Oracle` | `Oracle.ManagedDataAccess.Core` | -Any other database system can be supported by implementing a custom adapter. +All six packages are versioned and released together, so a release's adapter always matches its core package. ## Getting started -Register the adapter(s) once at application startup: +Register the adapter once, at application startup, then call the extension methods on any `DbConnection`: ```csharp using RentADeveloper.DbConnectionPlus.Configuration; +using static RentADeveloper.DbConnectionPlus.DbConnectionExtensions; -DbConnectionExtensions.Configure(config => config.UseSqlServer()); -``` - -Then use the extension methods on any `DbConnection`: +DbConnectionExtensions.Configure(configuration => configuration.UseSqlServer()); -```csharp -using static RentADeveloper.DbConnectionPlus.DbConnectionExtensions; +// Entities, scalars and value tuples +var lowStockProducts = connection.Query( + $"SELECT * FROM Product WHERE UnitsInStock < {Parameter(threshold)}" +); -// Query entities, scalars and value tuples -var product = connection.QuerySingle($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); -var count = connection.ExecuteScalar($"SELECT COUNT(*) FROM Product"); -var (id, name) = connection.QueryFirst<(Int64 Id, String Name)>($"SELECT Id, Name FROM Product"); - -// Rows without a type - read columns with the indexer or via dynamic member access -var row = connection.QueryFirst($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); -var unitsInStock = row["UnitsInStock"]; - -dynamic row = connection.QueryFirst($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); -var unitsInStock = row.UnitsInStock; - -// CRUD -connection.InsertEntity(product); -connection.UpdateEntities(products); -connection.DeleteEntity(product); - -// A collection as a temporary table, joined in SQL -var retired = connection.Query( - $""" - SELECT * - FROM Product - WHERE SupplierId IN (SELECT Value FROM {TemporaryTable(retiredSupplierIds)}) - """ +// A whole collection, as a temporary table the database can join against +var affected = connection.Query( + $"SELECT * FROM Product WHERE SupplierId IN (SELECT Value FROM {TemporaryTable(retiredSupplierIds)})" ); + +// CRUD, with optimistic concurrency +connection.InsertEntity(newProduct); +connection.UpdateEntities(lowStockProducts); +connection.DeleteEntity(discontinuedProduct); ``` Every method has an `…Async` counterpart, and all of them accept an optional transaction, command timeout, command type and cancellation token. -## Native AOT - -Reference the packages and publish — there is nothing to install and nothing to opt into. Publishing with -`PublishAot` or `PublishTrimmed` reports no `IL2xxx` or `IL3xxx` diagnostic for any supported scenario, on -`net8.0` and `net10.0` alike. +## Compatibility limits -The one API that cannot work under Native AOT is `dynamic` member access on a row (`row.Id`), because the -Dynamic Language Runtime needs run-time code generation; use the `row["Id"]` indexer instead. End-to-end -support is also bounded by your ADO.NET provider — see the AOT section of the full documentation for the -per-provider matrix. +- **`net8.0` or later.** `net8.0` is the supported floor, `net10.0` is recommended. There is no .NET Framework + or .NET Standard 2.0 support. +- **No multi-mapping, no multiple result sets, no custom type-conversion handlers.** +- **`dynamic row.Id` does not work under Native AOT** — use the `row["Id"]` indexer, which works everywhere. +- **End-to-end AOT support is bounded by your ADO.NET driver.** SQLite, MySQL and SQL Server publish and run + clean; some Npgsql type plug-ins reflect; `Oracle.ManagedDataAccess.Core` is not AOT-ready. +- **Temporary tables are off by default on Oracle**, because creating or dropping a private temporary table + implicitly commits the caller's transaction. On MySQL they need `AllowLoadLocalInfile=true` in the + connection string and `local_infile` on the server. +- **`Configure` can be called once per process**, at startup. The configuration is frozen afterwards. ## Documentation -- **[Full documentation and examples](https://github.com/rent-a-developer/DbConnectionPlus#readme)** -- [API reference](https://rent-a-developer.github.io/DbConnectionPlus/) +- **[Documentation and guides](https://rent-a-developer.github.io/DbConnectionPlus/)** — querying, parameters + and temporary tables, entity mapping and CRUD, configuration, custom adapters, and Native AOT +- [API reference](https://rent-a-developer.github.io/DbConnectionPlus/api/RentADeveloper.DbConnectionPlus.DbConnectionExtensions.html) +- [Comparison with Dapper](https://rent-a-developer.github.io/DbConnectionPlus/reference/comparison.html) +- [Benchmarks](https://rent-a-developer.github.io/DbConnectionPlus/reference/performance.html) +- [Source code](https://github.com/rent-a-developer/DbConnectionPlus) - [Change log](https://github.com/rent-a-developer/DbConnectionPlus/blob/main/CHANGELOG.md) - [Report an issue](https://github.com/rent-a-developer/DbConnectionPlus/issues) diff --git a/README.md b/README.md index 710cfcb..b04a1c4 100644 --- a/README.md +++ b/README.md @@ -4,9 +4,9 @@ # DbConnectionPlus -**A lightweight .NET ORM and extension library for the type +**A lightweight .NET ORM and extension library for [DbConnection](https://learn.microsoft.com/en-us/dotnet/api/system.data.common.dbconnection) -that adds high-performance, type-safe helpers to reduce boilerplate code, boost productivity, and make working with +that adds high-performance, type-safe helpers to reduce boilerplate code, boost productivity, and make working with SQL databases in C# more enjoyable.** [![CI](https://github.com/rent-a-developer/DbConnectionPlus/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/rent-a-developer/DbConnectionPlus/actions/workflows/ci.yml) @@ -19,285 +19,84 @@ SQL databases in C# more enjoyable.** -If you frequently write SQL queries in your C# code and want to avoid boilerplate code, you will love DbConnectionPlus! - -Highlights: -- [Parameterized interpolated-string support](#parameters-via-interpolated-strings) -- [On-the-fly temporary tables](#on-the-fly-temporary-tables-via-interpolated-strings) from in-memory collections -- Entity mapping helpers (insert, update, delete, query) -- Designed to be used in synchronous and asynchronous code paths -- Minimal performance and allocation overhead -- Fully [native AOT compatible](#native-aot-and-trimming) - -The following database systems are supported out of the box: -- MySQL (via [MySqlConnector](https://www.nuget.org/packages/MySqlConnector/)) -- Oracle Database (via [Oracle.ManagedDataAccess.Core](https://www.nuget.org/packages/Oracle.ManagedDataAccess.Core/)) -- PostgreSQL (via [Npgsql](https://www.nuget.org/packages/Npgsql/)) -- SQLite (via [Microsoft.Data.Sqlite](https://www.nuget.org/packages/Microsoft.Data.Sqlite/)) -- SQL Server (via [Microsoft.Data.SqlClient](https://www.nuget.org/packages/Microsoft.Data.SqlClient/)) - -Other database systems and database connectors can be supported by implementing a -[custom database adapter](#custom-database-adapter). - -All examples in this document use SQL Server, and assume the static helpers are imported: +Write your own SQL as an interpolated string and get parameters, entity mapping and CRUD for free. No change +tracking, no LINQ provider, no `DataTable`, and nothing to opt into under Native AOT. ```csharp using static RentADeveloper.DbConnectionPlus.DbConnectionExtensions; -``` - -## Table of contents -- **[Why not just use Dapper?](#why-not-just-use-dapper)** -- **[Quick start](#quick-start)** -- [Examples](#examples) - [parameters](#parameters-via-interpolated-strings), [temporary tables](#on-the-fly-temporary-tables-via-interpolated-strings), [Enums](#enum-support) -- **[Native AOT and trimming](#native-aot-and-trimming)** - [what works](#what-works), [supported providers](#supported-providers),[what you will see in your own build](#what-you-will-see-in-your-own-build) -- **[API summary](#api-summary)** -- [Custom database adapter](#custom-database-adapter) -- [Benchmarks](#benchmarks) -- [Running the tests](#running-the-tests) -- [Contributing](#contributing) -- [Links](#links) - -## Why not just use Dapper? - -This library follows the same philosophy as Dapper: extension methods on `DbConnection`, your own SQL, no change tracking and no LINQ provider, but with the following differences and additions: - -- Querying, CRUD and AOT support in one package. For dapper you need Dapper, Dapper.Contrib and Dapper.AOT. -- Pass parameters directly inside interpolated strings. SQL injection safe. -- Pass collections as on-the-fly temporary tables directly inside interpolated strings. Also SQL injection safe. -- Optimistic concurrency out-of-the-box. -- Full native AOT compatibility (including support for ValueTuples, which Dapper.AOT lacks). -- Customizable Enum serialization (string/integer). -- Great performance. - -### Your values stay inside your query - -```csharp -// Dapper -connection.Query( - "SELECT * FROM Product WHERE SupplierId = @SupplierId AND UnitsInStock < @Threshold", - new { SupplierId = supplierId, Threshold = threshold } -); - -// DbConnectionPlus -connection.Query( - $"SELECT * FROM Product WHERE SupplierId = {Parameter(supplierId)} AND UnitsInStock < {Parameter(threshold)}" -); -``` - -- No anonymous object to keep in sync with the SQL -- No renamed variable that compiles and then fails at run time -- Full auto-complete -- Full compiler verification - -Each value is written where it is used, and still reaches the database as a real `DbParameter`. Nothing to name -twice, no anonymous object to keep in sync with the SQL, and no renamed variable that compiles happily and blows -up at run time. - -### Throw a whole collection at the database - -```csharp -var retiredSupplierIds = suppliers.Where(a => a.IsRetired).Select(a => a.Id); - -var affectedProducts = connection.Query( - $""" - SELECT * - FROM Product - WHERE SupplierId IN (SELECT Value FROM {TemporaryTable(retiredSupplierIds)}) - """ -); -``` - -DbConnectionPlus creates a temporary table, bulk-loads your collection into it (`SqlBulkCopy` and the equivalent -for every other supported database) and drops it when the statement is done. Ten values or a million: no `IN` list -to build, no parameter limit to stay under, one query plan. - -Objects work just as well - each property becomes a column - so you can `JOIN` straight against in-memory data: -```csharp -var orderedProducts = connection.Query<(Int64 ProductId, Int32 Quantity, Decimal UnitPrice)>( - $""" - SELECT TOrderItem.ProductId, TOrderItem.Quantity, Product.UnitPrice - FROM Product - JOIN {TemporaryTable(orderItems)} TOrderItem ON TOrderItem.ProductId = Product.Id - """ +var lowStockProducts = connection.Query( + $"SELECT * FROM Product WHERE UnitsInStock < {Parameter(threshold)}" ); ``` -Dapper expands `IN @ids` into one parameter per element instead - a fresh query plan for every list length, and a -hard ceiling at the provider's parameter limit (2,100 on SQL Server). Table-valued parameters lift that ceiling, -but only on SQL Server, and only after you have created a table type in the database and hand-built a `DataTable`. - -### Full optimistic concurrence support - -```csharp -class Product -{ - [Key] - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public Int64 Id { get; set; } - - [Timestamp] - public Byte[] Version { get; set; } - - public Decimal UnitPrice { get; set; } -} - -connection.InsertEntity(newProduct); // Id and Version come back filled in -connection.UpdateEntity(product); // throws if someone else changed the row first -connection.DeleteEntities(discontinuedProducts); -``` - -Insert, update and delete - one entity or a whole sequence, synchronous or `…Async` - and **every one of them -supports optimistic concurrency**. Mark a property with `[Timestamp]` or `[ConcurrencyCheck]` and its original -value joins the `WHERE` clause, so an update or delete against a row that changed in the meantime raises -`DbUpdateConcurrencyException` instead of quietly matching nothing. Database-generated values are read back onto -your entity, and composite keys are supported. - -Dapper has no CRUD at all - you write every `INSERT`, `UPDATE` and `DELETE` by hand. Dapper.Contrib is the usual -answer, and it does add `Insert` / `Update` / `Delete` - but its `UPDATE` matches on the key alone, so **there is -no optimistic concurrency anywhere in the Dapper family**. It also cannot handle composite keys, has no Oracle -support, and has not shipped a release since 2020. - -### The attributes you already use - -```csharp -[Table("Products")] -class Product -{ - [Key] public Int64 Id { get; set; } - [Column("ProductName")] public String Name { get; set; } - [NotMapped] public Decimal TotalPrice => this.UnitPrice * this.Quantity; -} -``` - -Plain `System.ComponentModel.DataAnnotations` - the same attributes EF Core reads, so your entities usually need -no changes at all. Prefer to keep your domain model clean? A fluent API configures the same things from one place. -Dapper.Contrib brings its own smaller attribute set and cannot even map a column name. - -### Native AOT support with no extra steps - -```xml -true -``` - -That is the entire setup. No companion package, no source generator, no attribute on anything, and no `IL2xxx` or -`IL3xxx` warning in your build. Everything keeps working: entities, records and other immutable types, **value -tuples**, temporary tables, CRUD and enums. - -Dapper cannot run under Native AOT at all; you need the separate Dapper.AOT package and a `[DapperAot]` opt-in on -every method, type or module you want covered. Even then it does not support value tuples, and it does not cover -Dapper.Contrib's CRUD - so those simply have no Dapper answer under Native AOT. Worse, a call site Dapper.AOT -cannot handle is left as ordinary Dapper without any warning: the build stays green and the failure shows up in -your published binary. - -### Enums, your way, in one line - -```csharp -DbConnectionExtensions.Configure(config => config.EnumSerializationMode = EnumSerializationMode.Strings); -``` - -Readable strings or compact integers - the choice applies everywhere at once: entity properties, parameters and -temporary tables. Reading understands both, whichever your columns happen to hold. Dapper writes the underlying -number unless you write and register a type handler yourself - which Dapper.AOT's generated code then ignores. - -### Untyped rows without `dynamic` - -```csharp -var row = connection.QueryFirst($"SELECT * FROM Product WHERE Id = {Parameter(productId)}"); -var name = row["Name"]; -``` - -No cast, no `dynamic`, and it works under Native AOT - where Dapper's `dynamic` rows cannot be bound at all. If you -do want member access, one `dynamic` reference gives you `row.Name`; it is an option here rather than the only way -in. - -### See every command your app sends - -```csharp -DbConnectionExtensions.Configure(config => -{ - config.InterceptDbCommand = (command, _) => logger.LogDebug("SQL: {Sql}", command.CommandText); -}); -``` - -One hook, every statement DbConnectionPlus builds, right before it executes - for logging, a timeout, or a query -hint. Dapper has nothing comparable. +## Requirements -### And it costs you nothing - -The [benchmark suite](#benchmarks) in this repository runs every feature three ways - hand-written `DbCommand`, -Dapper and DbConnectionPlus - against in-memory SQLite, the harshest possible setting because the query itself is -almost free there. The two libraries trade places from category to category, both within a small multiple of raw -ADO.NET, and DbConnectionPlus allocates less than Dapper in eleven of the seventeen categories. - -### When Dapper is still the better pick - -Being honest about it: Dapper has multi-mapping (`splitOn`) and `QueryMultiple` for several result sets from one -command, custom `ITypeHandler` conversions, support for .NET Framework and .NET Standard 2.0, and fifteen years of -ecosystem. DbConnectionPlus has none of the first three, requires `net8.0` or later, and needs a -[custom adapter](#custom-database-adapter) for database systems beyond the [five it supports](#installation). +| | | +|---|---| +| Framework | `net8.0` or later. `net8.0` is the supported floor; `net10.0` is recommended | +| Database | MySQL, Oracle, PostgreSQL, SQLite or SQL Server — or [your own adapter](docs/guides/custom-adapters.md) | +| Dependencies | two, in the core package: `LinkDotNet.StringBuilder` and `Humanizer.Core`. Each adapter package adds its ADO.NET driver and nothing else | -## Quick start +There is no support for .NET Framework or .NET Standard 2.0. -### Installation +## Installation -Install the core package plus the adapter package for the database system you use: +Install the core package plus the adapter for the database you use: ```shell dotnet add package DbConnectionPlus dotnet add package DbConnectionPlus.DatabaseAdapters.SqlServer ``` -| Database | Adapter package | -|------------|------------------------------------------------| -| SQL Server | `DbConnectionPlus.DatabaseAdapters.SqlServer` | -| MySQL | `DbConnectionPlus.DatabaseAdapters.MySql` | -| PostgreSQL | `DbConnectionPlus.DatabaseAdapters.PostgreSql` | -| Oracle | `DbConnectionPlus.DatabaseAdapters.Oracle` | -| SQLite | `DbConnectionPlus.DatabaseAdapters.Sqlite` | +| Database | Adapter package | Driver it brings | +|---|---|---| +| SQL Server | `DbConnectionPlus.DatabaseAdapters.SqlServer` | `Microsoft.Data.SqlClient` | +| MySQL | `DbConnectionPlus.DatabaseAdapters.MySql` | `MySqlConnector` | +| PostgreSQL | `DbConnectionPlus.DatabaseAdapters.PostgreSql` | `Npgsql` | +| SQLite | `DbConnectionPlus.DatabaseAdapters.Sqlite` | `Microsoft.Data.Sqlite` | +| Oracle | `DbConnectionPlus.DatabaseAdapters.Oracle` | `Oracle.ManagedDataAccess.Core` | -### Register Database Adapters +All six packages are versioned and released together, so a release's adapter always matches its core package. -Before using DbConnectionPlus, register the adapter(s) for the database system(s) you use. This should be done -once at application startup: +## Quick start + +Register the adapter once, at application startup: ```csharp using RentADeveloper.DbConnectionPlus.Configuration; -// Register one or more adapters: -DbConnectionExtensions.Configure(config => config.UseSqlServer()); +DbConnectionExtensions.Configure(configuration => configuration.UseSqlServer()); ``` -### Use the extension methods - -Open or reuse a `DbConnection` and call the extension methods on it: +Then call the extension methods on any `DbConnection`: ```csharp +using static RentADeveloper.DbConnectionPlus.DbConnectionExtensions; + class Product { [Key] - public Int64 Id { get; set; } - public Int32 UnitsInStock { get; set; } + public long Id { get; set; } + public int UnitsInStock { get; set; } } -var lowStockThreshold = configuration.Thresholds.LowStock; - -// Query entities, scalars and value tuples +// Entities, scalars and value tuples var lowStockProducts = connection.Query( - $""" - SELECT * - FROM Product - WHERE UnitsInStock < {Parameter(lowStockThreshold)} - """ + $"SELECT * FROM Product WHERE UnitsInStock < {Parameter(threshold)}" ); +var numberOfProducts = connection.ExecuteScalar($"SELECT COUNT(*) FROM Product"); -var numberOfProducts = connection.ExecuteScalar($"SELECT COUNT(*) FROM Product"); - -// Rows without a type - read columns with the indexer +// A row without a type - read columns through the indexer var row = connection.QueryFirst($"SELECT * FROM Product WHERE Id = {Parameter(productId)}"); var unitsInStock = row["UnitsInStock"]; -// CRUD +// A whole collection, as a temporary table the database can join against +var affected = connection.Query( + $"SELECT * FROM Product WHERE SupplierId IN (SELECT Value FROM {TemporaryTable(retiredSupplierIds)})" +); + +// CRUD, with optimistic concurrency connection.InsertEntity(newProduct); connection.UpdateEntities(lowStockProducts); connection.DeleteEntity(discontinuedProduct); @@ -306,1018 +105,77 @@ connection.DeleteEntity(discontinuedProduct); Every method has an `…Async` counterpart, and all of them accept an optional transaction, command timeout, command type and cancellation token. -## Examples - -### Parameters via interpolated strings -All extension methods accept interpolated strings where parameter values are captured via -[Parameter(value)](#parametervalue): - -```csharp -var lowStockThreshold = configuration.Thresholds.LowStock; - -var lowStockProductInfos = connection.Query<(Int64 ProductId, Int32 UnitsInStock)>( - $""" - SELECT Id, UnitsInStock - FROM Product - WHERE UnitsInStock < {Parameter(lowStockThreshold)} - """ -); -``` - -This prevents SQL injection and keeps the SQL readable. - -### On-the-fly temporary tables via interpolated strings -> [!CAUTION] -> **Warning for Oracle users** -> This feature creates private temporary tables and drops them after use. In Oracle, DDL statements cause an -> implicit commit of the current transaction — so inside an explicit transaction it is committed **twice**: -> once when the temporary table is created and once when it is dropped. -> For that reason the feature is **disabled by default** for Oracle and using it throws. To enable it anyway, -> set `RentADeveloper.DbConnectionPlus.DatabaseAdapters.Oracle.OracleDatabaseAdapter.AllowTemporaryTables` to -> `true` — and avoid the feature inside explicit transactions. - -> [!NOTE] -> **Note for MySQL users** -> Temporary tables are populated with `MySqlBulkCopy`, so the connection string needs -> `AllowLoadLocalInfile=true` and the server needs `local_infile` enabled (e.g. `SET GLOBAL local_infile=1`). - -Create a temporary table on the fly from an `IEnumerable` and use it in statements via -[TemporaryTable(values)](#temporarytablevalues): - -```csharp -var retiredSupplierIds = suppliers.Where(a => a.IsRetired).Select(a => a.Id); - -var retiredSupplierProducts = connection.Query( - $""" - SELECT * - FROM Product - WHERE SupplierId IN ( - SELECT Value - FROM {TemporaryTable(retiredSupplierIds)} - ) - """ -); -``` - -Complex objects are also supported - the library creates a temporary table with appropriate columns and types: +## What it does -```csharp -class OrderItem -{ - public Int64 ProductId { get; set; } - public DateTime OrderDate { get; set; } -} - -var orderItems = GetOrderItems(); -var sixMonthsAgo = DateTime.UtcNow.AddMonths(-6); - -var productsOrderedInPastSixMonths = connection.Query( - $""" - SELECT * - FROM Product - WHERE EXISTS ( - SELECT 1 - FROM {TemporaryTable(orderItems)} TOrderItem - WHERE TOrderItem.ProductId = Product.Id AND - TOrderItem.OrderDate >= {Parameter(sixMonthsAgo)} - ) - """ -); -``` - -### Enum support -Enum values are sent to the database either as their string representation or as integers, controlled by -[EnumSerializationMode](#enumserializationmode). Reading maps both representations back to the enum value -automatically. - -```csharp -enum UserRole -{ - Admin = 1, - User = 2, - Guest = 3 -} - -class User -{ - [Key] - public Int64 Id { get; set; } - public String UserName { get; set; } - public UserRole Role { get; set; } -} - -var user = new User { Id = 1, UserName = "adminuser", Role = UserRole.User }; - -connection.InsertEntity(user); -// Column "Role" contains the string "User" with EnumSerializationMode.Strings (the default), -// and the integer 2 with EnumSerializationMode.Integers. -``` - -The column type has to match the mode - `NVARCHAR(200)` for `Strings`, `INT` for `Integers`: - -```sql -CREATE TABLE Users -( - Id BIGINT, - UserName NVARCHAR(255), - Role NVARCHAR(200) -- INT when EnumSerializationMode.Integers is used -) -``` - -## Native AOT and trimming - -**Reference the package and publish. There is nothing to install and nothing to opt into** - no companion -package, no source generator, no attribute, no registration call. Everything below works in an application -published with `PublishAot` exactly as it does on the just-in-time compiler. - -DbConnectionPlus targets `net8.0` and `net10.0`. `net8.0` is the supported floor; **`net10.0` is recommended** -for AOT, because from `net9.0` on the trim and AOT analyzers recognise `RuntimeFeature.IsDynamicCodeSupported` -as a feature guard and stop reporting code your own guard has already made unreachable. Either way this -library's own publish is warning-free on both — see [What you will see in your own -build](#what-you-will-see-in-your-own-build). - -### What works - -| Feature | Native AOT | -|---|---| -| `ExecuteNonQuery`, `ExecuteReader`, `Exists` | ✅ | -| `ExecuteScalar` and scalar `Query` | ✅ | -| `Query` for entities - property setters *and* constructor injection (records, immutable entities) | ✅ | -| `Query` for value tuples, including tuples with more than seven fields | ✅ | -| Non-generic `Query` / `QueryFirst` / … returning `DataRow`, read with `row["Id"]` | ✅ | -| `InsertEntity`, `UpdateEntity`, `DeleteEntity` and their bulk counterparts | ✅ | -| `TemporaryTable(...)` for scalar values and for complex objects | ✅ | -| Fluent-API mapping, `[Column]`/`[Key]` attributes, `EnumSerializationMode` | ✅ | -| `dynamic row.Id` member access on a `DataRow` | ❌ - use the `row["Id"]` indexer instead | - -Mapping is somewhat slower under Native AOT, because reflection replaces the compiled expression tree. In the -[benchmark suite](#benchmarks) - an in-memory SQLite database, the worst case, because statement execution is -almost free there and nothing dilutes the mapping cost - querying entities takes **~1.31x** as long end to end -and querying value tuples **~1.35x**. Part of that is the ahead-of-time runtime rather than this library: the -raw `DbCommand` baseline in the same run slows by 1.08x and 1.12x respectively. Against a real database server, -where the query itself dominates, the difference is correspondingly smaller. - -### Reading rows without a type: `row["Id"]`, not `row.Id` - -The non-generic query methods return `DataRow`. The string indexer is the AOT-safe way to read a column and is -what the examples in this README use: - -```csharp -var product = connection.QueryFirst($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); -var name = product["Name"]; -``` - -Member access through a `dynamic` reference still works wherever the runtime supports dynamic code generation, -but **not** under Native AOT - the Dynamic Language Runtime cannot bind without generating code. `DataRow` -itself is AOT-safe either way, and costs you nothing if you never write `dynamic`; the incompatibility is -reported by the compiler at your own call site. See [Query methods](#query-methods) for the full comparison. - -### Supported providers - -End-to-end AOT support is also bounded by your ADO.NET provider, which this library cannot fix: - -| Database | Provider | Native AOT | -|---|---|---| -| SQLite | `Microsoft.Data.Sqlite` | ✅ Verified trim-clean, and the provider this library's own AOT smoke test runs against | -| MySQL | `MySqlConnector` | ✅ Fully managed and trim-friendly | -| SQL Server | `Microsoft.Data.SqlClient` | ✅ Publishes and runs clean. | -| PostgreSQL | `Npgsql` | ⚠️ Core is AOT-capable; some type plug-ins reflect | -| Oracle | `Oracle.ManagedDataAccess.Core` | ❌ Not AOT-ready. This is a limitation of the provider | - -### What you will see in your own build - -**No warnings.** Publishing with `PublishAot` or `PublishTrimmed` reports no `IL2xxx` and no `IL3xxx` diagnostic -for any scenario in the table above, on either target framework. - -The public API carries no `[RequiresUnreferencedCode]` and no `[RequiresDynamicCode]`, so nothing is reported at -your call sites. The three underlying reflection sites are answered inside the library, where they occur: - -| Site | How it is answered | -|---|---| -| Specializing the value converter over the column's type | The generic method declares no `[DynamicallyAccessedMembers]`, so a runtime specialization has no requirements trimming could fail to preserve | -| Compiling the expression tree | `[RequiresDynamicCode]` stays on the expression-tree materializer, and its only caller reaches it from inside a `RuntimeFeature.IsDynamicCodeSupported` branch that the AOT compiler removes | -| Finding the constructor of a nested value tuple | An `ILLink.Descriptors.xml` embedded in the package preserves `System.ValueTuple\`1`-`\`8`, so the constructors survive trimming | - -This is verified rather than asserted: the repository publishes a Native AOT smoke test on `net8.0` and -`net10.0` and gates on **zero** IL diagnostics plus every asserted value coming back correctly, including -nested value tuples and enum fields inside them. - -## API summary - -Configuration: -- [EnumSerializationMode](#enumserializationmode) - Configure how enum values are serialized when sent to the database -- [InterceptDbCommand](#interceptdbcommand) - Configure a delegate to intercept `DbCommand`s executed by DbConnectionPlus - -Entity mapping: -- [Fluent API](#fluent-api) - Configure entity mapping via fluent API -- [Data annotation attributes](#data-annotation-attributes) - Configure entity mapping via data annotation attributes - -General-purpose methods: -- [ExecuteNonQuery / ExecuteNonQueryAsync](#executenonquery--executenonqueryasync) - Execute a non-query and return -number of affected rows -- [ExecuteReader / ExecuteReaderAsync](#executereader--executereaderasync) - Execute a query and return `DbDataReader` -to read the results -- [ExecuteScalar / ExecuteScalarAsync](#executescalar--executescalarasync) - Read a single value -- [Exists / ExistsAsync](#exists--existsasync) - Check for existence of rows - -Query methods: -- [Query / QueryAsync](#query--queryasync) - Map result set to `DataRow` instances -- [QueryFirst / QueryFirstAsync](#queryfirst--queryfirstasync) - Map first row of result set to a `DataRow` -- [QueryFirstOrDefault / QueryFirstOrDefaultAsync](#queryfirstordefault--queryfirstordefaultasync) - Map first row of result set to a `DataRow` or null if no rows are found -- [QuerySingle / QuerySingleAsync](#querysingle--querysingleasync) - Map single row of result set to a `DataRow` -- [QuerySingleOrDefault / QuerySingleOrDefaultAsync](#querysingleordefault--querysingleordefaultasync) - Map single row of result set to a `DataRow` or null if no rows are found -- [Query\ / QueryAsync\](#queryt--queryasynct) - Map result set to scalar values, entities or value tuples -- [QueryFirst\ / QueryFirstAsync\](#queryfirstt--queryfirstasynct) - Map first row of result set to a scalar value, entity or value tuple -- [QueryFirstOrDefault\ / QueryFirstOrDefaultAsync\](#queryfirstordefaultt--queryfirstordefaultasynct) - Map first row of result set to a scalar value, entity or value tuple or default value if no rows are found -- [QuerySingle\ / QuerySingleAsync\](#querysinglet--querysingleasynct) - Map single row of result set to a scalar value, entity or value tuple -- [QuerySingleOrDefault\ / QuerySingleOrDefaultAsync\](#querysingleordefaultt--querysingleordefaultasynct) - Map single row of result set to a scalar value, entity or value tuple or default value if no rows are found - -Entity manipulation methods: -- [InsertEntities / InsertEntitiesAsync](#insertentities--insertentitiesasync) - Insert a sequence of new entities -- [InsertEntity / InsertEntityAsync](#insertentity--insertentityasync) - Insert a new entity -- [UpdateEntities / UpdateEntitiesAsync](#updateentities--updateentitiesasync) - Update existing entities by keys -- [UpdateEntity / UpdateEntityAsync](#updateentity--updateentityasync) - Update an existing entity by key -- [DeleteEntities / DeleteEntitiesAsync](#deleteentities--deleteentitiesasync) - Delete existing entities by keys -- [DeleteEntity / DeleteEntityAsync](#deleteentity--deleteentityasync) - Delete an existing entity by key - -Special helpers: -- [Parameter(value)](#parametervalue) - Create a parameter for an SQL statement from an interpolated value -- [TemporaryTable(values)](#temporarytablevalues) - Create a temporary table from a sequence of values and reference -it inside an SQL statement - -### Configuration - -Use `DbConnectionExtensions.Configure` to configure DbConnectionPlus. - -```csharp -DbConnectionExtensions.Configure(config => -{ - // Configuration options go here -}); -``` - -> [!NOTE] -> To prevent multi-threading issues `DbConnectionExtensions.Configure` can only be called once during the application lifetime. -> After it has been called the configuration of DbConnectionPlus is frozen and cannot be changed anymore. - -#### EnumSerializationMode -Use `EnumSerializationMode` to configure how enum values are serialized when they are sent to a database. -`EnumSerializationMode.Strings` (the default) serializes them as their string representation, -`EnumSerializationMode.Integers` as integers. It applies to entity properties, parameters and temporary table -columns alike - see [Enum support](#enum-support). - -```csharp -DbConnectionExtensions.Configure(config => -{ - config.EnumSerializationMode = EnumSerializationMode.Integers; -}); -``` - -#### InterceptDbCommand -Use `InterceptDbCommand` to configure a delegate that intercepts a `DbCommand` before it is executed. This can be -useful for logging, modifying the command text, or applying additional configuration. - -```csharp -DbConnectionExtensions.Configure(config => -{ - config.InterceptDbCommand = (dbCommand, temporaryTables) => - { - // Log the command text - Console.WriteLine("Executing SQL Command: " + dbCommand.CommandText); - - // Modify the command text if needed - dbCommand.CommandText += " OPTION (RECOMPILE)"; - - // Apply additional configuration if needed - dbCommand.CommandTimeout = 60; - }; -}); -``` - -See [DbCommandLogger](https://github.com/rent-a-developer/DbConnectionPlus/blob/main/tests/DbConnectionPlus.IntegrationTests/TestHelpers/DbCommandLogger.cs) -for an example of logging executed commands. - -#### Entity Mapping - -You can configure how entity types are mapped to database tables and columns using either the fluent API or data -annotation attributes. - -> [!NOTE] -> Mapping configured via the fluent API takes precedence over mapping configured via data annotation attributes. -> When a fluent mapping exists for an entity type, the data annotations on this entity type are ignored. -> When a fluent mapping exists for an entity property, the data annotations on this property are ignored. - -##### Fluent API -You can use the fluent API to configure how entity types are mapped to database tables and columns. - -```csharp -DbConnectionExtensions.Configure(config => -{ - config.Entity() - .ToTable("Products"); - - config.Entity() - .Property(a => a.Id) - .HasColumnName("ProductId") - .IsIdentity() - .IsKey(); - - config.Entity() - .Property(a => a.DiscountedPrice) - .IsComputed(); - - config.Entity() - .Property(a => a.IsOnSale) - .IsIgnored(); - - config.Entity() - .Property(a => a.Version) - .IsRowVersion(); - - config.Entity() - .Property(a => a.ConcurrencyToken) - .IsConcurrencyToken(); -}); -``` - -| Method | Configures | +| | | |---|---| -| `Entity()` | Starts configuring the mapping for the entity type `TEntity`. | -| `ToTable(tableName)` | The table where entities of that type are stored. | -| `Property(propertyExpression)` | Starts configuring the mapping for one property. | -| `HasColumnName(columnName)` | The column where the property is stored. | -| `IsKey()` | The property is part of the key by which entities are identified. | -| `IsIdentity()` | The property is generated by the database on insert. | -| `IsComputed()` | The property is generated by the database on insert and update. | -| `IsRowVersion()` | The property is a native database-generated concurrency token. | -| `IsConcurrencyToken()` | The property is an application-managed concurrency token. | -| `IsIgnored()` | The property is not mapped to a column. | - -##### Data annotation attributes - -Entity mapping can also be configured with the standard attributes from -`System.ComponentModel.DataAnnotations` and `System.ComponentModel.DataAnnotations.Schema`: +| [Parameters in interpolated strings](docs/guides/parameters-and-temporary-tables.md) | `{Parameter(value)}` becomes a real `DbParameter`. The value is never concatenated into the SQL | +| [On-the-fly temporary tables](docs/guides/parameters-and-temporary-tables.md#on-the-fly-temporary-tables-via-interpolated-strings) | `{TemporaryTable(values)}` bulk-loads an `IEnumerable` into a temporary table and drops it afterwards | +| [Querying](docs/guides/querying.md) | `Query` and friends map to entities, records, scalars and value tuples — or to an untyped `DataRow` | +| [Entity mapping and CRUD](docs/guides/entity-mapping-and-crud.md) | `[Table]`, `[Column]`, `[Key]` and the rest of `System.ComponentModel.DataAnnotations`, or a fluent API. Insert, update and delete with optimistic concurrency | +| [Configuration](docs/guides/configuration.md) | One `Configure` call: adapters, enum serialization, and a hook that sees every command before it runs | +| [Native AOT](docs/guides/native-aot.md) | Reference the package and publish. No companion package, no source generator, no `IL2xxx` warnings | + +The detail lives in [the guides](docs/guides/), the API reference is +[published from the XML docs](https://rent-a-developer.github.io/DbConnectionPlus/), and +[the reference section](docs/reference/) carries the +[comparison with Dapper](docs/reference/comparison.md) and the +[benchmark results](docs/reference/performance.md). + +## Support limits + +Read these before adopting it; each one is a thing this library does **not** do. + +- **No multi-mapping and no multiple result sets.** There is no `splitOn` and no `QueryMultiple`. +- **No custom type-conversion handlers.** The conversions are the ones in the box. +- **`dynamic row.Id` does not work under Native AOT.** The Dynamic Language Runtime binds by generating code. + Use the `row["Id"]` indexer, which works everywhere. See [Native AOT](docs/guides/native-aot.md). +- **End-to-end AOT support is bounded by your ADO.NET driver.** SQLite, MySQL and SQL Server publish and run + clean; some Npgsql type plug-ins reflect; `Oracle.ManagedDataAccess.Core` is not AOT-ready, and this library + cannot fix that. The [per-provider matrix](docs/guides/native-aot.md#supported-providers) has the detail. +- **Temporary tables are off by default on Oracle**, because creating or dropping a private temporary table + implicitly commits the caller's transaction. [Why, and how to enable them](docs/guides/parameters-and-temporary-tables.md#on-the-fly-temporary-tables-via-interpolated-strings). +- **MySQL temporary tables need `AllowLoadLocalInfile=true`** in the connection string and `local_infile` on + the server, because they are populated with `MySqlBulkCopy`. +- **`Configure` can be called once per process**, at startup. The configuration is frozen afterwards. -```csharp -[Table("Products")] // Table name; defaults to the type name -class Product -{ - [Key] // Identifies the entity (usually the primary key) - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public Int64 Id { get; set; } - - [Column("ProductName")] // Column name; defaults to the property name - public String Name { get; set; } - - [Timestamp] // Native database-generated concurrency token - public Byte[] Version { get; set; } - - [ConcurrencyCheck] // Application-managed concurrency token - public Byte[] ConcurrencyToken { get; set; } - - [NotMapped] // Never read from or written to the database - public Decimal TotalPrice => this.UnitPrice * this.Quantity; -} -``` - -| Attribute | Effect | -|---|---| -| `TableAttribute` | The table where entities of the type are stored. Without it, the entity type's name (excluding its namespace) is used. | -| `ColumnAttribute` | The column where the property is stored. Without it, the property name is used. | -| `KeyAttribute` | The property (or properties) by which entities of the type are identified. | -| `DatabaseGeneratedAttribute` | The property is generated by the database. Unless `DatabaseGeneratedOption.None` is used it is skipped when inserting and updating, and its value is read back from the database onto the entity afterwards. | -| `TimestampAttribute` | The property is a native database-generated concurrency token: it is checked during update and delete, which fail if the database value no longer matches the original, and it is read back after insert and update. | -| `ConcurrencyCheckAttribute` | The property is an application-managed concurrency token, checked during update and delete the same way. | -| `NotMappedAttribute` | The property is ignored entirely - never read from and never written to the database. | - -### General-purpose methods - -#### ExecuteNonQuery / ExecuteNonQueryAsync -Executes an SQL statement and returns the number of rows affected by the statement. - -```csharp -if (supplier.IsRetired) -{ - var numberOfDeletedProducts = connection.ExecuteNonQuery( - $""" - DELETE FROM Product - WHERE SupplierId = {Parameter(supplier.Id)} - """ - ); -} -``` - -#### ExecuteReader / ExecuteReaderAsync -Executes an SQL statement and returns a `DbDataReader` to read the results. - -```csharp -var lowStockThreshold = configuration.Thresholds.LowStock; - -using var lowStockProductsReader = connection.ExecuteReader( - $""" - SELECT * - FROM Product - WHERE UnitsInStock < {Parameter(lowStockThreshold)} - """ -); -``` - -#### ExecuteScalar / ExecuteScalarAsync -Executes an SQL statement and returns the value of the first column of the first row in the result set converted to -the specified type. -```csharp -var lowStockThreshold = configuration.Thresholds.LowStock; - -var numberOfLowStockProducts = connection.ExecuteScalar( - $""" - SELECT COUNT(*) - FROM Product - WHERE UnitsInStock < {Parameter(lowStockThreshold)} - """ -); -``` - -#### Exists / ExistsAsync -Checks if any rows exist that match the specified SQL statement. -```csharp -var lowStockThreshold = configuration.Thresholds.LowStock; - -var existLowStockProducts = connection.Exists( - $""" - SELECT 1 - FROM Product - WHERE UnitsInStock < {Parameter(lowStockThreshold)} - """ -); -``` - -### Query methods - -The non-generic query methods below return `DataRow` instances. There are two ways to read a column, and which -one you should use depends on how your application is published: - -| | Access | Works on | -|---|---|---| -| **Recommended** | `product["Id"]` — string indexer, no cast | every runtime, **including Native AOT** | -| Optional | `product.Id` — member access through a `dynamic` reference | runtimes with dynamic code generation (**not** Native AOT) | - -The examples in this section use the string indexer. To use member access instead, assign the row to a `dynamic` -reference — the static return type is `DataRow`, not `dynamic`, so the step is explicit: - -```csharp -dynamic product = connection.QueryFirst($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); -var name = product.Name; - -foreach (dynamic p in connection.Query($"SELECT * FROM Product")) -{ - var unitsInStock = p.UnitsInStock; -} -``` - -Member access behaves exactly like the indexer, including throwing `KeyNotFoundException` for a column the row -does not contain. Note that through a `dynamic` reference a *property* always addresses a column — `row.Count` -reads the column named `Count`, not the number of columns — while *method* calls still resolve against `DataRow`, -so `row.ContainsKey("Id")` works as expected. Use a statically typed `DataRow` reference to reach `Count`, `Keys` -and `Values`. - -If you publish with Native AOT, use the indexer: the C# compiler reports `dynamic` usage as an AOT -incompatibility at your own call site, and the Dynamic Language Runtime cannot bind it without run-time code -generation. `DataRow` itself is AOT-safe to construct and use either way. See -[Native AOT and trimming](#native-aot-and-trimming). - -#### Query / QueryAsync -Executes an SQL statement and maps the result set to a sequence of `DataRow` instances. Access columns by name -through the string indexer. -```csharp -var lowStockThreshold = configuration.Thresholds.LowStock; - -var lowStockProducts = connection.Query( - $""" - SELECT * - FROM Product - WHERE UnitsInStock < {Parameter(lowStockThreshold)} - """ -); - -foreach (var product in lowStockProducts) -{ - var id = product["Id"]; - var unitsInStock = product["UnitsInStock"]; - ... -} -``` - -#### QueryFirst / QueryFirstAsync -Executes an SQL statement and maps the first row of the result set to a `DataRow`. -Throws if no rows are found. -```csharp -var product = connection.QueryFirst($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); - -var id = product["Id"]; -var name = product["Name"]; -... -``` - -#### QueryFirstOrDefault / QueryFirstOrDefaultAsync -Executes an SQL statement and maps the first row of the result set to a `DataRow` or null if no rows are found. -```csharp -var product = connection.QueryFirstOrDefault($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); - -if (product is not null) -{ - var id = product["Id"]; - var name = product["Name"]; - ... -} -``` - -#### QuerySingle / QuerySingleAsync -Executes an SQL statement and maps the single row of the result set to a `DataRow`. -Throws if no rows or more than one row are found. -```csharp -var product = connection.QuerySingle($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); - -var id = product["Id"]; -var name = product["Name"]; -... -``` - -#### QuerySingleOrDefault / QuerySingleOrDefaultAsync -Executes an SQL statement and maps the single row of the result set to a `DataRow` or null if no rows are found. -Throws if more than one row are found. -```csharp -var product = connection.QuerySingleOrDefault($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); - -if (product is not null) -{ - var id = product["Id"]; - var name = product["Name"]; - ... -} -``` - -#### Query\ / QueryAsync\ -Executes an SQL statement and maps the result set to a sequence of scalar values, entities or value tuples of the -specified type. - -```csharp -var lowStockThreshold = configuration.Thresholds.LowStock; - -// Entities -var lowStockProducts = connection.Query( - $""" - SELECT * - FROM Product - WHERE UnitsInStock < {Parameter(lowStockThreshold)} - """ -); - -// Scalar values -var lowStockProductIds = connection.Query( - $""" - SELECT Id - FROM Product - WHERE UnitsInStock < {Parameter(lowStockThreshold)} - """ -); - -// Value tuples -var lowStockProductInfos = connection.Query<(Int64 ProductId, Int32 UnitsInStock)>( - $""" - SELECT Id, UnitsInStock - FROM Product - WHERE UnitsInStock < {Parameter(lowStockThreshold)} - """ -); -``` - -#### QueryFirst\ / QueryFirstAsync\ -Executes an SQL statement and maps the first row of the result set to a scalar value, entity or value tuple of the -specified type. -Throws if no rows are found. -```csharp -var product = connection.QueryFirst($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); -``` - -#### QueryFirstOrDefault\ / QueryFirstOrDefaultAsync\ -Executes an SQL statement and maps the first row of the result set to a scalar value, entity or value tuple of the -specified type or default value if no rows are found. -```csharp -var product = connection.QueryFirstOrDefault($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); -``` - -#### QuerySingle\ / QuerySingleAsync\ -Executes an SQL statement and maps the single row of the result set to a scalar value, entity or value tuple of the -specified type. -Throws if no rows or more than one row are found. -```csharp -var product = connection.QuerySingle($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); -``` - -#### QuerySingleOrDefault\ / QuerySingleOrDefaultAsync\ -Executes an SQL statement and maps the single row of the result set to a scalar value, entity or value tuple of the -specified type or default value if no rows are found. -Throws if more than one row are found. -```csharp -var product = connection.QuerySingleOrDefault($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); -``` - -### Entity manipulation methods - -The examples below use these entity types: - -```csharp -class Product -{ - [Key] - public Int64 Id { get; set; } - public Int64 SupplierId { get; set; } - public String Name { get; set; } - public Decimal UnitPrice { get; set; } - public Int32 UnitsInStock { get; set; } - public Boolean IsDiscontinued { get; set; } -} - -enum UserState { Active, Inactive, Suspended } - -class User -{ - [Key] - public Int64 Id { get; set; } - public DateTime LastLoginDate { get; set; } - public UserState State { get; set; } -} -``` - -#### InsertEntities / InsertEntitiesAsync -Inserts a sequence of new entities into a database table. -```csharp -connection.InsertEntities(GetNewProducts()); -``` - -#### InsertEntity / InsertEntityAsync -Inserts a new entity into a database table. -```csharp -connection.InsertEntity(GetNewProduct()); -``` - -#### UpdateEntities / UpdateEntitiesAsync -Updates existing entities in a database table based on their keys. -```csharp -var usersWithoutLoginInPastYear = connection.Query( - """ - SELECT * - FROM Users - WHERE LastLoginDate < DATEADD(YEAR, -1, GETUTCDATE()) - """ -); - -foreach (var user in usersWithoutLoginInPastYear) -{ - user.State = UserState.Inactive; -} - -connection.UpdateEntities(usersWithoutLoginInPastYear); -``` - -#### UpdateEntity / UpdateEntityAsync -Updates an existing entity in a database table based on its key. -```csharp -if (user.LastLoginDate < DateTime.UtcNow.AddYears(-1)) -{ - user.State = UserState.Inactive; - connection.UpdateEntity(user); -} -``` - -#### DeleteEntities / DeleteEntitiesAsync -Deletes a sequence of entities from a database table based on their keys. -```csharp -connection.DeleteEntities(products.Where(a => a.IsDiscontinued)); -``` - -#### DeleteEntity / DeleteEntityAsync -Deletes an entity from a database table based on its key. -```csharp -if (product.IsDiscontinued) -{ - connection.DeleteEntity(product); -} -``` - -### Special helpers - -The following special helpers can be used with any DbConnectionPlus extension method that accepts an instance of -`InterpolatedSqlStatement`. - -#### Parameter(value) -Use `Parameter(value)` to pass a value in an interpolated string as a parameter to an SQL statement. - -```csharp -var lowStockThreshold = configuration.Thresholds.LowStock; +## Links -using var lowStockProductsReader = connection.ExecuteReader( - $""" - SELECT * - FROM Product - WHERE UnitsInStock < {Parameter(lowStockThreshold)} - """ -); -``` -This adds a parameter holding the value of `lowStockThreshold` to the SQL statement, and replaces the -`{Parameter(value)}` expression with the parameter's name. +- [Documentation](docs/) — guides, reference, and the design record +- [API reference](https://rent-a-developer.github.io/DbConnectionPlus/) +- [Change log](CHANGELOG.md) +- [Design decisions](docs/DESIGN-DECISIONS.md) — why it works the way it does +- [Contributing](CONTRIBUTING.md) · [Code of conduct](CODE_OF_CONDUCT.md) · [Security policy](SECURITY.md) +- Licensed under the [MIT license](LICENSE.md) -The parameter name is inferred from the expression passed to `Parameter(value)` - here `LowStockThreshold`. If -no name can be inferred (e.g. `Parameter(42)`), a generic name like `Parameter_1`, `Parameter_2` and so on is -used. +## Contributors -Enum values are serialized as strings or as integers according to -[EnumSerializationMode](#enumserializationmode). +- David Liebeherr ([info@rent-a-developer.de](mailto:info@rent-a-developer.de)) -#### TemporaryTable(values) -Use `TemporaryTable(values)` to pass a sequence of scalar values or complex objects in an interpolated string as a -temporary table to an SQL statement. +--- -A sequence of scalar values (e.g. `String`, `Int32`, `DateTime`, enums and so on) produces a temporary table -with a single column named `Value`, typed to match the passed values: +## Moved sections -```csharp -var retiredSupplierIds = suppliers.Where(a => a.IsRetired).Select(a => a.Id); +Four sections used to live in this file, and links to their anchors are already published — the +`PACKAGE_README.md` inside the 4.0.0 packages on nuget.org points at one of them. The headings below keep +those anchors working. Nothing else is here. -using var retiredSupplierProductsReader = connection.ExecuteReader( - $""" - SELECT * - FROM Product - WHERE SupplierId IN ( - SELECT Value - FROM {TemporaryTable(retiredSupplierIds)} - ) - """ -); -``` -```sql -CREATE TABLE #RetiredSupplierIds_48d42afd5d824a27bd9352676ab6c198 -( - Value BIGINT -) -``` +### Why not just use Dapper? -A sequence of complex objects produces one column per public property, named and typed after that property: +Moved to [docs/reference/comparison.md](docs/reference/comparison.md). -```csharp -class OrderItem -{ - public Int64 ProductId { get; set; } - public DateTime OrderDate { get; set; } -} - -var orderItems = GetOrderItems(); -var sixMonthsAgo = DateTime.UtcNow.AddMonths(-6); +### API summary -using var productsOrderedInPastSixMonthsReader = connection.ExecuteReader( - $""" - SELECT * - FROM Product - WHERE EXISTS ( - SELECT 1 - FROM {TemporaryTable(orderItems)} TOrderItem - WHERE TOrderItem.ProductId = Product.Id AND - TOrderItem.OrderDate >= {Parameter(sixMonthsAgo)} - ) - """ -); -``` -```sql -CREATE TABLE #OrderItems_d6545835d97148ab93709efe9ba1f110 -( - ProductId BIGINT, - OrderDate DATETIME2 -) -``` +Moved to [docs/reference/api-summary.md](docs/reference/api-summary.md). -The table name is inferred from the expression passed to `TemporaryTable(values)` and suffixed with a new Guid -to avoid naming conflicts (e.g. `OrderItems_395c98f203514e81aa0098ec7f13e8a2`); if no name can be inferred, -`Values` is used instead. The `{TemporaryTable(values)}` expression is replaced with that name in the SQL -statement. +### Benchmarks -Enum values - passed directly or as properties of complex objects - are serialized according to -[EnumSerializationMode](#enumserializationmode), and the column is typed `NVARCHAR(200)` for `Strings` and -`INT` for `Integers`. +Moved to [docs/reference/performance.md](docs/reference/performance.md). ### Custom database adapter -If you want to use DbConnectionPlus with a database system or a database connector that is not supported out of the -box, you can implement a custom `IDatabaseAdapter`: -```csharp -using RentADeveloper.DbConnectionPlus.DatabaseAdapters; - -public class MyDatabaseAdapter : IDatabaseAdapter -{ - // Write a class that implements RentADeveloper.DbConnectionPlus.DatabaseAdapters.IEntityManipulator and - // return it here. - public IEntityManipulator EntityManipulator => new MyEntityManipulator(); +Moved to [docs/guides/custom-adapters.md](docs/guides/custom-adapters.md). - // Write a class that implements RentADeveloper.DbConnectionPlus.DatabaseAdapters.ITemporaryTableBuilder and - // return it here. - public ITemporaryTableBuilder TemporaryTableBuilder => new MyTemporaryTableBuilder(); +### Native AOT and trimming - public void BindParameterValue(DbParameter parameter, Object? value) - { - ... - } - - public String FormatParameterName(String parameterName) - { - ... - } - - ... -} -``` - -Then register your custom database adapter before using DbConnectionPlus: -```csharp -using RentADeveloper.DbConnectionPlus.DatabaseAdapters; - -DbConnectionExtensions.Configure(config => -{ - config.RegisterDatabaseAdapter(new MyDatabaseAdapter()); -}); -``` - -You can also create an extension method for convenient registration: - -```csharp -namespace RentADeveloper.DbConnectionPlus.Configuration; - -public static class MyCustomConfigurationExtensions -{ - public static DbConnectionPlusConfiguration UseMyCustomDatabase(this DbConnectionPlusConfiguration configuration) - { - configuration.RegisterDatabaseAdapter(new MyDatabaseAdapter()); - return configuration; - } -} -``` - -Then register it like any built-in adapter: - -```csharp -DbConnectionExtensions.Configure(config => config.UseMyCustomDatabase()); -``` - -See [SqlServerDatabaseAdapter](https://github.com/rent-a-developer/DbConnectionPlus/blob/main/src/DbConnectionPlus.DatabaseAdapters.SqlServer/SqlServerDatabaseAdapter.cs) -for an example implementation of a database adapter. - -## Benchmarks -DbConnectionPlus is designed to have a minimal performance and allocation overhead compared to using `DbCommand` -manually. - -All benchmarks are performed using SQLite in-memory databases, which is a worst-case scenario for DbConnectionPlus -because the overhead of using DbConnectionPlus is more noticeable when the executed SQL statements are very fast. - -The entity-querying categories are additionally measured as a Native AOT compiled binary, because DbConnectionPlus -selects its materializer on `RuntimeFeature.IsDynamicCodeSupported` and the reflection path behind that switch is -the one a Native AOT consumer runs. Only `Query_Entities`, `Query_ValueTuples` and `TemporaryTable_ComplexObjects` -reach that branch; every other category runs identical code on both runtimes, so measuring it twice would only -compare RyuJIT with ILC. The table below is the JIT snapshot. See -[benchmarks/DbConnectionPlus.Benchmarks/README.md](benchmarks/DbConnectionPlus.Benchmarks/README.md) for the -two-job summary and for which categories have a Dapper competitor under Native AOT at all. - -``` - -BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.9168/25H2/2025Update/HudsonValley2) -12th Gen Intel Core i9-12900K 3.19GHz, 1 CPU, 24 logical and 16 physical cores -.NET SDK 10.0.303 - [Host] : .NET 10.0.11 (10.0.11, 10.0.1126.37416), X64 RyuJIT x86-64-v3 - JIT : .NET 10.0.11 (10.0.11, 10.0.1126.37416), X64 RyuJIT x86-64-v3 - AOT : .NET 10.0.11, X64 NativeAOT x86-64-v3 - -Server=True InvocationCount=Default IterationTime=300ms -MaxIterationCount=20 UnrollFactor=16 WarmupCount=3 - -``` -| Method | Job | Toolchain | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio | -|----------------------------------------------- |---- |------------------ |-------------:|------------:|------------:|-------------:|--------:|--------:|-------:|----------:|------------:| -| **DeleteEntities_Command** | **JIT** | **Default** | **136.627 μs** | **1.2107 μs** | **1.1324 μs** | **baseline** | **** | **0.7813** | **-** | **68556 B** | **** | -| DeleteEntities_Dapper | JIT | Default | 168.392 μs | 1.8021 μs | 1.5975 μs | 1.23x slower | 0.02x | 1.2500 | - | 133269 B | 1.94x more | -| DeleteEntities_DbConnectionPlus | JIT | Default | 167.147 μs | 1.0246 μs | 0.9584 μs | 1.22x slower | 0.01x | 1.0417 | - | 116876 B | 1.70x more | -| | | | | | | | | | | | | -| **DeleteEntity_Command** | **JIT** | **Default** | **1.464 μs** | **0.0115 μs** | **0.0102 μs** | **baseline** | **** | **0.0078** | **-** | **769 B** | **** | -| DeleteEntity_Dapper | JIT | Default | 1.972 μs | 0.0148 μs | 0.0131 μs | 1.35x slower | 0.01x | 0.0263 | - | 1705 B | 2.22x more | -| DeleteEntity_DbConnectionPlus | JIT | Default | 1.749 μs | 0.0186 μs | 0.0165 μs | 1.19x slower | 0.01x | 0.0170 | - | 1249 B | 1.62x more | -| | | | | | | | | | | | | -| **ExecuteNonQuery_Command** | **JIT** | **Default** | **1.353 μs** | **0.0168 μs** | **0.0140 μs** | **baseline** | **** | **0.0133** | **-** | **768 B** | **** | -| ExecuteNonQuery_Dapper | JIT | Default | 1.551 μs | 0.0145 μs | 0.0129 μs | 1.15x slower | 0.01x | 0.0153 | - | 1072 B | 1.40x more | -| ExecuteNonQuery_DbConnectionPlus | JIT | Default | 1.697 μs | 0.0057 μs | 0.0048 μs | 1.25x slower | 0.01x | 0.0280 | - | 1608 B | 2.09x more | -| | | | | | | | | | | | | -| **ExecuteReader_Command** | **JIT** | **Default** | **281.423 μs** | **1.7947 μs** | **1.4987 μs** | **baseline** | **** | **6.3406** | **-** | **411084 B** | **** | -| ExecuteReader_Dapper | JIT | Default | 282.375 μs | 2.5789 μs | 2.4123 μs | 1.00x slower | 0.01x | 5.8140 | - | 411116 B | 1.00x more | -| ExecuteReader_DbConnectionPlus | JIT | Default | 280.036 μs | 2.4280 μs | 2.2711 μs | 1.01x faster | 0.01x | 6.0976 | - | 411724 B | 1.00x more | -| | | | | | | | | | | | | -| **ExecuteScalar_Command** | **JIT** | **Default** | **1.914 μs** | **0.0116 μs** | **0.0103 μs** | **baseline** | **** | **0.0188** | **-** | **1120 B** | **** | -| ExecuteScalar_Dapper | JIT | Default | 2.166 μs | 0.0142 μs | 0.0111 μs | 1.13x slower | 0.01x | 0.0215 | - | 1424 B | 1.27x more | -| ExecuteScalar_DbConnectionPlus | JIT | Default | 2.286 μs | 0.0171 μs | 0.0151 μs | 1.19x slower | 0.01x | 0.0307 | - | 2088 B | 1.86x more | -| | | | | | | | | | | | | -| **Exists_Command** | **JIT** | **Default** | **1.625 μs** | **0.0106 μs** | **0.0088 μs** | **baseline** | **** | **0.0161** | **-** | **1000 B** | **** | -| Exists_Dapper | JIT | Default | 1.847 μs | 0.0071 μs | 0.0060 μs | 1.14x slower | 0.01x | 0.0367 | - | 1336 B | 1.34x more | -| Exists_DbConnectionPlus | JIT | Default | 2.040 μs | 0.0238 μs | 0.0186 μs | 1.26x slower | 0.01x | 0.0338 | - | 1944 B | 1.94x more | -| | | | | | | | | | | | | -| **InsertEntities_Command** | **JIT** | **Default** | **1,084.779 μs** | **5.2549 μs** | **4.1027 μs** | **baseline** | **** | **18.7500** | **-** | **1129093 B** | **** | -| InsertEntities_Dapper | JIT | Default | 1,089.550 μs | 14.3120 μs | 12.6873 μs | 1.00x slower | 0.01x | 14.8148 | - | 1247818 B | 1.11x more | -| InsertEntities_DbConnectionPlus | JIT | Default | 1,184.102 μs | 7.7167 μs | 6.8406 μs | 1.09x slower | 0.01x | 19.5313 | - | 1139668 B | 1.01x more | -| | | | | | | | | | | | | -| **InsertEntity_Command** | **JIT** | **Default** | **8.668 μs** | **0.0370 μs** | **0.0309 μs** | **baseline** | **** | **0.1447** | **-** | **8480 B** | **** | -| InsertEntity_Dapper | JIT | Default | 14.258 μs | 0.0960 μs | 0.0851 μs | 1.64x slower | 0.01x | 0.2872 | - | 17608 B | 2.08x more | -| InsertEntity_DbConnectionPlus | JIT | Default | 9.003 μs | 0.0555 μs | 0.0492 μs | 1.04x slower | 0.01x | 0.1217 | - | 8024 B | 1.06x less | -| | | | | | | | | | | | | -| **Parameter_Command** | **JIT** | **Default** | **3.390 μs** | **0.0078 μs** | **0.0061 μs** | **baseline** | **** | **0.0452** | **-** | **2952 B** | **** | -| Parameter_Dapper | JIT | Default | 5.279 μs | 0.0373 μs | 0.0312 μs | 1.56x slower | 0.01x | 0.2117 | - | 5016 B | 1.70x more | -| Parameter_DbConnectionPlus | JIT | Default | 5.898 μs | 0.0504 μs | 0.0447 μs | 1.74x slower | 0.01x | 0.3523 | - | 7376 B | 2.50x more | -| | | | | | | | | | | | | -| **Query_Dynamic_Command** | **JIT** | **Default** | **302.573 μs** | **3.1295 μs** | **2.6133 μs** | **baseline** | **** | **15.1210** | **1.0081** | **532528 B** | **** | -| Query_Dynamic_Dapper | JIT | Default | 214.474 μs | 1.1667 μs | 1.0913 μs | 1.41x faster | 0.01x | 0.7267 | - | 73880 B | 7.21x less | -| Query_Dynamic_DbConnectionPlus | JIT | Default | 276.257 μs | 1.8022 μs | 1.5049 μs | 1.10x faster | 0.01x | 2.7174 | - | 131944 B | 4.04x less | -| | | | | | | | | | | | | -| **Query_Entities_Command** | **JIT** | **Default** | **281.692 μs** | **2.1036 μs** | **1.8648 μs** | **baseline** | **** | **7.1023** | **-** | **411084 B** | **** | -| Query_Entities_Dapper | JIT | Default | 234.856 μs | 0.8877 μs | 0.8303 μs | 1.20x faster | 0.01x | 0.7806 | - | 74105 B | 5.55x less | -| Query_Entities_DbConnectionPlus | JIT | Default | 245.137 μs | 0.8514 μs | 0.7547 μs | 1.15x faster | 0.01x | 0.8244 | - | 64025 B | 6.42x less | -| | | | | | | | | | | | | -| **Query_Entities_Command** | **AOT** | **Latest ILCompiler** | **305.151 μs** | **4.2016 μs** | **3.5085 μs** | **baseline** | **** | **7.0565** | **-** | **411091 B** | **** | -| Query_Entities_Dapper_Aot | AOT | Latest ILCompiler | 245.874 μs | 1.4414 μs | 1.2778 μs | 1.24x faster | 0.02x | 2.4351 | - | 60969 B | 6.74x less | -| Query_Entities_DbConnectionPlus | AOT | Latest ILCompiler | 321.910 μs | 3.0612 μs | 2.7137 μs | 1.06x slower | 0.01x | 1.0593 | - | 91244 B | 4.51x less | -| | | | | | | | | | | | | -| **Query_Scalars_Command** | **JIT** | **Default** | **81.212 μs** | **0.2861 μs** | **0.2389 μs** | **baseline** | **** | **0.2717** | **-** | **17288 B** | **** | -| Query_Scalars_Dapper | JIT | Default | 111.449 μs | 0.6049 μs | 0.5051 μs | 1.37x slower | 0.01x | 0.3720 | - | 36976 B | 2.14x more | -| Query_Scalars_DbConnectionPlus | JIT | Default | 109.890 μs | 0.4992 μs | 0.4670 μs | 1.35x slower | 0.01x | 0.3655 | - | 32480 B | 1.88x more | -| | | | | | | | | | | | | -| **Query_ValueTuples_Command** | **JIT** | **Default** | **98.529 μs** | **0.5243 μs** | **0.4905 μs** | **baseline** | **** | **0.6649** | **-** | **47801 B** | **** | -| Query_ValueTuples_Dapper | JIT | Default | 131.177 μs | 0.5286 μs | 0.4945 μs | 1.33x slower | 0.01x | 1.3193 | - | 71297 B | 1.49x more | -| Query_ValueTuples_DbConnectionPlus | JIT | Default | 130.361 μs | 1.5104 μs | 1.4128 μs | 1.32x slower | 0.02x | 0.9021 | - | 53137 B | 1.11x more | -| | | | | | | | | | | | | -| **Query_ValueTuples_Command** | **AOT** | **Latest ILCompiler** | **110.324 μs** | **0.4156 μs** | **0.3684 μs** | **baseline** | **** | **0.7267** | **-** | **47790 B** | **** | -| Query_ValueTuples_DbConnectionPlus | AOT | Latest ILCompiler | 175.588 μs | 1.3776 μs | 1.2212 μs | 1.59x slower | 0.01x | 1.1682 | - | 84376 B | 1.77x more | -| | | | | | | | | | | | | -| **TemporaryTable_ComplexObjects_Command** | **JIT** | **Default** | **2,728.061 μs** | **14.5332 μs** | **13.5944 μs** | **baseline** | **** | **46.8750** | **-** | **3388440 B** | **** | -| TemporaryTable_ComplexObjects_Dapper | JIT | Default | 1,918.002 μs | 34.1135 μs | 28.4863 μs | 1.42x faster | 0.02x | 16.6667 | - | 1731239 B | 1.96x less | -| TemporaryTable_ComplexObjects_DbConnectionPlus | JIT | Default | 2,169.543 μs | 39.4448 μs | 36.8967 μs | 1.26x faster | 0.02x | 17.8571 | - | 1580135 B | 2.14x less | -| | | | | | | | | | | | | -| **TemporaryTable_ComplexObjects_Command** | **AOT** | **Latest ILCompiler** | **3,180.703 μs** | **23.5846 μs** | **20.9072 μs** | **baseline** | **** | **52.0833** | **-** | **3388726 B** | **** | -| TemporaryTable_ComplexObjects_DbConnectionPlus | AOT | Latest ILCompiler | 2,650.714 μs | 13.9937 μs | 13.0897 μs | 1.20x faster | 0.01x | 23.4375 | - | 1648405 B | 2.06x less | -| | | | | | | | | | | | | -| **TemporaryTable_ScalarValues_Command** | **JIT** | **Default** | **4,946.405 μs** | **34.7151 μs** | **27.1033 μs** | **baseline** | **** | **20.8333** | **-** | **1493512 B** | **** | -| TemporaryTable_ScalarValues_Dapper | JIT | Default | 5,993.998 μs | 117.4418 μs | 115.3436 μs | 1.21x slower | 0.02x | 39.2157 | - | 3175374 B | 2.13x more | -| TemporaryTable_ScalarValues_DbConnectionPlus | JIT | Default | 5,844.893 μs | 38.7475 μs | 32.3559 μs | 1.18x slower | 0.01x | 38.4615 | - | 2696352 B | 1.81x more | -| | | | | | | | | | | | | -| **UpdateEntities_Command** | **JIT** | **Default** | **532.109 μs** | **2.0057 μs** | **1.8762 μs** | **baseline** | **** | **6.9444** | **-** | **566049 B** | **** | -| UpdateEntities_Dapper | JIT | Default | 586.049 μs | 4.1645 μs | 3.8954 μs | 1.10x slower | 0.01x | 11.6054 | - | 663867 B | 1.17x more | -| UpdateEntities_DbConnectionPlus | JIT | Default | 587.406 μs | 3.3649 μs | 2.8098 μs | 1.10x slower | 0.01x | 9.7656 | - | 571057 B | 1.01x more | -| | | | | | | | | | | | | -| **UpdateEntity_Command** | **JIT** | **Default** | **9.143 μs** | **0.1013 μs** | **0.0846 μs** | **baseline** | **** | **0.1225** | **-** | **8551 B** | **** | -| UpdateEntity_Dapper | JIT | Default | 10.748 μs | 0.0497 μs | 0.0465 μs | 1.18x slower | 0.01x | 0.1789 | - | 12031 B | 1.41x more | -| UpdateEntity_DbConnectionPlus | JIT | Default | 9.630 μs | 0.0560 μs | 0.0468 μs | 1.05x slower | 0.01x | 0.1277 | - | 8055 B | 1.06x less | - -### Running the benchmarks -```shell -pwsh -File scripts/benchmarks.ps1 -``` - -Anything after the script name is forwarded to BenchmarkDotNet, e.g. `--filter *Query_Entities*`. The Native AOT -job needs a C++ toolchain (MSVC on Windows, `clang` and `zlib1g-dev` on Linux); the script also puts `vswhere.exe` -on `PATH`, without which the native link step fails with a misleading `MSB3073`. - -## Running the tests - -The unit tests need nothing but the SDK: -```shell -dotnet test --project tests\DbConnectionPlus.UnitTests\DbConnectionPlus.UnitTests.csproj -``` - -The integration tests need a running [Docker](https://www.docker.com/) daemon, and nothing else - there is no -container to start by hand and no connection string to configure: - -```shell -dotnet test --project tests\DbConnectionPlus.IntegrationTests\DbConnectionPlus.IntegrationTests.csproj -``` - -[Testcontainers](https://dotnet.testcontainers.org/) starts MySQL, Oracle, PostgreSQL and SQL Server, waits -until each one accepts connections, and removes them again when the run ends. Containers start **on demand**, so -a run filtered to one database system only pays for that one, and SQLite needs no container at all. Every -container publishes its port to a free port of the host, so nothing collides with a database server installed -locally. - -## Contributing -Contributions and bug reports are welcome and appreciated. -Please follow the repository's [CONTRIBUTING.md](CONTRIBUTING.md) and code style. -Open a GitHub issue for problems or a pull request with tests and a clear description of changes. - -## Links - -- [API documentation](https://rent-a-developer.github.io/DbConnectionPlus/) -- [Change log](CHANGELOG.md) -- [Design decisions](DESIGN-DECISIONS.md) - why the library works the way it does -- Licensed under the [MIT license](LICENSE.md) - -## Contributors - -- David Liebeherr ([info@rent-a-developer.de](mailto:info@rent-a-developer.de)) +Moved to [docs/guides/native-aot.md](docs/guides/native-aot.md). diff --git a/docs/docfx.json b/build/docfx/docfx.json similarity index 50% rename from docs/docfx.json rename to build/docfx/docfx.json index 0a58a54..1d93b65 100644 --- a/docs/docfx.json +++ b/build/docfx/docfx.json @@ -4,42 +4,60 @@ { "src": [ { - "src": "../src", + "src": "../../src", "files": [ "**/*.csproj" ] } ], - "dest": "api", + "dest": "../../artifacts/docs/api", "noRestore": true } ], "build": { "content": [ { + "src": "../../docs", "files": [ "**/*.{md,yml}" ], - "exclude": [ - "_site/**" - ] + "dest": "." + }, + { + "src": "../../artifacts/docs/api", + "files": [ + "**/*.{md,yml}" + ], + "dest": "api" + }, + { + "src": "../../", + "files": [ + "CONTRIBUTING.md", + "CODE_OF_CONDUCT.md", + "SECURITY.md", + "CHANGELOG.md" + ], + "dest": "." } ], "resource": [ { + "src": "../../docs", "files": [ "assets/**" - ] + ], + "dest": "." } ], - "output": "_site", + "output": "../../artifacts/docs/site", "template": [ "default", "modern" ], "globalMetadata": { - "_appName": "DbConnectionPlus API Documentation", - "_appTitle": "DbConnectionPlus API Documentation", + "_appName": "DbConnectionPlus", + "_appTitle": "DbConnectionPlus documentation", "_appLogoPath": "assets/logo.svg", "_appFaviconPath": "assets/favicon.ico", "_enableSearch": true diff --git a/DESIGN-DECISIONS.md b/docs/DESIGN-DECISIONS.md similarity index 98% rename from DESIGN-DECISIONS.md rename to docs/DESIGN-DECISIONS.md index 469a876..7628e47 100644 --- a/DESIGN-DECISIONS.md +++ b/docs/DESIGN-DECISIONS.md @@ -5,8 +5,8 @@ **Author:** David Liebeherr This document describes the design **as it is now**, and why it is that way. It is not a change log - see -[CHANGELOG.md](CHANGELOG.md) for what changed between versions, and [README.md](README.md) for how to use the -library. +[CHANGELOG.md](../CHANGELOG.md) for what changed between versions, and [the guides](guides/querying.md) for how to use +the library. ## Table of contents @@ -236,7 +236,7 @@ types at opted-in call sites and emitted reflection-free mappers, registered thr `[ModuleInitializer]`. It buys run-time performance on the mapping step and nothing else. What the repository still measures is the price of the reflection path itself - the same cost a generator would -have removed. From the [benchmark suite](README.md#benchmarks), on in-memory SQLite, where statement execution +have removed. From the [benchmark results](reference/performance.md), on in-memory SQLite, where statement execution is nearly free and mapping is therefore the largest possible share of the total: | Category, JIT → Native AOT | End to end | Of which the runtime itself (raw `DbCommand` baseline) | @@ -431,7 +431,7 @@ read-only for the rest of the process and the lookup needs no synchronization - - **The same mechanism serves custom adapters.** There is no built-in/third-party asymmetry: implement `IDatabaseAdapter` (plus an `IEntityManipulator` and an `ITemporaryTableBuilder`), call `RegisterDatabaseAdapter`, and optionally wrap that in a `UseMyDatabase()` extension method - - which is all the built-in adapters are. The [README](README.md#custom-database-adapter) carries a worked + which is all the built-in adapters are. The [custom-adapter guide](guides/custom-adapters.md) carries a worked example. **Trade-off:** one line of startup configuration that a static auto-registering registry would not need, and a @@ -762,7 +762,7 @@ That is also why the Oracle fixture pins the `faststart` image variant, whose da the plain one that spends minutes creating `FREEPDB1` on first start. Scoping rules and measured per-provider timings: -[`.agents/skills/integration-db/SKILL.md`](.agents/skills/integration-db/SKILL.md). +[`.agents/skills/integration-db/SKILL.md`](https://github.com/rent-a-developer/DbConnectionPlus/blob/main/.agents/skills/integration-db/SKILL.md). ### The Native AOT smoke test @@ -831,7 +831,7 @@ column of the two rows for the same method. ⚠️ **The benchmarks are not a trimming check.** BenchmarkDotNet reports a benchmark that returned default-valued entities as a *fast* benchmark, not a broken one. Only the smoke test asserts values. Details: -[the benchmark suite's README](benchmarks/DbConnectionPlus.Benchmarks/README.md). +[the benchmark suite's README](https://github.com/rent-a-developer/DbConnectionPlus/blob/main/benchmarks/DbConnectionPlus.Benchmarks/README.md). --- diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md new file mode 100644 index 0000000..2c49b47 --- /dev/null +++ b/docs/guides/configuration.md @@ -0,0 +1,51 @@ +# Configuration + +Use `DbConnectionExtensions.Configure` to configure DbConnectionPlus. + +```csharp +DbConnectionExtensions.Configure(config => +{ + // Configuration options go here +}); +``` + +> [!NOTE] +> To prevent multi-threading issues `DbConnectionExtensions.Configure` can only be called once during the application lifetime. +> After it has been called the configuration of DbConnectionPlus is frozen and cannot be changed anymore. + +## EnumSerializationMode +Use `EnumSerializationMode` to configure how enum values are serialized when they are sent to a database. +`EnumSerializationMode.Strings` (the default) serializes them as their string representation, +`EnumSerializationMode.Integers` as integers. It applies to entity properties, parameters and temporary table +columns alike - see [Enum support](entity-mapping-and-crud.md#enum-support). + +```csharp +DbConnectionExtensions.Configure(config => +{ + config.EnumSerializationMode = EnumSerializationMode.Integers; +}); +``` + +## InterceptDbCommand +Use `InterceptDbCommand` to configure a delegate that intercepts a `DbCommand` before it is executed. This can be +useful for logging, modifying the command text, or applying additional configuration. + +```csharp +DbConnectionExtensions.Configure(config => +{ + config.InterceptDbCommand = (dbCommand, temporaryTables) => + { + // Log the command text + Console.WriteLine("Executing SQL Command: " + dbCommand.CommandText); + + // Modify the command text if needed + dbCommand.CommandText += " OPTION (RECOMPILE)"; + + // Apply additional configuration if needed + dbCommand.CommandTimeout = 60; + }; +}); +``` + +See [DbCommandLogger](https://github.com/rent-a-developer/DbConnectionPlus/tree/main/tests/DbConnectionPlus.IntegrationTests) +for an example of logging executed commands. diff --git a/docs/guides/custom-adapters.md b/docs/guides/custom-adapters.md new file mode 100644 index 0000000..2294018 --- /dev/null +++ b/docs/guides/custom-adapters.md @@ -0,0 +1,65 @@ +# Custom database adapters + +If you want to use DbConnectionPlus with a database system or a database connector that is not supported out of the +box, you can implement a custom `IDatabaseAdapter`: + +```csharp +using RentADeveloper.DbConnectionPlus.DatabaseAdapters; + +public class MyDatabaseAdapter : IDatabaseAdapter +{ + // Write a class that implements RentADeveloper.DbConnectionPlus.DatabaseAdapters.IEntityManipulator and + // return it here. + public IEntityManipulator EntityManipulator => new MyEntityManipulator(); + + // Write a class that implements RentADeveloper.DbConnectionPlus.DatabaseAdapters.ITemporaryTableBuilder and + // return it here. + public ITemporaryTableBuilder TemporaryTableBuilder => new MyTemporaryTableBuilder(); + + public void BindParameterValue(DbParameter parameter, object? value) + { + ... + } + + public string FormatParameterName(string parameterName) + { + ... + } + + ... +} +``` + +Then register your custom database adapter before using DbConnectionPlus: +```csharp +using RentADeveloper.DbConnectionPlus.DatabaseAdapters; + +DbConnectionExtensions.Configure(config => +{ + config.RegisterDatabaseAdapter(new MyDatabaseAdapter()); +}); +``` + +You can also create an extension method for convenient registration: + +```csharp +namespace RentADeveloper.DbConnectionPlus.Configuration; + +public static class MyCustomConfigurationExtensions +{ + public static DbConnectionPlusConfiguration UseMyCustomDatabase(this DbConnectionPlusConfiguration configuration) + { + configuration.RegisterDatabaseAdapter(new MyDatabaseAdapter()); + return configuration; + } +} +``` + +Then register it like any built-in adapter: + +```csharp +DbConnectionExtensions.Configure(config => config.UseMyCustomDatabase()); +``` + +See [SqlServerDatabaseAdapter](https://github.com/rent-a-developer/DbConnectionPlus/blob/main/src/DbConnectionPlus.DatabaseAdapters.SqlServer/SqlServerDatabaseAdapter.cs) +for an example implementation of a database adapter. diff --git a/docs/guides/entity-mapping-and-crud.md b/docs/guides/entity-mapping-and-crud.md new file mode 100644 index 0000000..b382e27 --- /dev/null +++ b/docs/guides/entity-mapping-and-crud.md @@ -0,0 +1,218 @@ +# Entity mapping and CRUD + +How an entity type maps to a table and its columns, and the methods that insert, update and delete entities — one at a time or in bulk, with optimistic concurrency. + +## Entity Mapping + +You can configure how entity types are mapped to database tables and columns using either the fluent API or data +annotation attributes. + +> [!NOTE] +> Mapping configured via the fluent API takes precedence over mapping configured via data annotation attributes. +> When a fluent mapping exists for an entity type, the data annotations on this entity type are ignored. +> When a fluent mapping exists for an entity property, the data annotations on this property are ignored. + +### Fluent API +You can use the fluent API to configure how entity types are mapped to database tables and columns. + +```csharp +DbConnectionExtensions.Configure(config => +{ + config.Entity() + .ToTable("Products"); + + config.Entity() + .Property(a => a.Id) + .HasColumnName("ProductId") + .IsIdentity() + .IsKey(); + + config.Entity() + .Property(a => a.DiscountedPrice) + .IsComputed(); + + config.Entity() + .Property(a => a.IsOnSale) + .IsIgnored(); + + config.Entity() + .Property(a => a.Version) + .IsRowVersion(); + + config.Entity() + .Property(a => a.ConcurrencyToken) + .IsConcurrencyToken(); +}); +``` + +| Method | Configures | +|---|---| +| `Entity()` | Starts configuring the mapping for the entity type `TEntity`. | +| `ToTable(tableName)` | The table where entities of that type are stored. | +| `Property(propertyExpression)` | Starts configuring the mapping for one property. | +| `HasColumnName(columnName)` | The column where the property is stored. | +| `IsKey()` | The property is part of the key by which entities are identified. | +| `IsIdentity()` | The property is generated by the database on insert. | +| `IsComputed()` | The property is generated by the database on insert and update. | +| `IsRowVersion()` | The property is a native database-generated concurrency token. | +| `IsConcurrencyToken()` | The property is an application-managed concurrency token. | +| `IsIgnored()` | The property is not mapped to a column. | + +### Data annotation attributes + +Entity mapping can also be configured with the standard attributes from +`System.ComponentModel.DataAnnotations` and `System.ComponentModel.DataAnnotations.Schema`: + +```csharp +[Table("Products")] // Table name; defaults to the type name +class Product +{ + [Key] // Identifies the entity (usually the primary key) + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long Id { get; set; } + + [Column("ProductName")] // Column name; defaults to the property name + public string Name { get; set; } + + [Timestamp] // Native database-generated concurrency token + public byte[] Version { get; set; } + + [ConcurrencyCheck] // Application-managed concurrency token + public byte[] ConcurrencyToken { get; set; } + + [NotMapped] // Never read from or written to the database + public decimal TotalPrice => this.UnitPrice * this.Quantity; +} +``` + +| Attribute | Effect | +|---|---| +| `TableAttribute` | The table where entities of the type are stored. Without it, the entity type's name (excluding its namespace) is used. | +| `ColumnAttribute` | The column where the property is stored. Without it, the property name is used. | +| `KeyAttribute` | The property (or properties) by which entities of the type are identified. | +| `DatabaseGeneratedAttribute` | The property is generated by the database. Unless `DatabaseGeneratedOption.None` is used it is skipped when inserting and updating, and its value is read back from the database onto the entity afterwards. | +| `TimestampAttribute` | The property is a native database-generated concurrency token: it is checked during update and delete, which fail if the database value no longer matches the original, and it is read back after insert and update. | +| `ConcurrencyCheckAttribute` | The property is an application-managed concurrency token, checked during update and delete the same way. | +| `NotMappedAttribute` | The property is ignored entirely - never read from and never written to the database. | + +## Entity manipulation methods + +The examples below use these entity types: + +```csharp +class Product +{ + [Key] + public long Id { get; set; } + public long SupplierId { get; set; } + public string Name { get; set; } + public decimal UnitPrice { get; set; } + public int UnitsInStock { get; set; } + public bool IsDiscontinued { get; set; } +} + +enum UserState { Active, Inactive, Suspended } + +class User +{ + [Key] + public long Id { get; set; } + public DateTime LastLoginDate { get; set; } + public UserState State { get; set; } +} +``` + +### InsertEntities / InsertEntitiesAsync +Inserts a sequence of new entities into a database table. +```csharp +connection.InsertEntities(GetNewProducts()); +``` + +### InsertEntity / InsertEntityAsync +Inserts a new entity into a database table. +```csharp +connection.InsertEntity(GetNewProduct()); +``` + +### UpdateEntities / UpdateEntitiesAsync +Updates existing entities in a database table based on their keys. +```csharp +var usersWithoutLoginInPastYear = connection.Query( + """ + SELECT * + FROM Users + WHERE LastLoginDate < DATEADD(YEAR, -1, GETUTCDATE()) + """ +); + +foreach (var user in usersWithoutLoginInPastYear) +{ + user.State = UserState.Inactive; +} + +connection.UpdateEntities(usersWithoutLoginInPastYear); +``` + +### UpdateEntity / UpdateEntityAsync +Updates an existing entity in a database table based on its key. +```csharp +if (user.LastLoginDate < DateTime.UtcNow.AddYears(-1)) +{ + user.State = UserState.Inactive; + connection.UpdateEntity(user); +} +``` + +### DeleteEntities / DeleteEntitiesAsync +Deletes a sequence of entities from a database table based on their keys. +```csharp +connection.DeleteEntities(products.Where(a => a.IsDiscontinued)); +``` + +### DeleteEntity / DeleteEntityAsync +Deletes an entity from a database table based on its key. +```csharp +if (product.IsDiscontinued) +{ + connection.DeleteEntity(product); +} +``` + +## Enum support +Enum values are sent to the database either as their string representation or as integers, controlled by +[EnumSerializationMode](configuration.md#enumserializationmode). Reading maps both representations back to the enum value +automatically. + +```csharp +enum UserRole +{ + Admin = 1, + User = 2, + Guest = 3 +} + +class User +{ + [Key] + public long Id { get; set; } + public string UserName { get; set; } + public UserRole Role { get; set; } +} + +var user = new User { Id = 1, UserName = "adminuser", Role = UserRole.User }; + +connection.InsertEntity(user); +// Column "Role" contains the string "User" with EnumSerializationMode.Strings (the default), +// and the integer 2 with EnumSerializationMode.Integers. +``` + +The column type has to match the mode - `NVARCHAR(200)` for `Strings`, `INT` for `Integers`: + +```sql +CREATE TABLE Users +( + Id BIGINT, + UserName NVARCHAR(255), + Role NVARCHAR(200) -- INT when EnumSerializationMode.Integers is used +) +``` diff --git a/docs/guides/native-aot.md b/docs/guides/native-aot.md new file mode 100644 index 0000000..e59cb06 --- /dev/null +++ b/docs/guides/native-aot.md @@ -0,0 +1,77 @@ +# Native AOT and trimming + +**Reference the package and publish. There is nothing to install and nothing to opt into** - no companion +package, no source generator, no attribute, no registration call. Everything below works in an application +published with `PublishAot` exactly as it does on the just-in-time compiler. + +DbConnectionPlus targets `net8.0` and `net10.0`. `net8.0` is the supported floor; **`net10.0` is recommended** +for AOT, because from `net9.0` on the trim and AOT analyzers recognise `RuntimeFeature.IsDynamicCodeSupported` +as a feature guard and stop reporting code your own guard has already made unreachable. Either way this +library's own publish is warning-free on both — see [What you will see in your own +build](#what-you-will-see-in-your-own-build). + +## What works + +| Feature | Native AOT | +|---|---| +| `ExecuteNonQuery`, `ExecuteReader`, `Exists` | ✅ | +| `ExecuteScalar` and scalar `Query` | ✅ | +| `Query` for entities - property setters *and* constructor injection (records, immutable entities) | ✅ | +| `Query` for value tuples, including tuples with more than seven fields | ✅ | +| Non-generic `Query` / `QueryFirst` / … returning `DataRow`, read with `row["Id"]` | ✅ | +| `InsertEntity`, `UpdateEntity`, `DeleteEntity` and their bulk counterparts | ✅ | +| `TemporaryTable(...)` for scalar values and for complex objects | ✅ | +| Fluent-API mapping, `[Column]`/`[Key]` attributes, `EnumSerializationMode` | ✅ | +| `dynamic row.Id` member access on a `DataRow` | ❌ - use the `row["Id"]` indexer instead | + +Mapping is somewhat slower under Native AOT, because reflection replaces the compiled expression tree. In the +[benchmark suite](../reference/performance.md) - an in-memory SQLite database, the worst case, because statement execution is +almost free there and nothing dilutes the mapping cost - querying entities takes **~1.31x** as long end to end +and querying value tuples **~1.35x**. Part of that is the ahead-of-time runtime rather than this library: the +raw `DbCommand` baseline in the same run slows by 1.08x and 1.12x respectively. Against a real database server, +where the query itself dominates, the difference is correspondingly smaller. + +## Reading rows without a type: `row["Id"]`, not `row.Id` + +The non-generic query methods return `DataRow`. The string indexer is the AOT-safe way to read a column and is +what the examples in this README use: + +```csharp +var product = connection.QueryFirst($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); +var name = product["Name"]; +``` + +Member access through a `dynamic` reference still works wherever the runtime supports dynamic code generation, +but **not** under Native AOT - the Dynamic Language Runtime cannot bind without generating code. `DataRow` +itself is AOT-safe either way, and costs you nothing if you never write `dynamic`; the incompatibility is +reported by the compiler at your own call site. See [Query methods](querying.md#query-methods) for the full comparison. + +## Supported providers + +End-to-end AOT support is also bounded by your ADO.NET provider, which this library cannot fix: + +| Database | Provider | Native AOT | +|---|---|---| +| SQLite | `Microsoft.Data.Sqlite` | ✅ Verified trim-clean, and the provider this library's own AOT smoke test runs against | +| MySQL | `MySqlConnector` | ✅ Fully managed and trim-friendly | +| SQL Server | `Microsoft.Data.SqlClient` | ✅ Publishes and runs clean. | +| PostgreSQL | `Npgsql` | ⚠️ Core is AOT-capable; some type plug-ins reflect | +| Oracle | `Oracle.ManagedDataAccess.Core` | ❌ Not AOT-ready. This is a limitation of the provider | + +## What you will see in your own build + +**No warnings.** Publishing with `PublishAot` or `PublishTrimmed` reports no `IL2xxx` and no `IL3xxx` diagnostic +for any scenario in the table above, on either target framework. + +The public API carries no `[RequiresUnreferencedCode]` and no `[RequiresDynamicCode]`, so nothing is reported at +your call sites. The three underlying reflection sites are answered inside the library, where they occur: + +| Site | How it is answered | +|---|---| +| Specializing the value converter over the column's type | The generic method declares no `[DynamicallyAccessedMembers]`, so a runtime specialization has no requirements trimming could fail to preserve | +| Compiling the expression tree | `[RequiresDynamicCode]` stays on the expression-tree materializer, and its only caller reaches it from inside a `RuntimeFeature.IsDynamicCodeSupported` branch that the AOT compiler removes | +| Finding the constructor of a nested value tuple | An `ILLink.Descriptors.xml` embedded in the package preserves `System.ValueTuple\`1`-`\`8`, so the constructors survive trimming | + +This is verified rather than asserted: the repository publishes a Native AOT smoke test on `net8.0` and +`net10.0` and gates on **zero** IL diagnostics plus every asserted value coming back correctly, including +nested value tuples and enum fields inside them. diff --git a/docs/guides/parameters-and-temporary-tables.md b/docs/guides/parameters-and-temporary-tables.md new file mode 100644 index 0000000..8912d63 --- /dev/null +++ b/docs/guides/parameters-and-temporary-tables.md @@ -0,0 +1,179 @@ +# Parameters and temporary tables + +Two helpers turn an ordinary interpolated string into a parameterized statement: `Parameter(value)` binds a value, and `TemporaryTable(values)` puts a whole collection into the database for the statement to join against. Both are usable with any method that accepts an `InterpolatedSqlStatement`. + +## Parameters via interpolated strings +All extension methods accept interpolated strings where parameter values are captured via +[Parameter(value)](#parametervalue): + +```csharp +var lowStockThreshold = configuration.Thresholds.LowStock; + +var lowStockProductInfos = connection.Query<(long ProductId, int UnitsInStock)>( + $""" + SELECT Id, UnitsInStock + FROM Product + WHERE UnitsInStock < {Parameter(lowStockThreshold)} + """ +); +``` + +This prevents SQL injection and keeps the SQL readable. + +## On-the-fly temporary tables via interpolated strings +> [!CAUTION] +> **Warning for Oracle users** +> This feature creates private temporary tables and drops them after use. In Oracle, DDL statements cause an +> implicit commit of the current transaction — so inside an explicit transaction it is committed **twice**: +> once when the temporary table is created and once when it is dropped. +> For that reason the feature is **disabled by default** for Oracle and using it throws. To enable it anyway, +> set `RentADeveloper.DbConnectionPlus.DatabaseAdapters.Oracle.OracleDatabaseAdapter.AllowTemporaryTables` to +> `true` — and avoid the feature inside explicit transactions. + +> [!NOTE] +> **Note for MySQL users** +> Temporary tables are populated with `MySqlBulkCopy`, so the connection string needs +> `AllowLoadLocalInfile=true` and the server needs `local_infile` enabled (e.g. `SET GLOBAL local_infile=1`). + +Create a temporary table on the fly from an `IEnumerable` and use it in statements via +[TemporaryTable(values)](#temporarytablevalues): + +```csharp +var retiredSupplierIds = suppliers.Where(a => a.IsRetired).Select(a => a.Id); + +var retiredSupplierProducts = connection.Query( + $""" + SELECT * + FROM Product + WHERE SupplierId IN ( + SELECT Value + FROM {TemporaryTable(retiredSupplierIds)} + ) + """ +); +``` + +Complex objects are also supported - the library creates a temporary table with appropriate columns and types: + +```csharp +class OrderItem +{ + public long ProductId { get; set; } + public DateTime OrderDate { get; set; } +} + +var orderItems = GetOrderItems(); +var sixMonthsAgo = DateTime.UtcNow.AddMonths(-6); + +var productsOrderedInPastSixMonths = connection.Query( + $""" + SELECT * + FROM Product + WHERE EXISTS ( + SELECT 1 + FROM {TemporaryTable(orderItems)} TOrderItem + WHERE TOrderItem.ProductId = Product.Id AND + TOrderItem.OrderDate >= {Parameter(sixMonthsAgo)} + ) + """ +); +``` + +## Special helpers + +The following special helpers can be used with any DbConnectionPlus extension method that accepts an instance of +`InterpolatedSqlStatement`. + +### Parameter(value) +Use `Parameter(value)` to pass a value in an interpolated string as a parameter to an SQL statement. + +```csharp +var lowStockThreshold = configuration.Thresholds.LowStock; + +using var lowStockProductsReader = connection.ExecuteReader( + $""" + SELECT * + FROM Product + WHERE UnitsInStock < {Parameter(lowStockThreshold)} + """ +); +``` +This adds a parameter holding the value of `lowStockThreshold` to the SQL statement, and replaces the +`{Parameter(value)}` expression with the parameter's name. + +The parameter name is inferred from the expression passed to `Parameter(value)` - here `LowStockThreshold`. If +no name can be inferred (e.g. `Parameter(42)`), a generic name like `Parameter_1`, `Parameter_2` and so on is +used. + +Enum values are serialized as strings or as integers according to +[EnumSerializationMode](configuration.md#enumserializationmode). + +### TemporaryTable(values) +Use `TemporaryTable(values)` to pass a sequence of scalar values or complex objects in an interpolated string as a +temporary table to an SQL statement. + +A sequence of scalar values (e.g. `string`, `int`, `DateTime`, enums and so on) produces a temporary table +with a single column named `Value`, typed to match the passed values: + +```csharp +var retiredSupplierIds = suppliers.Where(a => a.IsRetired).Select(a => a.Id); + +using var retiredSupplierProductsReader = connection.ExecuteReader( + $""" + SELECT * + FROM Product + WHERE SupplierId IN ( + SELECT Value + FROM {TemporaryTable(retiredSupplierIds)} + ) + """ +); +``` +```sql +CREATE TABLE #RetiredSupplierIds_48d42afd5d824a27bd9352676ab6c198 +( + Value BIGINT +) +``` + +A sequence of complex objects produces one column per public property, named and typed after that property: + +```csharp +class OrderItem +{ + public long ProductId { get; set; } + public DateTime OrderDate { get; set; } +} + +var orderItems = GetOrderItems(); +var sixMonthsAgo = DateTime.UtcNow.AddMonths(-6); + +using var productsOrderedInPastSixMonthsReader = connection.ExecuteReader( + $""" + SELECT * + FROM Product + WHERE EXISTS ( + SELECT 1 + FROM {TemporaryTable(orderItems)} TOrderItem + WHERE TOrderItem.ProductId = Product.Id AND + TOrderItem.OrderDate >= {Parameter(sixMonthsAgo)} + ) + """ +); +``` +```sql +CREATE TABLE #OrderItems_d6545835d97148ab93709efe9ba1f110 +( + ProductId BIGINT, + OrderDate DATETIME2 +) +``` + +The table name is inferred from the expression passed to `TemporaryTable(values)` and suffixed with a new Guid +to avoid naming conflicts (e.g. `OrderItems_395c98f203514e81aa0098ec7f13e8a2`); if no name can be inferred, +`Values` is used instead. The `{TemporaryTable(values)}` expression is replaced with that name in the SQL +statement. + +Enum values - passed directly or as properties of complex objects - are serialized according to +[EnumSerializationMode](configuration.md#enumserializationmode), and the column is typed `NVARCHAR(200)` for `Strings` and +`INT` for `Integers`. diff --git a/docs/guides/querying.md b/docs/guides/querying.md new file mode 100644 index 0000000..dd361b4 --- /dev/null +++ b/docs/guides/querying.md @@ -0,0 +1,241 @@ +# Querying + +Every method here is an extension method on `DbConnection`, every one has an `…Async` counterpart, and all of them accept an optional transaction, command timeout, command type and cancellation token. + +The examples assume the static helpers are imported: + +```csharp +using static RentADeveloper.DbConnectionPlus.DbConnectionExtensions; +``` + +## General-purpose methods + +### ExecuteNonQuery / ExecuteNonQueryAsync +Executes an SQL statement and returns the number of rows affected by the statement. + +```csharp +if (supplier.IsRetired) +{ + var numberOfDeletedProducts = connection.ExecuteNonQuery( + $""" + DELETE FROM Product + WHERE SupplierId = {Parameter(supplier.Id)} + """ + ); +} +``` + +### ExecuteReader / ExecuteReaderAsync +Executes an SQL statement and returns a `DbDataReader` to read the results. + +```csharp +var lowStockThreshold = configuration.Thresholds.LowStock; + +using var lowStockProductsReader = connection.ExecuteReader( + $""" + SELECT * + FROM Product + WHERE UnitsInStock < {Parameter(lowStockThreshold)} + """ +); +``` + +### ExecuteScalar / ExecuteScalarAsync +Executes an SQL statement and returns the value of the first column of the first row in the result set converted to +the specified type. +```csharp +var lowStockThreshold = configuration.Thresholds.LowStock; + +var numberOfLowStockProducts = connection.ExecuteScalar( + $""" + SELECT COUNT(*) + FROM Product + WHERE UnitsInStock < {Parameter(lowStockThreshold)} + """ +); +``` + +### Exists / ExistsAsync +Checks if any rows exist that match the specified SQL statement. +```csharp +var lowStockThreshold = configuration.Thresholds.LowStock; + +var existLowStockProducts = connection.Exists( + $""" + SELECT 1 + FROM Product + WHERE UnitsInStock < {Parameter(lowStockThreshold)} + """ +); +``` + +## Query methods + +The non-generic query methods below return `DataRow` instances. There are two ways to read a column, and which +one you should use depends on how your application is published: + +| | Access | Works on | +|---|---|---| +| **Recommended** | `product["Id"]` — string indexer, no cast | every runtime, **including Native AOT** | +| Optional | `product.Id` — member access through a `dynamic` reference | runtimes with dynamic code generation (**not** Native AOT) | + +The examples in this section use the string indexer. To use member access instead, assign the row to a `dynamic` +reference — the static return type is `DataRow`, not `dynamic`, so the step is explicit: + +```csharp +dynamic product = connection.QueryFirst($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); +var name = product.Name; + +foreach (dynamic p in connection.Query($"SELECT * FROM Product")) +{ + var unitsInStock = p.UnitsInStock; +} +``` + +Member access behaves exactly like the indexer, including throwing `KeyNotFoundException` for a column the row +does not contain. Note that through a `dynamic` reference a *property* always addresses a column — `row.Count` +reads the column named `Count`, not the number of columns — while *method* calls still resolve against `DataRow`, +so `row.ContainsKey("Id")` works as expected. Use a statically typed `DataRow` reference to reach `Count`, `Keys` +and `Values`. + +If you publish with Native AOT, use the indexer: the C# compiler reports `dynamic` usage as an AOT +incompatibility at your own call site, and the Dynamic Language Runtime cannot bind it without run-time code +generation. `DataRow` itself is AOT-safe to construct and use either way. See +[Native AOT and trimming](native-aot.md). + +### Query / QueryAsync +Executes an SQL statement and maps the result set to a sequence of `DataRow` instances. Access columns by name +through the string indexer. +```csharp +var lowStockThreshold = configuration.Thresholds.LowStock; + +var lowStockProducts = connection.Query( + $""" + SELECT * + FROM Product + WHERE UnitsInStock < {Parameter(lowStockThreshold)} + """ +); + +foreach (var product in lowStockProducts) +{ + var id = product["Id"]; + var unitsInStock = product["UnitsInStock"]; + ... +} +``` + +### QueryFirst / QueryFirstAsync +Executes an SQL statement and maps the first row of the result set to a `DataRow`. +Throws if no rows are found. +```csharp +var product = connection.QueryFirst($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); + +var id = product["Id"]; +var name = product["Name"]; +... +``` + +### QueryFirstOrDefault / QueryFirstOrDefaultAsync +Executes an SQL statement and maps the first row of the result set to a `DataRow` or null if no rows are found. +```csharp +var product = connection.QueryFirstOrDefault($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); + +if (product is not null) +{ + var id = product["Id"]; + var name = product["Name"]; + ... +} +``` + +### QuerySingle / QuerySingleAsync +Executes an SQL statement and maps the single row of the result set to a `DataRow`. +Throws if no rows or more than one row are found. +```csharp +var product = connection.QuerySingle($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); + +var id = product["Id"]; +var name = product["Name"]; +... +``` + +### QuerySingleOrDefault / QuerySingleOrDefaultAsync +Executes an SQL statement and maps the single row of the result set to a `DataRow` or null if no rows are found. +Throws if more than one row are found. +```csharp +var product = connection.QuerySingleOrDefault($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); + +if (product is not null) +{ + var id = product["Id"]; + var name = product["Name"]; + ... +} +``` + +### Query\ / QueryAsync\ +Executes an SQL statement and maps the result set to a sequence of scalar values, entities or value tuples of the +specified type. + +```csharp +var lowStockThreshold = configuration.Thresholds.LowStock; + +// Entities +var lowStockProducts = connection.Query( + $""" + SELECT * + FROM Product + WHERE UnitsInStock < {Parameter(lowStockThreshold)} + """ +); + +// Scalar values +var lowStockProductIds = connection.Query( + $""" + SELECT Id + FROM Product + WHERE UnitsInStock < {Parameter(lowStockThreshold)} + """ +); + +// Value tuples +var lowStockProductInfos = connection.Query<(long ProductId, int UnitsInStock)>( + $""" + SELECT Id, UnitsInStock + FROM Product + WHERE UnitsInStock < {Parameter(lowStockThreshold)} + """ +); +``` + +### QueryFirst\ / QueryFirstAsync\ +Executes an SQL statement and maps the first row of the result set to a scalar value, entity or value tuple of the +specified type. +Throws if no rows are found. +```csharp +var product = connection.QueryFirst($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); +``` + +### QueryFirstOrDefault\ / QueryFirstOrDefaultAsync\ +Executes an SQL statement and maps the first row of the result set to a scalar value, entity or value tuple of the +specified type or default value if no rows are found. +```csharp +var product = connection.QueryFirstOrDefault($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); +``` + +### QuerySingle\ / QuerySingleAsync\ +Executes an SQL statement and maps the single row of the result set to a scalar value, entity or value tuple of the +specified type. +Throws if no rows or more than one row are found. +```csharp +var product = connection.QuerySingle($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); +``` + +### QuerySingleOrDefault\ / QuerySingleOrDefaultAsync\ +Executes an SQL statement and maps the single row of the result set to a scalar value, entity or value tuple of the +specified type or default value if no rows are found. +Throws if more than one row are found. +```csharp +var product = connection.QuerySingleOrDefault($"SELECT * FROM Product WHERE Id = {Parameter(id)}"); +``` diff --git a/docs/index.md b/docs/index.md index 8d080a8..b731427 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,12 +4,34 @@ A lightweight .NET ORM and extension library for `System.Data.Common.DbConnectio high-performance helpers — `Query`, `InsertEntity`, `UpdateEntities`, temporary tables and more — as extension methods on `DbConnection`, with per-database dialect support supplied by pluggable adapters. -These pages are the generated **API reference** for all six packages. The narrative documentation — getting -started, the full feature reference and the design record — lives in the repository: +Start at the [repository README](https://github.com/rent-a-developer/DbConnectionPlus#readme) for installation +and a quick start. These pages are the full documentation. -- [README](https://github.com/rent-a-developer/DbConnectionPlus#readme) — the reference documentation. -- [CHANGELOG](https://github.com/rent-a-developer/DbConnectionPlus/blob/main/CHANGELOG.md) — what changed, per release. -- [DESIGN-DECISIONS](https://github.com/rent-a-developer/DbConnectionPlus/blob/main/DESIGN-DECISIONS.md) — why it works the way it does. +## Guides + +| Guide | What it covers | +|---|---| +| [Querying](guides/querying.md) | `ExecuteNonQuery`, `ExecuteReader`, `ExecuteScalar`, `Exists`, and every `Query` overload — entities, scalars, value tuples and untyped rows | +| [Parameters and temporary tables](guides/parameters-and-temporary-tables.md) | `Parameter(value)` and `TemporaryTable(values)`, and the per-database caveats | +| [Entity mapping and CRUD](guides/entity-mapping-and-crud.md) | Attributes and the fluent API, insert/update/delete, optimistic concurrency, enums | +| [Configuration](guides/configuration.md) | `Configure`, `EnumSerializationMode`, `InterceptDbCommand` | +| [Custom database adapters](guides/custom-adapters.md) | Supporting a database that has no adapter package | +| [Native AOT and trimming](guides/native-aot.md) | What works, which providers are AOT-ready, and what you will see in your own build | + +## Reference + +| Page | What it covers | +|---|---| +| [API summary](reference/api-summary.md) | Every public entry point, one line each, linked to the generated reference | +| [Comparison with Dapper](reference/comparison.md) | Where the two differ, and where Dapper is still the better pick | +| [Performance](reference/performance.md) | The benchmark results, and how they were measured | +| [Design decisions](DESIGN-DECISIONS.md) | Why the library works the way it does | + +## API reference + +The generated reference for all six packages starts at +[`DbConnectionExtensions`](xref:RentADeveloper.DbConnectionPlus.DbConnectionExtensions) — the entry point for +nearly every operation. ## The packages @@ -30,10 +52,9 @@ DbConnectionExtensions.Configure(configuration => configuration.UseSqlServer()); All six are versioned and released together, so a given release's adapter always matches its core package. -## Native AOT - -The reflection paths are AOT-safe: no companion package, no source generator, no consumer opt-in. Publishing -an application with `PublishAot` or `PublishTrimmed` reports no `IL2xxx` or `IL3xxx` diagnostics for any -supported scenario, on `net8.0` and `net10.0` alike. +## Project -Start at [`DbConnectionExtensions`](xref:RentADeveloper.DbConnectionPlus.DbConnectionExtensions) — it is the entry point for nearly every operation. +- [Change log](../CHANGELOG.md) +- [Contributing](../CONTRIBUTING.md) +- [Code of conduct](../CODE_OF_CONDUCT.md) +- [Security policy](../SECURITY.md) diff --git a/docs/reference/api-summary.md b/docs/reference/api-summary.md new file mode 100644 index 0000000..5a9242b --- /dev/null +++ b/docs/reference/api-summary.md @@ -0,0 +1,87 @@ +# API summary + +Every public entry point, one line each. The generated reference has the signatures, the parameters and the +exceptions; the guides have the worked examples. + +Everything below is an extension method on `System.Data.Common.DbConnection`, unless it is listed under +Configuration. Every method has an `…Async` counterpart, and all of them accept an optional transaction, +command timeout, command type and cancellation token. + +## Configuration + +See [the configuration guide](../guides/configuration.md). + +| Member | What it does | +|---|---| +| `DbConnectionExtensions.Configure` | Configures the library. Callable **once**, at application startup | +| `EnumSerializationMode` | Whether enum values are sent as strings (the default) or as integers | +| `InterceptDbCommand` | A delegate that sees every `DbCommand` this library builds, before it executes | +| `RegisterDatabaseAdapter` | Registers an adapter for a connection type — what `UseSqlServer()` and friends call | + +## Entity mapping + +See [entity mapping and CRUD](../guides/entity-mapping-and-crud.md). + +| Member | What it does | +|---|---| +| `Entity()` | Starts configuring an entity type fluently | +| `ToTable`, `Property`, `HasColumnName` | Table and column names | +| `IsKey`, `IsIdentity`, `IsComputed`, `IsRowVersion`, `IsConcurrencyToken`, `IsIgnored` | Per-property mapping | +| `[Table]`, `[Column]`, `[Key]`, `[DatabaseGenerated]`, `[Timestamp]`, `[ConcurrencyCheck]`, `[NotMapped]` | The same, through the standard data annotations | + +## General-purpose methods + +See [querying](../guides/querying.md). + +| Method | What it does | +|---|---| +| `ExecuteNonQuery` | Executes a statement and returns the number of rows affected | +| `ExecuteReader` | Executes a statement and returns a `DbDataReader`. **Dispose it** — that is what drops any temporary tables | +| `ExecuteScalar` | Reads the first column of the first row, converted to `T` | +| `Exists` | Whether any row matches | + +## Query methods + +See [querying](../guides/querying.md). + +| Method | Maps to | +|---|---| +| `Query` | a sequence of `DataRow` | +| `QueryFirst` | the first row, as a `DataRow`. Throws if there is none | +| `QueryFirstOrDefault` | the first row, or `null` | +| `QuerySingle` | the single row. Throws if there is none, or more than one | +| `QuerySingleOrDefault` | the single row, or `null`. Throws if there is more than one | +| `Query` | a sequence of scalars, entities or value tuples | +| `QueryFirst` | the first row. Throws if there is none | +| `QueryFirstOrDefault` | the first row, or `default` | +| `QuerySingle` | the single row. Throws if there is none, or more than one | +| `QuerySingleOrDefault` | the single row, or `default`. Throws if there is more than one | + +## Entity manipulation methods + +See [entity mapping and CRUD](../guides/entity-mapping-and-crud.md). + +| Method | What it does | +|---|---| +| `InsertEntity` / `InsertEntities` | Inserts one entity, or a sequence. Database-generated values are read back | +| `UpdateEntity` / `UpdateEntities` | Updates by key. Throws `DbUpdateConcurrencyException` if a concurrency token no longer matches | +| `DeleteEntity` / `DeleteEntities` | Deletes by key, with the same concurrency check | + +## Special helpers + +Usable in any interpolated statement. See +[parameters and temporary tables](../guides/parameters-and-temporary-tables.md). + +| Helper | What it does | +|---|---| +| `Parameter(value)` | Adds the value as a real `DbParameter` and writes its name into the SQL | +| `TemporaryTable(values)` | Creates a temporary table from a sequence, bulk-loads it, writes its name into the SQL, and drops it afterwards | + +## Types you will see + +| Type | What it is | +|---|---| +| `DataRow` | An untyped row. Read columns with `row["Name"]`; `dynamic` member access works everywhere except Native AOT | +| `InterpolatedSqlStatement` | What an interpolated string becomes. A plain `string` converts implicitly | +| `DbUpdateConcurrencyException` | Thrown when an update or delete matched no row because a concurrency token changed. Carries the offending `Entity` | +| `IDatabaseAdapter`, `IEntityManipulator`, `ITemporaryTableBuilder` | The three seams a [custom adapter](../guides/custom-adapters.md) implements | diff --git a/docs/reference/comparison.md b/docs/reference/comparison.md new file mode 100644 index 0000000..d13a057 --- /dev/null +++ b/docs/reference/comparison.md @@ -0,0 +1,179 @@ +# Comparison with Dapper + +This library follows the same philosophy as Dapper: extension methods on `DbConnection`, your own SQL, no change tracking and no LINQ provider, but with the following differences and additions: + +- Querying, CRUD and AOT support in one package. For dapper you need Dapper, Dapper.Contrib and Dapper.AOT. +- Pass parameters directly inside interpolated strings. SQL injection safe. +- Pass collections as on-the-fly temporary tables directly inside interpolated strings. Also SQL injection safe. +- Optimistic concurrency out-of-the-box. +- Full native AOT compatibility (including support for ValueTuples, which Dapper.AOT lacks). +- Customizable Enum serialization (string/integer). +- Great performance. + +## Your values stay inside your query + +```csharp +// Dapper +connection.Query( + "SELECT * FROM Product WHERE SupplierId = @SupplierId AND UnitsInStock < @Threshold", + new { SupplierId = supplierId, Threshold = threshold } +); + +// DbConnectionPlus +connection.Query( + $"SELECT * FROM Product WHERE SupplierId = {Parameter(supplierId)} AND UnitsInStock < {Parameter(threshold)}" +); +``` + +- No anonymous object to keep in sync with the SQL +- No renamed variable that compiles and then fails at run time +- Full auto-complete +- Full compiler verification + +Each value is written where it is used, and still reaches the database as a real `DbParameter`. Nothing to name +twice, no anonymous object to keep in sync with the SQL, and no renamed variable that compiles happily and blows +up at run time. + +## Throw a whole collection at the database + +```csharp +var retiredSupplierIds = suppliers.Where(a => a.IsRetired).Select(a => a.Id); + +var affectedProducts = connection.Query( + $""" + SELECT * + FROM Product + WHERE SupplierId IN (SELECT Value FROM {TemporaryTable(retiredSupplierIds)}) + """ +); +``` + +DbConnectionPlus creates a temporary table, bulk-loads your collection into it (`SqlBulkCopy` and the equivalent +for every other supported database) and drops it when the statement is done. Ten values or a million: no `IN` list +to build, no parameter limit to stay under, one query plan. + +Objects work just as well - each property becomes a column - so you can `JOIN` straight against in-memory data: + +```csharp +var orderedProducts = connection.Query<(long ProductId, int Quantity, decimal UnitPrice)>( + $""" + SELECT TOrderItem.ProductId, TOrderItem.Quantity, Product.UnitPrice + FROM Product + JOIN {TemporaryTable(orderItems)} TOrderItem ON TOrderItem.ProductId = Product.Id + """ +); +``` + +Dapper expands `IN @ids` into one parameter per element instead - a fresh query plan for every list length, and a +hard ceiling at the provider's parameter limit (2,100 on SQL Server). Table-valued parameters lift that ceiling, +but only on SQL Server, and only after you have created a table type in the database and hand-built a `DataTable`. + +## Full optimistic concurrence support + +```csharp +class Product +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long Id { get; set; } + + [Timestamp] + public byte[] Version { get; set; } + + public decimal UnitPrice { get; set; } +} + +connection.InsertEntity(newProduct); // Id and Version come back filled in +connection.UpdateEntity(product); // throws if someone else changed the row first +connection.DeleteEntities(discontinuedProducts); +``` + +Insert, update and delete - one entity or a whole sequence, synchronous or `…Async` - and **every one of them +supports optimistic concurrency**. Mark a property with `[Timestamp]` or `[ConcurrencyCheck]` and its original +value joins the `WHERE` clause, so an update or delete against a row that changed in the meantime raises +`DbUpdateConcurrencyException` instead of quietly matching nothing. Database-generated values are read back onto +your entity, and composite keys are supported. + +Dapper has no CRUD at all - you write every `INSERT`, `UPDATE` and `DELETE` by hand. Dapper.Contrib is the usual +answer, and it does add `Insert` / `Update` / `Delete` - but its `UPDATE` matches on the key alone, so **there is +no optimistic concurrency anywhere in the Dapper family**. It also cannot handle composite keys, has no Oracle +support, and has not shipped a release since 2020. + +## The attributes you already use + +```csharp +[Table("Products")] +class Product +{ + [Key] public long Id { get; set; } + [Column("ProductName")] public string Name { get; set; } + [NotMapped] public decimal TotalPrice => this.UnitPrice * this.Quantity; +} +``` + +Plain `System.ComponentModel.DataAnnotations` - the same attributes EF Core reads, so your entities usually need +no changes at all. Prefer to keep your domain model clean? A fluent API configures the same things from one place. +Dapper.Contrib brings its own smaller attribute set and cannot even map a column name. + +## Native AOT support with no extra steps + +```xml +true +``` + +That is the entire setup. No companion package, no source generator, no attribute on anything, and no `IL2xxx` or +`IL3xxx` warning in your build. Everything keeps working: entities, records and other immutable types, **value +tuples**, temporary tables, CRUD and enums. + +Dapper cannot run under Native AOT at all; you need the separate Dapper.AOT package and a `[DapperAot]` opt-in on +every method, type or module you want covered. Even then it does not support value tuples, and it does not cover +Dapper.Contrib's CRUD - so those simply have no Dapper answer under Native AOT. Worse, a call site Dapper.AOT +cannot handle is left as ordinary Dapper without any warning: the build stays green and the failure shows up in +your published binary. + +## Enums, your way, in one line + +```csharp +DbConnectionExtensions.Configure(config => config.EnumSerializationMode = EnumSerializationMode.Strings); +``` + +Readable strings or compact integers - the choice applies everywhere at once: entity properties, parameters and +temporary tables. Reading understands both, whichever your columns happen to hold. Dapper writes the underlying +number unless you write and register a type handler yourself - which Dapper.AOT's generated code then ignores. + +## Untyped rows without `dynamic` + +```csharp +var row = connection.QueryFirst($"SELECT * FROM Product WHERE Id = {Parameter(productId)}"); +var name = row["Name"]; +``` + +No cast, no `dynamic`, and it works under Native AOT - where Dapper's `dynamic` rows cannot be bound at all. If you +do want member access, one `dynamic` reference gives you `row.Name`; it is an option here rather than the only way +in. + +## See every command your app sends + +```csharp +DbConnectionExtensions.Configure(config => +{ + config.InterceptDbCommand = (command, _) => logger.LogDebug("SQL: {Sql}", command.CommandText); +}); +``` + +One hook, every statement DbConnectionPlus builds, right before it executes - for logging, a timeout, or a query +hint. Dapper has nothing comparable. + +## And it costs you nothing + +The [benchmark suite](performance.md) in this repository runs every feature three ways - hand-written `DbCommand`, +Dapper and DbConnectionPlus - against in-memory SQLite, the harshest possible setting because the query itself is +almost free there. The two libraries trade places from category to category, both within a small multiple of raw +ADO.NET, and DbConnectionPlus allocates less than Dapper in eleven of the seventeen categories. + +## When Dapper is still the better pick + +Being honest about it: Dapper has multi-mapping (`splitOn`) and `QueryMultiple` for several result sets from one +command, custom `ITypeHandler` conversions, support for .NET Framework and .NET Standard 2.0, and fifteen years of +ecosystem. DbConnectionPlus has none of the first three, requires `net8.0` or later, and needs a +[custom adapter](../guides/custom-adapters.md) for database systems beyond the [five it supports](https://github.com/rent-a-developer/DbConnectionPlus/blob/main/README.md#installation). diff --git a/docs/reference/performance.md b/docs/reference/performance.md new file mode 100644 index 0000000..b35603c --- /dev/null +++ b/docs/reference/performance.md @@ -0,0 +1,117 @@ +# Performance + +DbConnectionPlus is designed to have a minimal performance and allocation overhead compared to using `DbCommand` +manually. + +All benchmarks are performed using SQLite in-memory databases, which is a worst-case scenario for DbConnectionPlus +because the overhead of using DbConnectionPlus is more noticeable when the executed SQL statements are very fast. + +The entity-querying categories are additionally measured as a Native AOT compiled binary, because DbConnectionPlus +selects its materializer on `RuntimeFeature.IsDynamicCodeSupported` and the reflection path behind that switch is +the one a Native AOT consumer runs. Only `Query_Entities`, `Query_ValueTuples` and `TemporaryTable_ComplexObjects` +reach that branch; every other category runs identical code on both runtimes, so measuring it twice would only +compare RyuJIT with ILC. The table below is the JIT snapshot. See +[benchmarks/DbConnectionPlus.Benchmarks/README.md](https://github.com/rent-a-developer/DbConnectionPlus/blob/main/benchmarks/DbConnectionPlus.Benchmarks/README.md) for the +two-job summary and for which categories have a Dapper competitor under Native AOT at all. + +``` + +BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.9168/25H2/2025Update/HudsonValley2) +12th Gen Intel Core i9-12900K 3.19GHz, 1 CPU, 24 logical and 16 physical cores +.NET SDK 10.0.303 + [Host] : .NET 10.0.11 (10.0.11, 10.0.1126.37416), X64 RyuJIT x86-64-v3 + JIT : .NET 10.0.11 (10.0.11, 10.0.1126.37416), X64 RyuJIT x86-64-v3 + AOT : .NET 10.0.11, X64 NativeAOT x86-64-v3 + +Server=True InvocationCount=Default IterationTime=300ms +MaxIterationCount=20 UnrollFactor=16 WarmupCount=3 + +``` +| Method | Job | Toolchain | Mean | Error | StdDev | Ratio | RatioSD | Gen0 | Gen1 | Allocated | Alloc Ratio | +|----------------------------------------------- |---- |------------------ |-------------:|------------:|------------:|-------------:|--------:|--------:|-------:|----------:|------------:| +| **DeleteEntities_Command** | **JIT** | **Default** | **136.627 μs** | **1.2107 μs** | **1.1324 μs** | **baseline** | **** | **0.7813** | **-** | **68556 B** | **** | +| DeleteEntities_Dapper | JIT | Default | 168.392 μs | 1.8021 μs | 1.5975 μs | 1.23x slower | 0.02x | 1.2500 | - | 133269 B | 1.94x more | +| DeleteEntities_DbConnectionPlus | JIT | Default | 167.147 μs | 1.0246 μs | 0.9584 μs | 1.22x slower | 0.01x | 1.0417 | - | 116876 B | 1.70x more | +| | | | | | | | | | | | | +| **DeleteEntity_Command** | **JIT** | **Default** | **1.464 μs** | **0.0115 μs** | **0.0102 μs** | **baseline** | **** | **0.0078** | **-** | **769 B** | **** | +| DeleteEntity_Dapper | JIT | Default | 1.972 μs | 0.0148 μs | 0.0131 μs | 1.35x slower | 0.01x | 0.0263 | - | 1705 B | 2.22x more | +| DeleteEntity_DbConnectionPlus | JIT | Default | 1.749 μs | 0.0186 μs | 0.0165 μs | 1.19x slower | 0.01x | 0.0170 | - | 1249 B | 1.62x more | +| | | | | | | | | | | | | +| **ExecuteNonQuery_Command** | **JIT** | **Default** | **1.353 μs** | **0.0168 μs** | **0.0140 μs** | **baseline** | **** | **0.0133** | **-** | **768 B** | **** | +| ExecuteNonQuery_Dapper | JIT | Default | 1.551 μs | 0.0145 μs | 0.0129 μs | 1.15x slower | 0.01x | 0.0153 | - | 1072 B | 1.40x more | +| ExecuteNonQuery_DbConnectionPlus | JIT | Default | 1.697 μs | 0.0057 μs | 0.0048 μs | 1.25x slower | 0.01x | 0.0280 | - | 1608 B | 2.09x more | +| | | | | | | | | | | | | +| **ExecuteReader_Command** | **JIT** | **Default** | **281.423 μs** | **1.7947 μs** | **1.4987 μs** | **baseline** | **** | **6.3406** | **-** | **411084 B** | **** | +| ExecuteReader_Dapper | JIT | Default | 282.375 μs | 2.5789 μs | 2.4123 μs | 1.00x slower | 0.01x | 5.8140 | - | 411116 B | 1.00x more | +| ExecuteReader_DbConnectionPlus | JIT | Default | 280.036 μs | 2.4280 μs | 2.2711 μs | 1.01x faster | 0.01x | 6.0976 | - | 411724 B | 1.00x more | +| | | | | | | | | | | | | +| **ExecuteScalar_Command** | **JIT** | **Default** | **1.914 μs** | **0.0116 μs** | **0.0103 μs** | **baseline** | **** | **0.0188** | **-** | **1120 B** | **** | +| ExecuteScalar_Dapper | JIT | Default | 2.166 μs | 0.0142 μs | 0.0111 μs | 1.13x slower | 0.01x | 0.0215 | - | 1424 B | 1.27x more | +| ExecuteScalar_DbConnectionPlus | JIT | Default | 2.286 μs | 0.0171 μs | 0.0151 μs | 1.19x slower | 0.01x | 0.0307 | - | 2088 B | 1.86x more | +| | | | | | | | | | | | | +| **Exists_Command** | **JIT** | **Default** | **1.625 μs** | **0.0106 μs** | **0.0088 μs** | **baseline** | **** | **0.0161** | **-** | **1000 B** | **** | +| Exists_Dapper | JIT | Default | 1.847 μs | 0.0071 μs | 0.0060 μs | 1.14x slower | 0.01x | 0.0367 | - | 1336 B | 1.34x more | +| Exists_DbConnectionPlus | JIT | Default | 2.040 μs | 0.0238 μs | 0.0186 μs | 1.26x slower | 0.01x | 0.0338 | - | 1944 B | 1.94x more | +| | | | | | | | | | | | | +| **InsertEntities_Command** | **JIT** | **Default** | **1,084.779 μs** | **5.2549 μs** | **4.1027 μs** | **baseline** | **** | **18.7500** | **-** | **1129093 B** | **** | +| InsertEntities_Dapper | JIT | Default | 1,089.550 μs | 14.3120 μs | 12.6873 μs | 1.00x slower | 0.01x | 14.8148 | - | 1247818 B | 1.11x more | +| InsertEntities_DbConnectionPlus | JIT | Default | 1,184.102 μs | 7.7167 μs | 6.8406 μs | 1.09x slower | 0.01x | 19.5313 | - | 1139668 B | 1.01x more | +| | | | | | | | | | | | | +| **InsertEntity_Command** | **JIT** | **Default** | **8.668 μs** | **0.0370 μs** | **0.0309 μs** | **baseline** | **** | **0.1447** | **-** | **8480 B** | **** | +| InsertEntity_Dapper | JIT | Default | 14.258 μs | 0.0960 μs | 0.0851 μs | 1.64x slower | 0.01x | 0.2872 | - | 17608 B | 2.08x more | +| InsertEntity_DbConnectionPlus | JIT | Default | 9.003 μs | 0.0555 μs | 0.0492 μs | 1.04x slower | 0.01x | 0.1217 | - | 8024 B | 1.06x less | +| | | | | | | | | | | | | +| **Parameter_Command** | **JIT** | **Default** | **3.390 μs** | **0.0078 μs** | **0.0061 μs** | **baseline** | **** | **0.0452** | **-** | **2952 B** | **** | +| Parameter_Dapper | JIT | Default | 5.279 μs | 0.0373 μs | 0.0312 μs | 1.56x slower | 0.01x | 0.2117 | - | 5016 B | 1.70x more | +| Parameter_DbConnectionPlus | JIT | Default | 5.898 μs | 0.0504 μs | 0.0447 μs | 1.74x slower | 0.01x | 0.3523 | - | 7376 B | 2.50x more | +| | | | | | | | | | | | | +| **Query_Dynamic_Command** | **JIT** | **Default** | **302.573 μs** | **3.1295 μs** | **2.6133 μs** | **baseline** | **** | **15.1210** | **1.0081** | **532528 B** | **** | +| Query_Dynamic_Dapper | JIT | Default | 214.474 μs | 1.1667 μs | 1.0913 μs | 1.41x faster | 0.01x | 0.7267 | - | 73880 B | 7.21x less | +| Query_Dynamic_DbConnectionPlus | JIT | Default | 276.257 μs | 1.8022 μs | 1.5049 μs | 1.10x faster | 0.01x | 2.7174 | - | 131944 B | 4.04x less | +| | | | | | | | | | | | | +| **Query_Entities_Command** | **JIT** | **Default** | **281.692 μs** | **2.1036 μs** | **1.8648 μs** | **baseline** | **** | **7.1023** | **-** | **411084 B** | **** | +| Query_Entities_Dapper | JIT | Default | 234.856 μs | 0.8877 μs | 0.8303 μs | 1.20x faster | 0.01x | 0.7806 | - | 74105 B | 5.55x less | +| Query_Entities_DbConnectionPlus | JIT | Default | 245.137 μs | 0.8514 μs | 0.7547 μs | 1.15x faster | 0.01x | 0.8244 | - | 64025 B | 6.42x less | +| | | | | | | | | | | | | +| **Query_Entities_Command** | **AOT** | **Latest ILCompiler** | **305.151 μs** | **4.2016 μs** | **3.5085 μs** | **baseline** | **** | **7.0565** | **-** | **411091 B** | **** | +| Query_Entities_Dapper_Aot | AOT | Latest ILCompiler | 245.874 μs | 1.4414 μs | 1.2778 μs | 1.24x faster | 0.02x | 2.4351 | - | 60969 B | 6.74x less | +| Query_Entities_DbConnectionPlus | AOT | Latest ILCompiler | 321.910 μs | 3.0612 μs | 2.7137 μs | 1.06x slower | 0.01x | 1.0593 | - | 91244 B | 4.51x less | +| | | | | | | | | | | | | +| **Query_Scalars_Command** | **JIT** | **Default** | **81.212 μs** | **0.2861 μs** | **0.2389 μs** | **baseline** | **** | **0.2717** | **-** | **17288 B** | **** | +| Query_Scalars_Dapper | JIT | Default | 111.449 μs | 0.6049 μs | 0.5051 μs | 1.37x slower | 0.01x | 0.3720 | - | 36976 B | 2.14x more | +| Query_Scalars_DbConnectionPlus | JIT | Default | 109.890 μs | 0.4992 μs | 0.4670 μs | 1.35x slower | 0.01x | 0.3655 | - | 32480 B | 1.88x more | +| | | | | | | | | | | | | +| **Query_ValueTuples_Command** | **JIT** | **Default** | **98.529 μs** | **0.5243 μs** | **0.4905 μs** | **baseline** | **** | **0.6649** | **-** | **47801 B** | **** | +| Query_ValueTuples_Dapper | JIT | Default | 131.177 μs | 0.5286 μs | 0.4945 μs | 1.33x slower | 0.01x | 1.3193 | - | 71297 B | 1.49x more | +| Query_ValueTuples_DbConnectionPlus | JIT | Default | 130.361 μs | 1.5104 μs | 1.4128 μs | 1.32x slower | 0.02x | 0.9021 | - | 53137 B | 1.11x more | +| | | | | | | | | | | | | +| **Query_ValueTuples_Command** | **AOT** | **Latest ILCompiler** | **110.324 μs** | **0.4156 μs** | **0.3684 μs** | **baseline** | **** | **0.7267** | **-** | **47790 B** | **** | +| Query_ValueTuples_DbConnectionPlus | AOT | Latest ILCompiler | 175.588 μs | 1.3776 μs | 1.2212 μs | 1.59x slower | 0.01x | 1.1682 | - | 84376 B | 1.77x more | +| | | | | | | | | | | | | +| **TemporaryTable_ComplexObjects_Command** | **JIT** | **Default** | **2,728.061 μs** | **14.5332 μs** | **13.5944 μs** | **baseline** | **** | **46.8750** | **-** | **3388440 B** | **** | +| TemporaryTable_ComplexObjects_Dapper | JIT | Default | 1,918.002 μs | 34.1135 μs | 28.4863 μs | 1.42x faster | 0.02x | 16.6667 | - | 1731239 B | 1.96x less | +| TemporaryTable_ComplexObjects_DbConnectionPlus | JIT | Default | 2,169.543 μs | 39.4448 μs | 36.8967 μs | 1.26x faster | 0.02x | 17.8571 | - | 1580135 B | 2.14x less | +| | | | | | | | | | | | | +| **TemporaryTable_ComplexObjects_Command** | **AOT** | **Latest ILCompiler** | **3,180.703 μs** | **23.5846 μs** | **20.9072 μs** | **baseline** | **** | **52.0833** | **-** | **3388726 B** | **** | +| TemporaryTable_ComplexObjects_DbConnectionPlus | AOT | Latest ILCompiler | 2,650.714 μs | 13.9937 μs | 13.0897 μs | 1.20x faster | 0.01x | 23.4375 | - | 1648405 B | 2.06x less | +| | | | | | | | | | | | | +| **TemporaryTable_ScalarValues_Command** | **JIT** | **Default** | **4,946.405 μs** | **34.7151 μs** | **27.1033 μs** | **baseline** | **** | **20.8333** | **-** | **1493512 B** | **** | +| TemporaryTable_ScalarValues_Dapper | JIT | Default | 5,993.998 μs | 117.4418 μs | 115.3436 μs | 1.21x slower | 0.02x | 39.2157 | - | 3175374 B | 2.13x more | +| TemporaryTable_ScalarValues_DbConnectionPlus | JIT | Default | 5,844.893 μs | 38.7475 μs | 32.3559 μs | 1.18x slower | 0.01x | 38.4615 | - | 2696352 B | 1.81x more | +| | | | | | | | | | | | | +| **UpdateEntities_Command** | **JIT** | **Default** | **532.109 μs** | **2.0057 μs** | **1.8762 μs** | **baseline** | **** | **6.9444** | **-** | **566049 B** | **** | +| UpdateEntities_Dapper | JIT | Default | 586.049 μs | 4.1645 μs | 3.8954 μs | 1.10x slower | 0.01x | 11.6054 | - | 663867 B | 1.17x more | +| UpdateEntities_DbConnectionPlus | JIT | Default | 587.406 μs | 3.3649 μs | 2.8098 μs | 1.10x slower | 0.01x | 9.7656 | - | 571057 B | 1.01x more | +| | | | | | | | | | | | | +| **UpdateEntity_Command** | **JIT** | **Default** | **9.143 μs** | **0.1013 μs** | **0.0846 μs** | **baseline** | **** | **0.1225** | **-** | **8551 B** | **** | +| UpdateEntity_Dapper | JIT | Default | 10.748 μs | 0.0497 μs | 0.0465 μs | 1.18x slower | 0.01x | 0.1789 | - | 12031 B | 1.41x more | +| UpdateEntity_DbConnectionPlus | JIT | Default | 9.630 μs | 0.0560 μs | 0.0468 μs | 1.05x slower | 0.01x | 0.1277 | - | 8055 B | 1.06x less | + +## Running the benchmarks +```shell +pwsh -File scripts/benchmarks.ps1 +``` + +Anything after the script name is forwarded to BenchmarkDotNet, e.g. `--filter *Query_Entities*`. The Native AOT +job needs a C++ toolchain (MSVC on Windows, `clang` and `zlib1g-dev` on Linux); the script also puts `vswhere.exe` +on `PATH`, without which the native link step fails with a misleading `MSB3073`. diff --git a/docs/toc.yml b/docs/toc.yml index d276141..7a5f126 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -1,4 +1,38 @@ - name: Home href: index.md +- name: Guides + items: + - name: Querying + href: guides/querying.md + - name: Parameters and temporary tables + href: guides/parameters-and-temporary-tables.md + - name: Entity mapping and CRUD + href: guides/entity-mapping-and-crud.md + - name: Configuration + href: guides/configuration.md + - name: Custom database adapters + href: guides/custom-adapters.md + - name: Native AOT and trimming + href: guides/native-aot.md +- name: Reference + items: + - name: API summary + href: reference/api-summary.md + - name: Comparison with Dapper + href: reference/comparison.md + - name: Performance + href: reference/performance.md + - name: Design decisions + href: DESIGN-DECISIONS.md +- name: Project + items: + - name: Change log + href: ../CHANGELOG.md + - name: Contributing + href: ../CONTRIBUTING.md + - name: Code of conduct + href: ../CODE_OF_CONDUCT.md + - name: Security policy + href: ../SECURITY.md - name: API reference - href: api/ + href: ../artifacts/docs/api/toc.yml diff --git a/tests/package-consumption/AotConsumer/README.md b/tests/package-consumption/AotConsumer/README.md index 0eb1fdd..9adebb8 100644 --- a/tests/package-consumption/AotConsumer/README.md +++ b/tests/package-consumption/AotConsumer/README.md @@ -13,7 +13,8 @@ left at their default values. Measured: 6 columns of real data in, 0 bound, no e Nothing is trimmed on the JIT, so the entire unit and integration suite passes with a broken annotation chain. **This program is the only check in the repository that can see the defect.** The design that defends against it - annotations, no suppressions on the entity path, and the zero-binding guard - is recorded in the -[Native AOT and Trimming](../../../DESIGN-DECISIONS.md#native-aot-and-trimming) section of DESIGN-DECISIONS.md. +[Native AOT and Trimming](../../../docs/DESIGN-DECISIONS.md#native-aot-and-trimming) section of +docs/DESIGN-DECISIONS.md. That is also why every case asserts **values**, never row counts: silent trimming damage does not remove rows, it empties them. @@ -59,7 +60,7 @@ program's own call sites. The generic query methods carry neither `[RequiresUnreferencedCode]` nor `[RequiresDynamicCode]`; the three underlying reflection sites are answered inside the library instead, and -[No consumer-facing diagnostics](../../../DESIGN-DECISIONS.md#4-no-consumer-facing-diagnostics) holds the +[No consumer-facing diagnostics](../../../docs/DESIGN-DECISIONS.md#4-no-consumer-facing-diagnostics) holds the argument. So this program is a faithful sample of what a consumer sees, and what a consumer sees is nothing. ⚠️ **Both target frameworks have to stay in the gate.** `net8.0` needs an `IL3050` suppression on the two From 8d07fe682b91be25807e36e53adae7a3d8c95856 Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 03:27:52 +0200 Subject: [PATCH 07/17] build: verify the packed artifacts against their nuspecs in an isolated cache The Native AOT gate is the only check in the repository that can see silent trimming damage, so what it consumes has to be exactly what would be published, and nothing else. - The six packages are confirmed from their **nuspecs** rather than from the file names on disk: id, version and target frameworks are read out of each package, so a stale artifact from an earlier pack, or a missing adapter, is a failure with a name rather than a run against the wrong input. - The consumer package cache is emptied on every run and NUGET_PACKAGES points at it, so a package restored from the machine's global cache cannot stand in for the one just built. Proved by setting an inherited NUGET_PACKAGES deliberately and watching the consumer's closure still land in the local directory. - RestoreConfigFile names the consumer's own NuGet.config, so the feeds are the consumer's and not whatever the machine has configured, and the publish runs from the consumer directory for the same reason. - The whole body is in try/finally: PATH, NUGET_PACKAGES and the working directory are restored however the run ends, so an interrupted gate does not leave the shell pointed at a throwaway cache. Refs #28 Co-Authored-By: Claude Opus 5 --- scripts/verify-package-aot.ps1 | 386 ++++++++++++++++++++++----------- 1 file changed, 258 insertions(+), 128 deletions(-) diff --git a/scripts/verify-package-aot.ps1 b/scripts/verify-package-aot.ps1 index 2fa5f26..9108972 100644 --- a/scripts/verify-package-aot.ps1 +++ b/scripts/verify-package-aot.ps1 @@ -7,7 +7,7 @@ [DynamicallyAccessedMembers] annotation makes reflection return fewer members with NO error, so the library binds no columns and hands back default-valued entities - measured at 6 columns of real data in, 0 bound, no exception. Nothing is trimmed on the JIT, which is why the unit and integration suites pass with a - broken annotation chain. See the "Native AOT and Trimming" section of DESIGN-DECISIONS.md. + broken annotation chain. See the "Native AOT and Trimming" section of docs/DESIGN-DECISIONS.md. The consumer reaches the library through PackageReference, never through a project reference. That matters: the DAM annotations, the embedded ILLink.Descriptors.xml and the IsTrimmable assembly marker all have to @@ -30,8 +30,8 @@ The runtime identifier to publish for. Defaults to win-x64 on Windows and linux-x64 elsewhere. .PARAMETER PackageVersion - The version of the packages to consume. Defaults to the in src/Directory.Build.props, which is - what `dotnet pack` produces. + The version of the packages to consume. Defaults to the in the repository-root + Directory.Build.props, which is what `dotnet pack` produces. .PARAMETER Configuration Build configuration. Release by default, because that is what CI uses. @@ -52,6 +52,7 @@ .EXAMPLE pwsh -File scripts/verify-package-aot.ps1 -Framework net8.0 #> +#requires -Version 7.0 [CmdletBinding()] param( [ValidateSet('net8.0', 'net10.0')] @@ -73,19 +74,22 @@ if (-not $Runtime) $Runtime = $IsWindows ? 'win-x64' : 'linux-x64' } -$repositoryRoot = Split-Path -Parent $PSScriptRoot +# scripts/ - the repository root is one level up, whatever the current directory is. Every path +# below is anchored to it. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path $solution = Join-Path $repositoryRoot 'DbConnectionPlus.slnx' $consumerDirectory = Join-Path $repositoryRoot 'tests/package-consumption/AotConsumer' $project = Join-Path $consumerDirectory 'AotConsumer.csproj' $packageDirectory = Join-Path $repositoryRoot 'artifacts/packages' $packageCache = Join-Path $repositoryRoot 'tests/package-consumption/.packages' +$consumerNuGetConfig = Join-Path $repositoryRoot 'tests/package-consumption/nuget.config' $publishDirectory = Join-Path $repositoryRoot "artifacts/package-aot/$Framework-$Runtime" if (-not $PackageVersion) { # The single source of truth for the version of all six packages. Reading it here keeps this script # correct across a release bump without a second place to edit. - $sharedProperties = Join-Path $repositoryRoot 'src/Directory.Build.props' + $sharedProperties = Join-Path $repositoryRoot 'Directory.Build.props' $PackageVersion = ([Xml] (Get-Content -Raw $sharedProperties)).Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1 @@ -98,177 +102,303 @@ if (-not $PackageVersion) } } -if ($IsWindows) -{ - # Without vswhere.exe on PATH the native link step fails with MSB3073 and a misleading error message. - $visualStudioInstaller = 'C:\Program Files (x86)\Microsoft Visual Studio\Installer' +# Everything below runs inside a try/finally that restores the caller's PATH and working location, so an +# interrupted run leaves the shell as it found it. `exit` inside a try block still runs the finally. +# +# The working directory is not cosmetic: `dotnet` resolves global.json from the CURRENT directory upward, +# and this repository's global.json is what pins the SDK the packages are built with. +$originalPath = $env:PATH +$originalNuGetPackages = $env:NUGET_PACKAGES - if ((Test-Path $visualStudioInstaller) -and ($env:PATH -notlike "*$visualStudioInstaller*")) +Push-Location -LiteralPath $repositoryRoot +try +{ + if ($IsWindows) { - $env:PATH = "$visualStudioInstaller;$env:PATH" - } -} + # Without vswhere.exe on PATH the native link step fails with MSB3073 and a misleading error message. + $visualStudioInstaller = 'C:\Program Files (x86)\Microsoft Visual Studio\Installer' -# --- The packages under test ------------------------------------------------------------------------------- + if ((Test-Path -LiteralPath $visualStudioInstaller) -and ($env:PATH -notlike "*$visualStudioInstaller*")) + { + $env:PATH = "$visualStudioInstaller;$env:PATH" + } + } -if ($Pack) -{ - Write-Host "Packing the shipping projects ($PackageVersion)..." -ForegroundColor Cyan + # --- The packages under test ------------------------------------------------------------------------------- - & dotnet pack $solution --configuration $Configuration --output $packageDirectory - if ($LASTEXITCODE -ne 0) + if ($Pack) { - Write-Host 'FAILED. dotnet pack did not succeed.' -ForegroundColor Red + Write-Host "Packing the shipping projects ($PackageVersion)..." -ForegroundColor Cyan - exit 1 + & dotnet pack $solution --configuration $Configuration --output $packageDirectory + if ($LASTEXITCODE -ne 0) + { + Write-Host 'FAILED. dotnet pack did not succeed.' -ForegroundColor Red + + exit 1 + } } - # NuGet resolves by version, not by content. Without this the consumer would happily restore the previous - # build of the same version number out of the isolated cache and the publish would prove nothing. - if (Test-Path $packageCache) + # The consumer's package cache is emptied on EVERY run, not only after a pack. NuGet resolves by version + # and not by content, so a cache entry for 4.0.0 satisfies a reference to 4.0.0 whatever bytes produced + # it - and this script exists to test THESE bytes. A stale entry would turn the gate into a re-run of + # whatever passed last time. + if (Test-Path -LiteralPath $packageCache) { - Remove-Item -Recurse -Force $packageCache + Remove-Item -Recurse -Force -LiteralPath $packageCache } -} -$expectedPackage = Join-Path $packageDirectory "DbConnectionPlus.$PackageVersion.nupkg" + # --- The artifact set, validated from the nuspec ------------------------------------------------------------ + # + # From the metadata inside each package, never from its file name. A file name is a claim: renaming + # DbConnectionPlus.3.9.9.nupkg to DbConnectionPlus.4.0.0.nupkg would satisfy a name check and then publish + # a package whose nuspec still says 3.9.9. The id and the version that matter are the ones NuGet reads. -if (-not (Test-Path $expectedPackage)) -{ - Write-Host "FAILED. $expectedPackage does not exist." -ForegroundColor Red - Write-Host '' - Write-Host 'This script consumes the packed packages, not the projects. Produce them first:' -ForegroundColor Red - Write-Host ' pwsh -File scripts/verify-package-aot.ps1 -Pack' -ForegroundColor Red + Add-Type -AssemblyName System.IO.Compression.FileSystem - exit 1 -} + function Get-NuspecMetadata + { + param([Parameter(Mandatory)] [String] $PackagePath) -Write-Host "Publishing the Native AOT package consumer ($Framework, $Runtime, packages $PackageVersion)..." ` - -ForegroundColor Cyan + $archive = [System.IO.Compression.ZipFile]::OpenRead($PackagePath) + try + { + $entry = $archive.Entries | Where-Object { $_.FullName -like '*.nuspec' -and $_.FullName -notlike '*/*' } | + Select-Object -First 1 -# PublishAot is passed here rather than set in the project file, so that an ordinary `dotnet run` of the -# consumer stays a genuine JIT baseline. See the comment in the .csproj. -$publishOutput = & dotnet publish $project ` - --configuration $Configuration ` - --framework $Framework ` - --runtime $Runtime ` - --self-contained true ` - -p:PublishAot=true ` - -p:DbConnectionPlusVersion=$PackageVersion ` - --output $publishDirectory 2>&1 + if ($null -eq $entry) { return $null } -$publishExitCode = $LASTEXITCODE + $reader = [System.IO.StreamReader]::new($entry.Open()) + try { $nuspec = [Xml] $reader.ReadToEnd() } + finally { $reader.Dispose() } + } + finally + { + $archive.Dispose() + } -$publishOutput | ForEach-Object { Write-Host $_ } + return [PSCustomObject] @{ + Id = $nuspec.package.metadata.id + Version = $nuspec.package.metadata.version + } + } -if ($publishExitCode -ne 0) -{ - Write-Host '' - Write-Host "FAILED. The Native AOT publish exited with code $publishExitCode." -ForegroundColor Red + $expectedPackageIds = @( + 'DbConnectionPlus' + 'DbConnectionPlus.DatabaseAdapters.MySql' + 'DbConnectionPlus.DatabaseAdapters.Oracle' + 'DbConnectionPlus.DatabaseAdapters.PostgreSql' + 'DbConnectionPlus.DatabaseAdapters.Sqlite' + 'DbConnectionPlus.DatabaseAdapters.SqlServer' + ) - exit 1 -} + if (-not (Test-Path -LiteralPath $packageDirectory)) + { + Write-Host "FAILED. $packageDirectory does not exist." -ForegroundColor Red + Write-Host '' + Write-Host 'This script consumes the packed packages, not the projects. Produce them first:' -ForegroundColor Red + Write-Host ' pwsh -File scripts/verify-package-aot.ps1 -Pack' -ForegroundColor Red -# --- The warning gate ------------------------------------------------------------------------------------- + exit 1 + } -$diagnostics = $publishOutput | - Select-String -Pattern 'IL[23]\d{3}' | - ForEach-Object { - # Every diagnostic line ends with the MSBuild project suffix "[...csproj::TargetFramework=...]", which - # names this project no matter which assembly the diagnostic came from. Strip it before deciding - # where the diagnostic originated, or everything would look like it came from the consumer. - $origin = $_.Line.Trim() -replace '\s*\[[^\[\]]*\]\s*$', '' + $available = @{} + foreach ($package in (Get-ChildItem -LiteralPath $packageDirectory -Filter '*.nupkg')) + { + $metadata = Get-NuspecMetadata -PackagePath $package.FullName + if ($null -eq $metadata) + { + Write-Host "FAILED. $($package.Name) contains no nuspec." -ForegroundColor Red - [PSCustomObject] @{ - Origin = $origin - Code = [Regex]::Match($_.Line, 'IL[23]\d{3}').Value - Text = $_.Line.Trim() + exit 1 } + + $available["$($metadata.Id)/$($metadata.Version)"] = $package.Name } -# The gate is ZERO diagnostics, from anywhere. -# -# The generic query methods carry neither [RequiresUnreferencedCode] nor [RequiresDynamicCode]: each of the three -# underlying reflection sites is answered inside the library, where it occurs, so a consumer publishing with -# PublishAot or PublishTrimmed sees nothing for any supported scenario. -# See the "No consumer-facing diagnostics" section of DESIGN-DECISIONS.md for the full argument. -# -# That makes this the strongest form of the gate: a warning appearing anywhere - in the library, in an adapter, -# in a package in the closure, or at this consumer's own call sites - is a regression. The split below only -# shapes the failure message, because "the library started warning" and "our own call site started warning" -# have different causes. The same site is reported twice, once by the Roslyn analyzer and once by ILC, so the -# list is de-duplicated. -$fromConsumer = @($diagnostics | Where-Object { $_.Origin.StartsWith($consumerDirectory, [StringComparison]::OrdinalIgnoreCase) }) -$fromElsewhere = @($diagnostics | Where-Object { -not $_.Origin.StartsWith($consumerDirectory, [StringComparison]::OrdinalIgnoreCase) }) + $missing = @($expectedPackageIds | Where-Object { -not $available.ContainsKey("$_/$PackageVersion") }) -Write-Host '' + if ($missing.Count -gt 0) + { + Write-Host "FAILED. $packageDirectory does not contain version $PackageVersion of:" -ForegroundColor Red + $missing | ForEach-Object { Write-Host " $_" -ForegroundColor Red } + Write-Host '' + Write-Host 'Present, by the id and version in each nuspec:' -ForegroundColor Red + if ($available.Count -eq 0) + { + Write-Host ' (nothing)' -ForegroundColor Red + } + else + { + $available.GetEnumerator() | Sort-Object Key | ForEach-Object { + Write-Host " $($_.Key) ($($_.Value))" -ForegroundColor Red + } + } + Write-Host '' + Write-Host 'Produce them with: pwsh -File scripts/verify-package-aot.ps1 -Pack' -ForegroundColor Red -if ($diagnostics.Count -eq 0) -{ - Write-Host 'IL diagnostics: none, from anywhere. A consumer publishing this way sees no warnings.' -ForegroundColor Cyan -} -else -{ - Write-Host 'FAILED. The publish reported IL diagnostics, and the gate is zero:' -ForegroundColor Red + exit 1 + } - if ($fromElsewhere.Count -gt 0) + Write-Host "All six packages are present at $PackageVersion, by their nuspec metadata." -ForegroundColor Cyan + + Write-Host "Publishing the Native AOT package consumer ($Framework, $Runtime, packages $PackageVersion)..." ` + -ForegroundColor Cyan + + # NUGET_PACKAGES is set explicitly, and to the CONSUMER's isolated cache. The environment variable takes + # precedence over the globalPackagesFolder setting in tests/package-consumption/nuget.config, so an + # inherited one - CI sets NUGET_PACKAGES to a workspace-wide cache - would silently defeat the isolation + # the config file exists to provide, and the consumer could restore a DbConnectionPlus assembly that never + # came out of these packages. It is restored in the finally block at the end of the script. + $env:NUGET_PACKAGES = $packageCache + + # PublishAot is passed here rather than set in the project file, so that an ordinary `dotnet run` of the + # consumer stays a genuine JIT baseline. See the comment in the .csproj. + # + # RestoreConfigFile names the CONSUMER's NuGet configuration explicitly rather than relying on NuGet's + # upward search finding it. That file is what maps DbConnectionPlus and DbConnectionPlus.* to the local + # artifact feed, and it s the sources first - so a missing local package fails instead of + # resolving from nuget.org, where a published package of the same version exists and would look like a + # pass. + # + # Run from the consumer directory, which is where a consumer would run it. + Push-Location -LiteralPath $consumerDirectory + try { - Write-Host '' - Write-Host " From the library, an adapter or a package ($($fromElsewhere.Count) before de-duplication):" -ForegroundColor Red - $fromElsewhere | Sort-Object Text -Unique | ForEach-Object { Write-Host " $($_.Text)" -ForegroundColor Red } + $publishOutput = & dotnet publish 'AotConsumer.csproj' ` + --configuration $Configuration ` + --framework $Framework ` + --runtime $Runtime ` + --self-contained true ` + -p:RestoreConfigFile=$consumerNuGetConfig ` + -p:PublishAot=true ` + -p:DbConnectionPlusVersion=$PackageVersion ` + --output $publishDirectory 2>&1 + } + finally + { + Pop-Location } - if ($fromConsumer.Count -gt 0) + $publishExitCode = $LASTEXITCODE + + $publishOutput | ForEach-Object { Write-Host $_ } + + if ($publishExitCode -ne 0) { Write-Host '' - Write-Host " At the consumer's own call sites ($($fromConsumer.Count) before de-duplication):" -ForegroundColor Red - $fromConsumer | Sort-Object Origin -Unique | Group-Object Code | Sort-Object Name | ForEach-Object { - Write-Host (" {0,-8} {1} call site(s)" -f $_.Name, $_.Count) -ForegroundColor Red + Write-Host "FAILED. The Native AOT publish exited with code $publishExitCode." -ForegroundColor Red + + exit 1 + } + + # --- The warning gate ------------------------------------------------------------------------------------- + + $diagnostics = $publishOutput | + Select-String -Pattern 'IL[23]\d{3}' | + ForEach-Object { + # Every diagnostic line ends with the MSBuild project suffix "[...csproj::TargetFramework=...]", which + # names this project no matter which assembly the diagnostic came from. Strip it before deciding + # where the diagnostic originated, or everything would look like it came from the consumer. + $origin = $_.Line.Trim() -replace '\s*\[[^\[\]]*\]\s*$', '' + + [PSCustomObject] @{ + Origin = $origin + Code = [Regex]::Match($_.Line, 'IL[23]\d{3}').Value + Text = $_.Line.Trim() + } } + + # The gate is ZERO diagnostics, from anywhere. + # + # The generic query methods carry neither [RequiresUnreferencedCode] nor [RequiresDynamicCode]: each of the three + # underlying reflection sites is answered inside the library, where it occurs, so a consumer publishing with + # PublishAot or PublishTrimmed sees nothing for any supported scenario. + # See the "No consumer-facing diagnostics" section of docs/DESIGN-DECISIONS.md for the full argument. + # + # That makes this the strongest form of the gate: a warning appearing anywhere - in the library, in an adapter, + # in a package in the closure, or at this consumer's own call sites - is a regression. The split below only + # shapes the failure message, because "the library started warning" and "our own call site started warning" + # have different causes. The same site is reported twice, once by the Roslyn analyzer and once by ILC, so the + # list is de-duplicated. + $fromConsumer = @($diagnostics | Where-Object { $_.Origin.StartsWith($consumerDirectory, [StringComparison]::OrdinalIgnoreCase) }) + $fromElsewhere = @($diagnostics | Where-Object { -not $_.Origin.StartsWith($consumerDirectory, [StringComparison]::OrdinalIgnoreCase) }) + + Write-Host '' + + if ($diagnostics.Count -eq 0) + { + Write-Host 'IL diagnostics: none, from anywhere. A consumer publishing this way sees no warnings.' -ForegroundColor Cyan + } + else + { + Write-Host 'FAILED. The publish reported IL diagnostics, and the gate is zero:' -ForegroundColor Red + + if ($fromElsewhere.Count -gt 0) + { + Write-Host '' + Write-Host " From the library, an adapter or a package ($($fromElsewhere.Count) before de-duplication):" -ForegroundColor Red + $fromElsewhere | Sort-Object Text -Unique | ForEach-Object { Write-Host " $($_.Text)" -ForegroundColor Red } + } + + if ($fromConsumer.Count -gt 0) + { + Write-Host '' + Write-Host " At the consumer's own call sites ($($fromConsumer.Count) before de-duplication):" -ForegroundColor Red + $fromConsumer | Sort-Object Origin -Unique | Group-Object Code | Sort-Object Name | ForEach-Object { + Write-Host (" {0,-8} {1} call site(s)" -f $_.Name, $_.Count) -ForegroundColor Red + } + Write-Host '' + Write-Host ' A diagnostic here means a public API started carrying [RequiresUnreferencedCode] or' -ForegroundColor Red + Write-Host ' [RequiresDynamicCode] again, which is exactly the consumer experience this work removed.' -ForegroundColor Red + } + Write-Host '' - Write-Host ' A diagnostic here means a public API started carrying [RequiresUnreferencedCode] or' -ForegroundColor Red - Write-Host ' [RequiresDynamicCode] again, which is exactly the consumer experience this work removed.' -ForegroundColor Red + Write-Host 'Do not silence these at the call site or with NoWarn. An IL2xxx warning is the only' -ForegroundColor Red + Write-Host 'build-time evidence that the [DynamicallyAccessedMembers] chain is complete - restructure' -ForegroundColor Red + Write-Host 'the code, or answer the diagnostic where it occurs with a justified, tested suppression.' -ForegroundColor Red + + exit 1 } - Write-Host '' - Write-Host 'Do not silence these at the call site or with NoWarn. An IL2xxx warning is the only' -ForegroundColor Red - Write-Host 'build-time evidence that the [DynamicallyAccessedMembers] chain is complete - restructure' -ForegroundColor Red - Write-Host 'the code, or answer the diagnostic where it occurs with a justified, tested suppression.' -ForegroundColor Red + # --- Running the native binary ---------------------------------------------------------------------------- - exit 1 -} + $executableName = $IsWindows ? 'AotConsumer.exe' : 'AotConsumer' + $executable = Join-Path $publishDirectory $executableName -# --- Running the native binary ---------------------------------------------------------------------------- + if (-not (Test-Path $executable)) + { + Write-Host '' + Write-Host "FAILED. The native binary was not produced at $executable." -ForegroundColor Red -$executableName = $IsWindows ? 'AotConsumer.exe' : 'AotConsumer' -$executable = Join-Path $publishDirectory $executableName + exit 1 + } -if (-not (Test-Path $executable)) -{ Write-Host '' - Write-Host "FAILED. The native binary was not produced at $executable." -ForegroundColor Red + Write-Host 'Running the native binary...' -ForegroundColor Cyan + Write-Host '' - exit 1 -} + & $executable -Write-Host '' -Write-Host 'Running the native binary...' -ForegroundColor Cyan -Write-Host '' + $runExitCode = $LASTEXITCODE -& $executable + Write-Host '' -$runExitCode = $LASTEXITCODE + if ($runExitCode -ne 0) + { + Write-Host "FAILED. The native binary exited with code $runExitCode." -ForegroundColor Red + + exit 1 + } -Write-Host '' + Write-Host "PASSED. $Framework/$Runtime published from the packages with no IL diagnostics at all, and every assertion passed." ` + -ForegroundColor Green -if ($runExitCode -ne 0) + exit 0 +} +finally { - Write-Host "FAILED. The native binary exited with code $runExitCode." -ForegroundColor Red - - exit 1 + $env:PATH = $originalPath + $env:NUGET_PACKAGES = $originalNuGetPackages + Pop-Location } - -Write-Host "PASSED. $Framework/$Runtime published from the packages with no IL diagnostics at all, and every assertion passed." ` - -ForegroundColor Green - -exit 0 From a7bb11de05bc968c38dc343e97557d69d0bfabf8 Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 03:28:17 +0200 Subject: [PATCH 08/17] fix: scope the editor hooks to the file the edit named The Codex adapter discarded the payload and ran the tidy script with no arguments, which formats every file git reports as changed. An agent editing one file therefore reformatted the contributor's unrelated work in progress, in the same edit, without saying so. Both adapters now format only the path the triggering edit carried: Claude Code's `tool_input.file_path`, and for Codex the patch text parsed for `*** Add File:`, `*** Update File:` and `*** Move to:`. A delete is skipped - there is nothing left to format - and a rename formats the new name only, because the old one no longer exists. There is **no fallback**. An unparseable payload formats nothing and says so in one line; the old behaviour was the fallback, and it was worse than doing nothing. - Every path is resolved and then required to sit under the resolved repository root, so `../` traversal is refused and so is a junction or symlink pointing out of the tree - the check happens after resolution, which follows it. Paths under bin/ and obj/ are refused too. - The public-API guard now fires only when the edited file is actually a PublicAPI.*.txt, instead of running on every edit. - Concurrent edits serialize on a named mutex derived from the repository path, shared by both agents and distinct per clone, with a timeout so a stuck hold cannot wedge later edits. - Both adapters exit 0 whatever happens. A PostToolUse failure cannot undo an edit that already happened, so failing only costs the agent its next step; they report through their own channel instead. - Each adapter runs the shared script as a child `pwsh -File` process. The shared scripts end with `exit`, which inside the adapter's own process would end the adapter before it could write its response - fatal for Codex, where a hook that writes no JSON is a hook that failed. - A missing formatter is reported with the command that installs it, and nothing is installed on your behalf. The wiring in .claude/settings.json and .codex/hooks.json is unchanged: the triggers are still Edit|Write and apply_patch|Edit|Write, and neither was broadened to every shell command. Two defects were found by feeding hand-written payloads to the adapters in throwaway clones: PowerShell unrolls a one-element list on the way out of a function, so a single-file patch threw and formatted nothing; both adapters now wrap the result as [string[]] @(...). Refs #28 Co-Authored-By: Claude Opus 5 --- .claude/hooks/public-api-guard.ps1 | 48 ++++++-- .claude/hooks/tidy-code.ps1 | 126 ++++++++++++++++---- .codex/hooks/public-api-guard.ps1 | 109 ++++++++++++++--- .codex/hooks/tidy-code.ps1 | 183 +++++++++++++++++++++++++---- 4 files changed, 396 insertions(+), 70 deletions(-) diff --git a/.claude/hooks/public-api-guard.ps1 b/.claude/hooks/public-api-guard.ps1 index f6e811b..b675c42 100644 --- a/.claude/hooks/public-api-guard.ps1 +++ b/.claude/hooks/public-api-guard.ps1 @@ -1,26 +1,54 @@ -# PostToolUse hook: remind about the companion edits a public API change needs. +# Claude Code PostToolUse hook: remind about the companion edits a public API change needs. # -# The checklist itself lives in scripts/public-api-guard.ps1, so that Codex's hook and preflight -# produce exactly the same text. This file is only the hook wiring: read the tool payload off -# stdin, pull the edited path out of it, and delegate. +# The checklist itself lives in scripts/public-api-guard.ps1, so that Codex's hook and preflight produce +# exactly the same text. This file is only the hook wiring: read the tool payload off stdin, pull the edited +# path out of it, and delegate. # -# Never fails the edit - a problem here is surfaced as text and the hook still exits 0. +# SCOPED TO THE TRIGGERING EDIT: it passes the one path the payload names. If the payload cannot be parsed it +# says so and checks nothing - it never falls back to every file git reports as changed, which would nag +# about work this edit did not touch. +# +# READ-ONLY. The shared script only ever prints; it stages nothing, rewrites no API snapshot, and installs +# nothing. Never fails the edit - a problem here is surfaced as text and the hook still exits 0. $ErrorActionPreference = 'Stop' try { - $payload = [Console]::In.ReadToEnd() | ConvertFrom-Json - $filePath = $payload.tool_input.file_path + # .claude/hooks/ - two levels up. + $repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))).Path + + $raw = [Console]::In.ReadToEnd() + if ([String]::IsNullOrWhiteSpace($raw)) { exit 0 } + try { + $payload = $raw | ConvertFrom-Json + } + catch { + Write-Output 'public-api-guard hook: could not parse the tool payload. Run: pwsh -File scripts/public-api-guard.ps1' + exit 0 + } + + $filePath = $payload.tool_input.file_path if ([String]::IsNullOrWhiteSpace($filePath)) { exit 0 } - $script = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'scripts/public-api-guard.ps1' - if (-not (Test-Path -LiteralPath $script)) { + # Only the two snapshot files matter, so the check costs nothing on every other edit. + if ((Split-Path -Leaf $filePath) -notin @('PublicAPI.Shipped.txt', 'PublicAPI.Unshipped.txt')) { exit 0 } + + if (-not [System.IO.Path]::IsPathRooted($filePath)) { $filePath = Join-Path $repositoryRoot $filePath } + if (-not (Test-Path -LiteralPath $filePath -PathType Leaf)) { exit 0 } + + $resolved = (Resolve-Path -LiteralPath $filePath).Path + $prefix = $repositoryRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + if (-not $resolved.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { exit 0 } + + $script = Join-Path $repositoryRoot 'scripts/public-api-guard.ps1' + if (-not (Test-Path -LiteralPath $script -PathType Leaf)) { Write-Output "public-api-guard hook: scripts/public-api-guard.ps1 not found at $script" exit 0 } - & $script -Path $filePath + # A CHILD pwsh: the shared script ends with `exit`, which in this process would end the hook there. + & pwsh -NoProfile -NonInteractive -File $script -Path $resolved } catch { Write-Output "public-api-guard hook error: $($_.Exception.Message)" diff --git a/.claude/hooks/tidy-code.ps1 b/.claude/hooks/tidy-code.ps1 index 049f017..58f3fac 100644 --- a/.claude/hooks/tidy-code.ps1 +++ b/.claude/hooks/tidy-code.ps1 @@ -1,41 +1,127 @@ -# PostToolUse hook: format an edited C# file with CSharpier. +# Claude Code PostToolUse hook: format the C# file this edit touched, with CSharpier. # # The logic itself lives in scripts/tidy-code.ps1, so that Codex's hook and a human run exactly the same -# thing. This file is only the hook wiring: read the tool payload off stdin, pull the edited path out of -# it, and delegate. +# thing. This file is only the hook wiring: read the tool payload off stdin, pull the edited path out of it, +# check that the path is one we are allowed to touch, and delegate. # -# Formatting only, which is the default scope and takes under a second. Style and member ordering are -# not run here: `dotnet format style` needs MSBuild and ReSharper loads the whole solution, and neither -# belongs on the critical path of every single edit. All three are build errors, and scripts/preflight.ps1 -# runs `-Scope all` before a commit, so nothing slips through. +# SCOPED TO THE TRIGGERING EDIT. It formats the file named in the payload and nothing else. If the payload +# cannot be parsed, or names a path outside this repository, it says so and formats NOTHING - it never falls +# back to "every file git reports as changed", which would reformat work the user has in progress and did not +# ask this hook to touch. # -# Never fails the edit - a formatter problem is surfaced as text and the hook still exits 0. +# Formatting only, which is the default scope and takes under a second. Style and member ordering are not run +# here: `dotnet format style` needs MSBuild and ReSharper loads the whole solution, and neither belongs on the +# critical path of every single edit. All three are build errors, and scripts/preflight.ps1 checks `-Scope +# all` before a commit, so nothing slips through. +# +# Never fails the edit - a formatter problem is surfaced as text and the hook still exits 0. A PostToolUse +# failure does not undo an edit that has already happened, so failing here would only be noise. $ErrorActionPreference = 'Stop' -try { - $payload = [Console]::In.ReadToEnd() | ConvertFrom-Json - $filePath = $payload.tool_input.file_path +function Get-RepositoryRoot +{ + # .claude/hooks/ - two levels up. Resolved, so that the comparison below is against a real + # path rather than against a string with ".." in it. + return (Resolve-Path -LiteralPath (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))).Path +} + +function Resolve-EligibleFile +{ + <# + A path from the payload, turned into an absolute path this hook is allowed to format - or nothing. + + Rejected: anything that is not a .cs file, anything that no longer exists (a delete, or a move's old + name), anything under bin/ or obj/, and anything that resolves outside the repository root. The last + one is what stops a payload naming ../../etc/something, and it is checked AFTER resolution, so a + symbolic link or junction that points out of the tree is rejected too - Resolve-Path follows it and + the result no longer starts with the root. + #> + param([Parameter(Mandatory)] [String] $Root, [String] $Path) + + if ([String]::IsNullOrWhiteSpace($Path)) { return $null } + if ([System.IO.Path]::GetExtension($Path) -ne '.cs') { return $null } + + if (-not [System.IO.Path]::IsPathRooted($Path)) + { + $Path = Join-Path $Root $Path + } + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + + $resolved = (Resolve-Path -LiteralPath $Path).Path - if ([String]::IsNullOrWhiteSpace($filePath)) { exit 0 } - if ([System.IO.Path]::GetExtension($filePath) -ne '.cs') { exit 0 } - if (-not (Test-Path -LiteralPath $filePath)) { exit 0 } + $prefix = $Root.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + if (-not $resolved.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { return $null } + + # Generated output is not ours to format, and reformatting it would fight the tool that wrote it. + if ($resolved -match '[\\/](bin|obj)[\\/]') { return $null } + + return $resolved +} - $script = Join-Path (Split-Path -Parent (Split-Path -Parent $PSScriptRoot)) 'scripts/tidy-code.ps1' - if (-not (Test-Path -LiteralPath $script)) { +try +{ + $repositoryRoot = Get-RepositoryRoot + + $raw = [Console]::In.ReadToEnd() + if ([String]::IsNullOrWhiteSpace($raw)) { exit 0 } + + try + { + $payload = $raw | ConvertFrom-Json + } + catch + { + Write-Output 'tidy-code hook: could not parse the tool payload, so nothing was formatted. Run: pwsh -File scripts/tidy-code.ps1' + exit 0 + } + + # Edit and Write both carry the path here. Nothing else is inferred: no path, no formatting. + $file = Resolve-EligibleFile -Root $repositoryRoot -Path $payload.tool_input.file_path + if (-not $file) { exit 0 } + + $script = Join-Path $repositoryRoot 'scripts/tidy-code.ps1' + if (-not (Test-Path -LiteralPath $script -PathType Leaf)) + { Write-Output "tidy-code hook: scripts/tidy-code.ps1 not found at $script" exit 0 } - $output = & $script -Path $filePath 2>&1 | Out-String + # A named mutex, so two edits landing at once cannot run two formatters over the same file. Global\ so it + # is shared across sessions of both agents; the name is derived from the repository path, so two clones + # do not block each other. + $mutexName = 'Global\dbconnectionplus-tidy-' + + [BitConverter]::ToString( + [System.Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($repositoryRoot.ToLowerInvariant())) + ).Replace('-', '').Substring(0, 32) + + $mutex = [System.Threading.Mutex]::new($false, $mutexName) + try + { + # Waiting, but not forever: a stuck hold must not wedge every later edit. + [void] $mutex.WaitOne([TimeSpan]::FromSeconds(30)) + + # A CHILD pwsh, not dot-sourcing: scripts/tidy-code.ps1 ends with `exit`, and running it in this + # process would end the hook there - before it could report anything. + $output = & pwsh -NoProfile -NonInteractive -File $script -Scope format -Path $file 2>&1 | Out-String + $exitCode = $LASTEXITCODE + } + finally + { + try { $mutex.ReleaseMutex() } catch { } + $mutex.Dispose() + } # Quiet on success. On failure say so explicitly and say what it means, because the build treats # formatting as an error - the same contract Codex's adapter reports through additionalContext. - if ($LASTEXITCODE -ne 0) { - Write-Output "CSharpier failed on $filePath. The build treats formatting as an error, so fix this before building:`n$output" + if ($exitCode -ne 0) + { + Write-Output "CSharpier failed on $file. The build treats formatting as an error, so fix this before building:`n$output" } } -catch { +catch +{ Write-Output "tidy-code hook error: $($_.Exception.Message)" } diff --git a/.codex/hooks/public-api-guard.ps1 b/.codex/hooks/public-api-guard.ps1 index 1152dc8..b2a4932 100644 --- a/.codex/hooks/public-api-guard.ps1 +++ b/.codex/hooks/public-api-guard.ps1 @@ -1,24 +1,32 @@ # Codex PostToolUse hook: remind about the companion edits a public API change needs. # -# The checklist itself lives in scripts/public-api-guard.ps1, which Claude Code's hook runs too. -# This file is only the hook wiring. +# The checklist itself lives in scripts/public-api-guard.ps1, which Claude Code's hook runs too. This file is +# only the hook wiring. # -# Called with no -Path, the shared script examines every file git reports as changed. That is what this hook -# needs: for a file edit Codex reports tool_name "apply_patch" and puts the patch text in tool_input.command, -# not a file path. +# SCOPED TO THE TRIGGERING EDIT. For a file edit Codex reports tool_name "apply_patch" and puts the patch TEXT +# in tool_input.command, so the paths come from the patch headers - Add File, Update File and Move to; a +# Delete File is skipped, because a deleted snapshot has nothing to check. If the patch cannot be parsed this +# hook checks nothing and says so. It never falls back to every file git reports as changed, which would nag +# about work this edit did not touch. +# +# READ-ONLY. The shared script only ever prints; it stages nothing, rewrites no API snapshot, and installs +# nothing. # # Contract (https://learn.chatgpt.com/docs/hooks): exit 0 and write the response JSON to stdout. The checklist # is returned as additionalContext, so it reaches the model rather than only the user. $ErrorActionPreference = 'Stop' -function Write-HookResult { +function Write-HookResult +{ param([String] $AdditionalContext) - if ([String]::IsNullOrWhiteSpace($AdditionalContext)) { + if ([String]::IsNullOrWhiteSpace($AdditionalContext)) + { $result = @{ continue = $true; suppressOutput = $true } } - else { + else + { $result = @{ continue = $true hookSpecificOutput = @{ @@ -31,26 +39,93 @@ function Write-HookResult { $result | ConvertTo-Json -Depth 5 -Compress | Write-Output } -try { - # The payload is read and discarded: draining stdin keeps Codex from blocking on the pipe. - [Console]::In.ReadToEnd() | Out-Null +function Get-PatchPath +{ + param([String] $Command) + + $paths = New-Object System.Collections.Generic.List[String] + + if ([String]::IsNullOrWhiteSpace($Command)) { return $paths } + + foreach ($line in ($Command -split "`r?`n")) + { + $match = [Regex]::Match($line, '^\s*\*\*\*\s+(Add File|Update File|Move to):\s*(.+?)\s*$') + if ($match.Success) + { + $paths.Add($match.Groups[2].Value) + } + } + + return $paths +} + +try +{ + # .codex/hooks/ - two levels up. Anchored to this file rather than asked of git, so the answer + # does not depend on the current directory or on git being on PATH. + $repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))).Path + + $raw = [Console]::In.ReadToEnd() + if ([String]::IsNullOrWhiteSpace($raw)) + { + Write-HookResult + exit 0 + } + + try + { + $payload = $raw | ConvertFrom-Json + } + catch + { + Write-HookResult -AdditionalContext 'public-api-guard hook: the tool payload could not be parsed. Run `pwsh -File scripts/public-api-guard.ps1` yourself.' + exit 0 + } + + $candidates = New-Object System.Collections.Generic.List[String] + # [string[]] @(...) on purpose: PowerShell unrolls a one-element list to a bare string on the way + # out of a function, and AddRange cannot take one. + $candidates.AddRange([string[]] @(Get-PatchPath -Command $payload.tool_input.command)) + if ($payload.tool_input.file_path) { $candidates.Add([String] $payload.tool_input.file_path) } + + $prefix = $repositoryRoot.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar - $repositoryRoot = & git rev-parse --show-toplevel 2>$null - if ([String]::IsNullOrWhiteSpace($repositoryRoot)) { - $repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + $files = New-Object System.Collections.Generic.List[String] + foreach ($candidate in $candidates) + { + $path = $candidate.Trim('"') + if ((Split-Path -Leaf $path) -notin @('PublicAPI.Shipped.txt', 'PublicAPI.Unshipped.txt')) { continue } + + if (-not [System.IO.Path]::IsPathRooted($path)) { $path = Join-Path $repositoryRoot $path } + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { continue } + + $resolved = (Resolve-Path -LiteralPath $path).Path + if (-not $resolved.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { continue } + + if (-not $files.Contains($resolved)) { $files.Add($resolved) } + } + + if ($files.Count -eq 0) + { + Write-HookResult + exit 0 } $script = Join-Path $repositoryRoot 'scripts/public-api-guard.ps1' - if (-not (Test-Path -LiteralPath $script)) { + if (-not (Test-Path -LiteralPath $script -PathType Leaf)) + { Write-HookResult -AdditionalContext "public-api-guard hook: scripts/public-api-guard.ps1 not found at $script" exit 0 } - $output = & pwsh -NoProfile -NonInteractive -File $script 2>&1 | Out-String + # A CHILD pwsh: the shared script ends with `exit`, which in this process would end the hook before it + # could write its protocol response - and a Codex hook that writes nothing is a hook that failed. + $output = & pwsh -NoProfile -NonInteractive -File $script -Path @files 2>&1 | Out-String Write-HookResult -AdditionalContext $output.Trim() } -catch { +catch +{ Write-HookResult -AdditionalContext "public-api-guard hook error: $($_.Exception.Message)" } diff --git a/.codex/hooks/tidy-code.ps1 b/.codex/hooks/tidy-code.ps1 index c6676ad..5d7dd2e 100644 --- a/.codex/hooks/tidy-code.ps1 +++ b/.codex/hooks/tidy-code.ps1 @@ -1,29 +1,40 @@ -# Codex PostToolUse hook: format the C# files an edit just touched, with CSharpier. +# Codex PostToolUse hook: format the C# files this edit touched, with CSharpier. # -# The logic itself lives in scripts/tidy-code.ps1, which Claude Code's hook runs too. This file is only -# the hook wiring. +# The logic itself lives in scripts/tidy-code.ps1, which Claude Code's hook runs too. This file is only the +# hook wiring. # -# Why it does not read a path out of the payload: for a file edit Codex reports tool_name "apply_patch" and -# puts the patch text in tool_input.command, not a file path. The script with no arguments formats -# every .cs file git reports as changed, which covers the edit that just happened and costs nothing when -# there is none. +# SCOPED TO THE TRIGGERING EDIT, which for Codex means reading the patch. For a file edit Codex reports +# tool_name "apply_patch" and puts the patch TEXT in tool_input.command, not a file path - so the paths are +# parsed out of the patch headers: +# +# *** Add File: src/Foo.cs formatted +# *** Update File: src/Foo.cs formatted +# *** Move to: src/Bar.cs formatted (the new name; the old one no longer exists) +# *** Delete File: src/Foo.cs skipped +# +# If the patch cannot be parsed, this hook formats NOTHING and says so. It does not fall back to "every file +# git reports as changed": that would reformat work in progress that this edit did not touch, which is +# exactly the surprise a scoped hook exists to avoid. # # Formatting only, which is the default scope. Style and member ordering are build errors and -# scripts/preflight.ps1 runs `-Scope all` before a commit; neither belongs on the critical path of -# every edit. +# scripts/preflight.ps1 checks `-Scope all` before a commit; neither belongs on the critical path of every +# edit. # # Contract (https://learn.chatgpt.com/docs/hooks): exit 0 and write the response JSON to stdout. Exit code 2 # would block the operation - this hook never does that, because a formatter problem must not stop an edit. $ErrorActionPreference = 'Stop' -function Write-HookResult { +function Write-HookResult +{ param([String] $AdditionalContext) - if ([String]::IsNullOrWhiteSpace($AdditionalContext)) { + if ([String]::IsNullOrWhiteSpace($AdditionalContext)) + { $result = @{ continue = $true; suppressOutput = $true } } - else { + else + { $result = @{ continue = $true hookSpecificOutput = @{ @@ -36,33 +47,159 @@ function Write-HookResult { $result | ConvertTo-Json -Depth 5 -Compress | Write-Output } -try { - # The payload is read and discarded: draining stdin keeps Codex from blocking on the pipe. - [Console]::In.ReadToEnd() | Out-Null +function Get-RepositoryRoot +{ + # .codex/hooks/ - two levels up. Anchored to this file rather than asked of git, so that the + # answer does not depend on the current directory or on git being on PATH. + return (Resolve-Path -LiteralPath (Split-Path -Parent (Split-Path -Parent $PSScriptRoot))).Path +} + +function Get-PatchPath +{ + <# + The paths an apply_patch command touches, from its headers. Deletions are skipped: there is nothing + left to format. A rename is reported as an Update of the old name followed by a Move to the new one, + so both are collected and the filter below drops the old name, which no longer exists. + #> + param([String] $Command) + + $paths = New-Object System.Collections.Generic.List[String] + + if ([String]::IsNullOrWhiteSpace($Command)) { return $paths } - $repositoryRoot = & git rev-parse --show-toplevel 2>$null - if ([String]::IsNullOrWhiteSpace($repositoryRoot)) { - $repositoryRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot) + foreach ($line in ($Command -split "`r?`n")) + { + $match = [Regex]::Match($line, '^\s*\*\*\*\s+(Add File|Update File|Move to):\s*(.+?)\s*$') + if ($match.Success) + { + $paths.Add($match.Groups[2].Value) + } + } + + return $paths +} + +function Resolve-EligibleFile +{ + <# + A path from the patch, turned into an absolute path this hook is allowed to format - or nothing. + + Rejected: anything that is not a .cs file, anything that no longer exists, anything under bin/ or + obj/, and anything that resolves outside the repository root. The last check happens AFTER + resolution, so a symbolic link or junction pointing out of the tree is rejected too. + #> + param([Parameter(Mandatory)] [String] $Root, [String] $Path) + + if ([String]::IsNullOrWhiteSpace($Path)) { return $null } + + $Path = $Path.Trim('"') + if ([System.IO.Path]::GetExtension($Path) -ne '.cs') { return $null } + + if (-not [System.IO.Path]::IsPathRooted($Path)) + { + $Path = Join-Path $Root $Path + } + + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { return $null } + + $resolved = (Resolve-Path -LiteralPath $Path).Path + + $prefix = $Root.TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar + if (-not $resolved.StartsWith($prefix, [StringComparison]::OrdinalIgnoreCase)) { return $null } + + if ($resolved -match '[\\/](bin|obj)[\\/]') { return $null } + + return $resolved +} + +try +{ + $repositoryRoot = Get-RepositoryRoot + + $raw = [Console]::In.ReadToEnd() + if ([String]::IsNullOrWhiteSpace($raw)) + { + Write-HookResult + exit 0 + } + + try + { + $payload = $raw | ConvertFrom-Json + } + catch + { + Write-HookResult -AdditionalContext 'tidy-code hook: the tool payload could not be parsed, so nothing was formatted. Run `pwsh -File scripts/tidy-code.ps1` yourself.' + exit 0 + } + + $candidates = New-Object System.Collections.Generic.List[String] + + # apply_patch puts the patch in .command; Edit and Write carry a plain path. Both are read, and nothing + # is inferred beyond them. + # [string[]] @(...) on purpose: PowerShell unrolls a one-element list to a bare string on the way + # out of a function, and AddRange cannot take one. + $candidates.AddRange([string[]] @(Get-PatchPath -Command $payload.tool_input.command)) + if ($payload.tool_input.file_path) { $candidates.Add([String] $payload.tool_input.file_path) } + + $files = @( + $candidates | + ForEach-Object { Resolve-EligibleFile -Root $repositoryRoot -Path $_ } | + Where-Object { $_ } | + Select-Object -Unique + ) + + if ($files.Count -eq 0) + { + Write-HookResult + exit 0 } $script = Join-Path $repositoryRoot 'scripts/tidy-code.ps1' - if (-not (Test-Path -LiteralPath $script)) { + if (-not (Test-Path -LiteralPath $script -PathType Leaf)) + { Write-HookResult -AdditionalContext "tidy-code hook: scripts/tidy-code.ps1 not found at $script" exit 0 } - $output = & pwsh -NoProfile -NonInteractive -File $script 2>&1 | Out-String + # A named mutex, so two edits landing at once cannot run two formatters over the same file. The name is + # derived from the repository path, so two clones do not block each other, and it is the same name the + # Claude adapter uses - the two agents serialize against each other as well. + $mutexName = 'Global\dbconnectionplus-tidy-' + + [BitConverter]::ToString( + [System.Security.Cryptography.SHA256]::HashData([Text.Encoding]::UTF8.GetBytes($repositoryRoot.ToLowerInvariant())) + ).Replace('-', '').Substring(0, 32) + + $mutex = [System.Threading.Mutex]::new($false, $mutexName) + try + { + [void] $mutex.WaitOne([TimeSpan]::FromSeconds(30)) + + # A CHILD pwsh, not dot-sourcing: scripts/tidy-code.ps1 ends with `exit`, which in this process would + # end the hook before it could write its protocol response - and a Codex hook that writes nothing is + # a hook that failed. + $output = & pwsh -NoProfile -NonInteractive -File $script -Scope format -Path @files 2>&1 | Out-String + $exitCode = $LASTEXITCODE + } + finally + { + try { $mutex.ReleaseMutex() } catch { } + $mutex.Dispose() + } # Quiet on success: the agent does not need to be told that nothing needed formatting. A failure is # reported, because it means the next build breaks on a formatting rule. - if ($LASTEXITCODE -ne 0) { + if ($exitCode -ne 0) + { Write-HookResult -AdditionalContext "CSharpier failed. The build treats formatting as an error, so fix this before building:`n$output" } - else { + else + { Write-HookResult } } -catch { +catch +{ Write-HookResult -AdditionalContext "tidy-code hook error: $($_.Exception.Message)" } From 67d80d76befc0fdf57b5103ec2bd54bf6742a9ce Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 03:28:33 +0200 Subject: [PATCH 09/17] docs: state one policy for agents and contributors, and make the reviewers read-only The agent guidance and CONTRIBUTING.md described the same repository differently, which is how an agent ends up doing what a contributor is told not to. They now say the same things in the same words: the branch convention with no agent-specific prefix, the commit convention, the companion edits a change needs, and that a version bump is the maintainer's at release time and nobody else's. - The two custom reviewers lose `Bash` and keep Read, Grep and Glob. They review; they report findings and cite file and line. A reviewer that can run a shell can also write a file, and "please do not edit" is not a sandbox. Where a checklist wants a diff or a build, it now says to ask the caller for it. - The `model:` and `model_reasoning_effort` pins are removed. They are the operator's choice, not the repository's, and a pinned model in a checked-in agent definition goes stale silently. - .agents/README.md documents the hooks the previous commit scoped: what they format, the three rules that keep them from reaching anything else, why each adapter runs a child process, how to review and trust the declared hooks in Codex, and what to run by hand when no hook covered the edit - a shell redirection, an editor outside the agent, a `git apply`, or an untrusted session. - The links that pointed at the root DESIGN-DECISIONS.md follow it under docs/. Refs #28 Co-Authored-By: Claude Opus 5 --- .agents/README.md | 57 ++++++++++++++-- .agents/references/code-style.md | 30 ++++++-- .agents/references/reviews/aot-compat.md | 2 +- .agents/skills/commit/SKILL.md | 66 +++++++++--------- .agents/skills/integration-db/SKILL.md | 2 +- .claude/agents/adapter-parity-reviewer.md | 10 ++- .claude/agents/aot-compat-reviewer.md | 11 ++- .codex/agents/adapter-parity-reviewer.toml | 1 - .codex/agents/aot-compat-reviewer.toml | 1 - AGENTS.md | 79 +++++++++++++++------- 10 files changed, 184 insertions(+), 75 deletions(-) diff --git a/.agents/README.md b/.agents/README.md index 78b915d..f962dbc 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -29,10 +29,59 @@ Codex-only UI metadata and explicit-invocation policy. Claude needs thin skill w The hook adapters differ because Claude and Codex use different payload and response contracts. Both delegate all substantive behavior to the same scripts: `scripts/tidy-code.ps1` and `scripts/public-api-guard.ps1`. -The tidy hook runs the **default scope only** — CSharpier, under a second. Code style and member ordering are -not run on every edit: `dotnet format style` needs MSBuild and ReSharper loads the whole solution, and neither -belongs on the critical path of a single edit. All three are build errors, and `scripts/preflight.ps1` runs -`tidy-code.ps1 -Scope all` before a commit, so nothing reaches a pull request untidied. +## What the hooks do, and what they will not do + +Both `PostToolUse` hooks are **scoped to the file the triggering edit touched**. Claude reports it as +`tool_input.file_path`; Codex reports an `apply_patch` whose patch text is in `tool_input.command`, so the +adapter reads the `*** Add File:`, `*** Update File:` and `*** Move to:` headers out of it and skips +`*** Delete File:`. + +Three rules follow, and they are the point of the design: + +- **No fallback.** If the payload cannot be parsed, the hook formats nothing and says so in one line. It does + not fall back to "every file git reports as changed" — that would rewrite work in progress that this edit + never touched. +- **No path outside the repository.** Every path is resolved and then checked to be under the repository + root, so `../` and a symbolic link that points out of the tree are both refused. `bin/` and `obj/` are + refused too; generated output is not ours to format. +- **They never fail an edit.** A `PostToolUse` failure cannot undo an edit that already happened, so both + adapters exit 0 whatever went wrong and report it as text. Neither one stages a file, changes an API + snapshot, installs a tool, or runs a style, ordering, build or test pass. + +The child scripts run in a **child `pwsh` process**. They end with `exit`, which run in-process would end the +adapter before it could emit its protocol output — and a Codex hook that writes nothing is a hook that +failed. Concurrent edits serialize on a named mutex derived from the repository path, so two formatters +cannot run over one file. + +### Codex needs the hooks trusted, once per clone + +Codex does not run a repository's hooks until the project is trusted. Until then **nothing fires**. Run +`/hooks` in Codex to see the hook definitions this repository declares, review them, and trust the project. +Review them again whenever `.codex/hooks.json` or anything under `.codex/hooks/` changes in a pull request: +a hook is code that runs on your machine after every edit, and "it was already trusted" is not a review. + +Never bypass project trust to make a hook fire. + +### When no hook covers the edit + +The hooks only see edits made through a tool that reports one. An edit made another way — a shell +redirection, an editor outside the agent, a `git apply`, a Codex session whose hooks are not trusted yet — is +not formatted by anything. So: + +```bash +pwsh -File scripts/tidy-code.ps1 # format what git reports as changed +pwsh -File scripts/preflight.ps1 # and before committing, check the whole tree +``` + +`preflight.ps1` is the backstop for all of it, and the build is the backstop for `preflight`: formatting, +style and member ordering are build errors, so an unformatted file cannot reach a green pull request whether +a hook fired or not. + +The tidy hook runs the **default scope only**, on the file the edit touched — CSharpier, under a second. Code +style and member ordering are not run on every edit: `dotnet format style` needs MSBuild and ReSharper loads +the whole solution, and neither belongs on the critical path of a single edit. All three are build errors, and +`scripts/preflight.ps1` checks `-Scope all` before a commit (`-Fix` applies it), so nothing reaches a pull +request untidied. When changing behavior, edit the canonical file. Keep only required names, descriptions, policies, tool/model settings, and reference instructions in tool-specific files. diff --git a/.agents/references/code-style.md b/.agents/references/code-style.md index 83f459b..bc3889f 100644 --- a/.agents/references/code-style.md +++ b/.agents/references/code-style.md @@ -10,7 +10,13 @@ not check. |---|---|---| | Formatting — whitespace, line breaks, wrapping | **CSharpier** | `.editorconfig` (`max_line_length`, `indent_size`) | | Style — `var`, `=>`, `this.`, null checks, usings | **Roslyn analyzers** | `.editorconfig` | -| Ordering — types and their members | **ReSharper** applies it, **NewStyleCop** checks it | `DbConnectionPlus.slnx.DotSettings` and `stylecop.json` | +| Ordering — types and their members | **ReSharper** applies it, **NewStyleCop** checks *part* of it | `DbConnectionPlus.slnx.DotSettings` and `stylecop.json` | + +The word *part* is load-bearing. StyleCop checks kind, access, constant, static and readonly — `SA1201`, +`SA1202`, `SA1203`, `SA1204`, `SA1214`. It has no notion of alphabetical order **within** one of those +groups, which the ReSharper file layout applies and nothing checks. A member that is in the right group but +the wrong place inside it compiles, passes the analyzers, and is only visible by running the pipeline and +looking at what it moves. Each tool owns its concern completely, and all three are build errors rather than warnings, in `tests/` and `benchmarks/` as much as in `src/`. Two different mechanisms, both in the root `Directory.Build.props`: @@ -70,6 +76,16 @@ explicit field and the compiler's capture field are one field, not two. ReSharper puts it by default, at the end; giving events an entry breaks the build. The entries match `ImplementsInterface` **and** `Access Is="Private"` — without the access test they would also catch implicit implementations and pull `Equals(T)` away from `Equals(object)`. +- **Constructors sort static-first; everything else sorts by access first.** The file layout gives the + constructors entry `` ahead of ``, and only that entry. It is the one place where the + two order definitions had to be reconciled by hand: `stylecop.json` lists `accessibility` before `static`, + which for constructors would put a `public` instance constructor ahead of the static one. `Benchmarks.cs` + has both, in that order, and the build is green — so this is load-bearing, not an oversight. Do not + "regularise" it. +- **Static fields are not reordered past each other.** The file layout sets + `StaticFieldReorderingPolicy="Strict"`, so ReSharper leaves a static field where it found it relative to the + other static fields of its type. Moving one can change the order its initializer runs in, and that is a + behaviour change no formatter is allowed to make. Order them by hand if you need them ordered. - **Overloads with the same name have no defined order between them.** Two methods called `Equals` tie on every key the file layout sorts by — kind, access, static, readonly and name — and ReSharper's sort is stable, so it leaves them in whatever order it found them. Reordering the same file starting from two @@ -90,14 +106,16 @@ The benchmarks are the exception: nothing consumes them as an API, so they use p ## Member order -StyleCop's order, applied by ReSharper and checked by NewStyleCop. Write a new member straight into the right -place rather than relying on the fixer: +StyleCop's order, applied by ReSharper. Write a new member straight into the right place rather than relying +on the fixer — and note that only the first five keys below are checked by an analyzer: constants → fields → constructors → finalizers → delegates → events → enums → interfaces → properties → indexers → conversion operators → operators → methods → nested structs → nested classes Within each of those groups: public before internal before protected before private, static before instance, -readonly before mutable, and **alphabetical** after that. +readonly before mutable, and **alphabetical** after that. The kind, access, constant, static and readonly keys +are `SA1201`/`SA1202`/`SA1203`/`SA1204`/`SA1214` and are build errors. Alphabetical order is applied by +ReSharper and checked by nothing. Note that fields go at the **top** of a type, and that explicit interface implementations sort ahead of the ordinary methods — with the exception for events described above. @@ -108,6 +126,10 @@ These need no attention beyond letting the tools run — they are listed here so stays short, not because they are optional: - **Primary constructors** wherever `IDE0290` asks for one. +- **`var` where the type is obvious** — a built-in type, or a right-hand side that names the type. Both are + errors. Everywhere else the preference is the explicit type, stated in `.editorconfig` as + `csharp_style_var_elsewhere = false`, but it is *not* enforced: it is a preference the build does not fail + on, so an existing `var` in that position is not a defect. - **Expression-bodied members** are `error`-severity for methods, constructors, operators, properties, indexers, accessors, lambdas and local functions. Use `=>` wherever a member is a single expression. - **File-scoped namespaces**, with usings outside the namespace. diff --git a/.agents/references/reviews/aot-compat.md b/.agents/references/reviews/aot-compat.md index 31b2861..eef6c4e 100644 --- a/.agents/references/reviews/aot-compat.md +++ b/.agents/references/reviews/aot-compat.md @@ -4,7 +4,7 @@ Use this whenever a change touches reflection, dynamic dispatch, expression tree `src/`. This is a **standing** checklist — AOT support shipped in 4.0.0, and everything here exists to keep it from regressing. -Read the [Native AOT and Trimming](../../../DESIGN-DECISIONS.md#native-aot-and-trimming) section of +Read the [Native AOT and Trimming](../../../docs/DESIGN-DECISIONS.md#native-aot-and-trimming) section of DESIGN-DECISIONS.md before reviewing: it records what is deliberate, and therefore what counts as a regression, and it carries the measurements behind each decision. diff --git a/.agents/skills/commit/SKILL.md b/.agents/skills/commit/SKILL.md index ee3e096..5135168 100644 --- a/.agents/skills/commit/SKILL.md +++ b/.agents/skills/commit/SKILL.md @@ -5,7 +5,10 @@ description: Review, verify, deliberately stage, and commit the current DbConnec # Commit -Write a commit that matches how `main` is written, and check the repo's own release-hygiene rules first. +Write a commit that matches how `main` is written, and check the repository's own release-hygiene rules first. + +**This skill commits. It never pushes, never opens a pull request, and never tags.** Those are separate acts +and the user asks for them separately. ## 1. Look at what changed @@ -26,60 +29,57 @@ Before committing, check whether the change requires companion edits and raise a - **Public API changed?** The build already told you — an undeclared public member is `RS0016` and a vanished one `RS0017`. Record it with `pwsh -File scripts/update-public-api.ps1` and review the `PublicAPI.Unshipped.txt` diff; a `*REMOVED*` line is a break. -- **User-facing change?** `CHANGELOG.md` needs an entry under `## [Unreleased]` or the next version heading, - Keep-a-Changelog format (`### Added` / `### Changed` / `### Fixed`). Breaking changes are written - `- **BREAKING:** …` and a `### Migration from Nx` section is added for a major. -- **Interface or behaviour change?** `README.md` — the "API summary" section and any affected examples. The - README also carries version numbers in its examples. `PACKAGE_README.md` (the NuGet package page) only needs - touching if the change makes its short overview wrong. -- **SemVer bump?** `` in `src/Directory.Build.props` — one edit, applied to all six shipping projects. +- **User-facing change?** `CHANGELOG.md` needs an entry under `## [Unreleased]`, in Keep-a-Changelog format + (`### Added` / `### Changed` / `### Fixed`). Breaking changes are written `- **BREAKING:** …`. Internal + formatting and tooling work needs no entry. +- **Interface or behaviour change?** The affected pages under `docs/` — the guides carry the examples, and + `docs/reference/api-summary.md` carries the one-line index. `PACKAGE_README.md` (the NuGet package page) + only needs touching if the change makes its short overview wrong. - **Adapter change?** Was it mirrored into the other four adapters? Delegate the check to the `adapter_parity_reviewer` custom agent when the change meets that agent's scope. - **Touched a reflection path?** `pwsh -File scripts/verify-package-aot.ps1 -Pack` — the unit and integration suites cannot see silent trimming damage. Delegate the review to the `aot_compat_reviewer` custom agent. +⚠️ **Do not bump a version, and do not date a changelog section.** The version in the repository-root +`Directory.Build.props`, the release date, promoting `PublicAPI.Unshipped.txt` to `Shipped`, and the tag are +the maintainer's, at release time. If the change looks like it needs a release, say so — do not perform one. + ## 3. Verify it builds -`TreatWarningsAsErrors=true` means a style slip is a build break, and CONTRIBUTING.md requires "all tests pass -and the build succeeds with no warnings". +`TreatWarningsAsErrors=true` means a style slip is a build break, and CONTRIBUTING.md requires that the build +succeeds with no warnings and the tests pass. ```bash pwsh -File scripts/preflight.ps1 ``` -That script runs the public-API reminder, the Release build and the unit tests. Equivalent by hand: +That runs the public-API reminder, checks style, formatting and member ordering, builds Release and runs the +unit suite on both target frameworks. It does not edit your files; if it reports the tree as untidy, run +`pwsh -File scripts/preflight.ps1 -Fix` and review what changed before committing it. -```bash -dotnet build DbConnectionPlus.slnx -c Release -``` - -```bash -dotnet test --project tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj -``` - -If either fails, report the failure and stop — don't commit over it. +If either fails, report the failure and stop — do not commit over it. ## 4. Write the message -Conventional Commits, matching `main`'s history: +[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/), with a **lowercase, imperative** +summary: -``` -feat: Implement feature Optimistic Concurrency Support via Concurrency Tokens -fix: NameHelper.CreateNameFromCallerArgumentExpression stops scanning to early -BREAKING CHANGE: Rename NuGet packages +```text +feat: add bulk insert for value tuples +fix: stop NameHelper scanning past the closing bracket +build: standardize repository tooling ``` -- Subject line: type prefix, then a capitalised, descriptive summary. Imperative or descriptive both appear in - this history — match the surrounding style. -- Types in use: `feat`, `fix`, `BREAKING CHANGE`. Use `docs`, `test`, `refactor`, `chore`, `build` where they - genuinely fit. +- Types in use: `feat`, `fix`, `docs`, `test`, `refactor`, `chore`, `build`, `perf`, `ci`. +- A breaking change is `feat!:` or `fix!:` plus a `BREAKING CHANGE:` **footer** saying what breaks and what to + do about it. `BREAKING CHANGE` is a footer, never a type. - Add a body when the *why* is not obvious from the subject. -- If the branch is `feature/-` or `bugfix/-`, reference the issue number in the body. +- If the branch is `/issue--`, reference the issue number in the body. Stage deliberately — `git add` the relevant paths rather than `git add -A`, and confirm nothing unintended -(build output, local scratch files) is included. A `PublicAPI.*.txt` change belongs in the same commit as -the code that caused it. +(build output, local scratch files) is included. A `PublicAPI.*.txt` change belongs in the same commit as the +code that caused it. ## 5. Commit -Commit only. Do not push and do not open a PR unless the user asks. +Commit only. Do not push and do not open a pull request unless the user asks. diff --git a/.agents/skills/integration-db/SKILL.md b/.agents/skills/integration-db/SKILL.md index 937db8d..33986aa 100644 --- a/.agents/skills/integration-db/SKILL.md +++ b/.agents/skills/integration-db/SKILL.md @@ -51,7 +51,7 @@ To scope to one database system, add a class filter — e.g. `--filter-class "*S ## Scoping the run — don't pay 10 minutes for every iteration **First decide whether you need database tests at all.** They are not part of the default verification loop — -`scripts/preflight.ps1` (hygiene + Release build + unit tests) is. Reach for the integration suite when the +`scripts/preflight.ps1` (hygiene, tidiness, Release build and the unit suite) is. Reach for the integration suite when the change can only be proven against a real database: SQL generation, an adapter, the CRUD or temp-table paths, type mapping, or anything a substituted `DbDataReader` cannot exercise honestly. A refactor whose behaviour the unit tests already pin down does not need them. diff --git a/.claude/agents/adapter-parity-reviewer.md b/.claude/agents/adapter-parity-reviewer.md index 3d0c5fb..7a5f777 100644 --- a/.claude/agents/adapter-parity-reviewer.md +++ b/.claude/agents/adapter-parity-reviewer.md @@ -1,11 +1,17 @@ --- name: adapter-parity-reviewer description: Checks whether a change to one database adapter was correctly mirrored into the other four. Use after editing anything under src/DbConnectionPlus.DatabaseAdapters.*, or after changing IDatabaseAdapter, IEntityManipulator, or ITemporaryTableBuilder in core. -tools: Read, Grep, Glob, Bash -model: sonnet +tools: Read, Grep, Glob --- # Adapter parity reviewer Read [.agents/references/reviews/adapter-parity.md](../../.agents/references/reviews/adapter-parity.md) in full before doing anything, then follow it exactly. + +This is a **review**: report findings, cite file and line for each, and change nothing. The tools above are +read-only by construction — there is no Edit, no Write, and no Bash, because a reviewer that can run a shell +can also write a file, and "please do not edit" is not a sandbox. + +Everything this checklist needs is a file read or a search. Where it asks for a diff, ask the caller for one +rather than running git yourself. diff --git a/.claude/agents/aot-compat-reviewer.md b/.claude/agents/aot-compat-reviewer.md index 6e20ee3..bb85c6d 100644 --- a/.claude/agents/aot-compat-reviewer.md +++ b/.claude/agents/aot-compat-reviewer.md @@ -1,11 +1,18 @@ --- name: aot-compat-reviewer description: Reviews changes for Native AOT and trimming compatibility. Use whenever code touches reflection, dynamic dispatch, expression trees, or generic instantiation in src/. -tools: Read, Grep, Glob, Bash -model: sonnet +tools: Read, Grep, Glob --- # Native AOT compatibility reviewer Read [.agents/references/reviews/aot-compat.md](../../.agents/references/reviews/aot-compat.md) in full before doing anything, then follow it exactly. + +This is a **review**: report findings, cite file and line for each, and change nothing. The tools above are +read-only by construction — there is no Edit, no Write, and no Bash, because a reviewer that can run a shell +can also write a file, and "please do not edit" is not a sandbox. + +The checklist's text searches are Grep searches. Where it names a build or the Native AOT gate as the way to +measure something, report that it needs running and let the caller run it — a reviewer states what it found, +not what it fixed. diff --git a/.codex/agents/adapter-parity-reviewer.toml b/.codex/agents/adapter-parity-reviewer.toml index 6718b5c..0951003 100644 --- a/.codex/agents/adapter-parity-reviewer.toml +++ b/.codex/agents/adapter-parity-reviewer.toml @@ -1,6 +1,5 @@ name = "adapter_parity_reviewer" description = "Review whether database-adapter or shared adapter-seam changes were mirrored correctly across MySQL, Oracle, PostgreSQL, SQLite, and SQL Server implementations and tests." -model_reasoning_effort = "high" sandbox_mode = "read-only" developer_instructions = """ Read `.agents/references/reviews/adapter-parity.md` in full before doing anything, then follow it exactly. diff --git a/.codex/agents/aot-compat-reviewer.toml b/.codex/agents/aot-compat-reviewer.toml index 97dd68a..5298184 100644 --- a/.codex/agents/aot-compat-reviewer.toml +++ b/.codex/agents/aot-compat-reviewer.toml @@ -1,6 +1,5 @@ name = "aot_compat_reviewer" description = "Review changes for Native AOT and trimming regressions when code touches reflection, dynamic dispatch, expression trees, generic instantiation, materializers, or temporary-table readers in src/." -model_reasoning_effort = "high" sandbox_mode = "read-only" developer_instructions = """ Read `.agents/references/reviews/aot-compat.md` in full before doing anything, then follow it exactly. diff --git a/AGENTS.md b/AGENTS.md index d16036e..422d875 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,18 +26,31 @@ methods on `DbConnection`, with per-database dialect support from pluggable adap The solution file is `DbConnectionPlus.slnx` (XML `.slnx`, not `.sln`). **New projects must be added to it.** -Build properties live in two `Directory.Build.props` files, not in the `.csproj` files: the repo-root one for all -nine solution projects (shared metadata, and the **style gate** — `EnforceCodeStyleInBuild` + -`TreatWarningsAsErrors`), and `src/Directory.Build.props` for the six shipping projects (``, -`TargetFrameworks`, `IsAotCompatible`, `AnalysisLevel=latest-all`, the AOT and public-API analyzers, the package -metadata). The `src/` one imports the root explicitly, because MSBuild stops at the nearest file, and it is where -`AnalysisLevel=latest-all` has to stay — CA1707 alone objects 2100 times to the test suite's -`Method_ShouldDoSomething` naming. Target frameworks differ per project on purpose: benchmarks `net10.0`, unit -tests `net8.0;net10.0` (the shipping libraries' two builds are not the same code), integration tests `net8.0`. +Build settings live in shared files, not in the `.csproj` files: + +| File | Covers | Carries | +|---|---|---| +| `Directory.Build.props` | all nine solution projects | shared metadata, ``, the **style gate** (`EnforceCodeStyleInBuild` + `TreatWarningsAsErrors`), `IsPackable=false`, the dependency audit | +| `Directory.Build.targets` | all nine | the files the packages carry, conditioned on `IsPackable` — which is why they cannot be in a `.props` file | +| `Directory.Packages.props` | all nine | **every dependency version**. A `PackageReference` here carries no `Version`; adding one is `NU1008` | +| `src/Directory.Build.props` | the six shipping projects | `TargetFrameworks`, `IsAotCompatible`, `AnalysisLevel=latest-all`, the AOT and public-API analyzers, the package metadata and package validation | +| `tests/Directory.Build.props` | the two test projects | `OutputType=Exe`, the xUnit v3 / Microsoft.Testing.Platform references and the coverage extension | + +MSBuild stops at the nearest `Directory.Build.props`, so the `src/` and `tests/` ones **import the root +explicitly** — without that import their projects would silently lose all of it. `AnalysisLevel=latest-all` has +to stay under `src/`: CA1707 alone objects 2100 times to the test suite's `Method_ShouldDoSomething` naming. + +`` is one edit for all six packages, at the repository root. `scripts/verify-package-aot.ps1` reads it +from there when `-PackageVersion` is omitted. **Packing is opt-in**: the root sets `IsPackable=false` and each of +the six shipping projects sets `IsPackable=true` itself, so a new project ships nothing by accident. + +Target frameworks differ per project on purpose: benchmarks `net10.0`, unit tests `net8.0;net10.0` (the shipping +libraries' two builds are not the same code), integration tests `net8.0`. `tests/package-consumption/` sits **outside** all of this on purpose. It carries its own empty -`Directory.Build.props`/`.targets` that stop MSBuild's upward search, so those projects get the library only from -the packed packages. Do not "fix" that by deleting the empty files. +`Directory.Build.props`/`.targets`, plus a `Directory.Packages.props` that turns central package management back +off, all of which stop the upward search — so those projects get the library only from the packed packages, at a +version CI hands them. Do not "fix" that by deleting the three files. **Two readmes, and they are not interchangeable.** `README.md` is the repository's reference documentation, what a GitHub visitor reads, and what an API change updates. `PACKAGE_README.md` is the short overview nuget.org renders @@ -58,10 +71,15 @@ a miss, and that needs Docker. Run the adapter-parity reviewer, walk ```bash dotnet build DbConnectionPlus.slnx -c Release dotnet test --project tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj -pwsh -File scripts/preflight.ps1 # both, plus hygiene and tidying — the default loop +pwsh -File scripts/preflight.ps1 # the default loop: hygiene, tidiness CHECK, build, unit tests +pwsh -File scripts/preflight.ps1 -Fix # the same, but tidy the working tree first pwsh -File scripts/verify-package-aot.ps1 -Pack # the Native AOT gate ``` +`preflight.ps1` **writes build output and nothing else by default** — it does not edit source and it does not +touch the git index. `-Fix` is what rewrites files. `tidy-code.ps1 -Check` never writes at any scope: at +`-Scope all` it runs the pipeline on a disposable copy of the tree and prints the diff from there. + Search the **whole repository** when a change touches a shared symbol. A search scoped to `src/` will miss call sites in `tests/` and `benchmarks/`; `EntityHelper.GetEntityTypeMetadata`, for scale, is used in 18 files across eight projects. The Release build covers every project, so it turns a missed call site into a build error. @@ -123,10 +141,10 @@ pwsh -File scripts/tidy-code.ps1 -Scope style # ~15s + the code-style fixers pwsh -File scripts/tidy-code.ps1 -Scope all # ~3min + member ordering, whole solution ``` -**Before you commit, run `-Scope all`** — or `scripts/preflight.ps1`, which does it for you. That is the only scope -that reorders members, because ReSharper loads the whole solution either way. Claude Code and Codex run the -**default scope** on every `.cs` edit through a PostToolUse hook; style and ordering are not run there, because -they are too slow for a single edit and the build catches them. +**Before you commit, run `-Scope all`** — or `scripts/preflight.ps1 -Fix`, which does it for you. That is the +only scope that reorders members, because ReSharper loads the whole solution either way. Claude Code and Codex +run the **default scope** on the file each edit touched, through a PostToolUse hook; style and ordering are not +run there, because they are too slow for a single edit and the build catches them. ## Tests @@ -144,13 +162,13 @@ an undeclared public member, `RS0017` for a declared one that is gone, both erro public surface cannot compile. Record a deliberate one with `pwsh -File scripts/update-public-api.ps1`, then **review the diff**: it is the public-API change. Per [CONTRIBUTING.md](CONTRIBUTING.md) it also needs a `CHANGELOG.md` entry, a `README.md` update and a SemVer bump in -`src/Directory.Build.props`. A `*REMOVED*` line is a break. `-MarkShipped` folds `Unshipped` into `Shipped` at +the repository-root `Directory.Build.props`. A `*REMOVED*` line is a break. `-MarkShipped` folds `Unshipped` into `Shipped` at release time. ## Native AOT support The **reflection paths are AOT-safe**: no companion package, no source generator, no consumer opt-in. Rationale and -measurements: [DESIGN-DECISIONS.md](DESIGN-DECISIONS.md#native-aot-and-trimming). Before changing anything that +measurements: [docs/DESIGN-DECISIONS.md](docs/DESIGN-DECISIONS.md#native-aot-and-trimming). Before changing anything that reflects, run the AOT/trim reviewer or walk [its checklist](.agents/references/reviews/aot-compat.md). Three constraints must not be broken: @@ -164,16 +182,22 @@ constraints must not be broken: suppressed; the code-style rules name the two sanctioned exceptions. - **No consumer-facing diagnostics.** The generic query methods carry neither `[RequiresUnreferencedCode]` nor `[RequiresDynamicCode]`; adding either to a public API is a regression the AOT consumer's zero-diagnostic gate - fails on. The argument: [DESIGN-DECISIONS.md](DESIGN-DECISIONS.md#4-no-consumer-facing-diagnostics). + fails on. The argument: [docs/DESIGN-DECISIONS.md](docs/DESIGN-DECISIONS.md#4-no-consumer-facing-diagnostics). ## Conventions -- Branches: [Conventional Branch](https://conventionalbranch.org/) — `/issue--` off `main`, - types `feature/ bugfix/ hotfix/ release/ chore/`. An agent on its own branch may use `claude/` or `codex/`. -- Commits: Conventional Commits — `feat:`, `fix:`, `BREAKING CHANGE:`. Full checklist: - [the `commit` skill](.agents/skills/commit/SKILL.md). `CHANGELOG.md` follows - [Keep a Changelog](https://keepachangelog.com/); versioning is SemVer. -- Pull request process: [CONTRIBUTING.md](CONTRIBUTING.md#pull-request-process). +- Branches: [Conventional Branch](https://conventionalbranch.org/) — `/issue--` off + `main`, or `/` when there is no issue. Types: `feature bugfix hotfix release chore`. **The same + rule applies to you.** There is no `claude/` or `codex/` prefix — a branch is named after what it does, not + after who typed it. +- Commits: [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) with a **lowercase, + imperative** summary — `build: standardize repository tooling`. A breaking change is `feat!:` or `fix!:` + plus a `BREAKING CHANGE:` **footer**; `BREAKING CHANGE` is never a type. Full checklist: + [the `commit` skill](.agents/skills/commit/SKILL.md). +- `CHANGELOG.md` follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/): user-visible changes go + under `## [Unreleased]`. Internal formatting and tooling work needs no entry. **Never bump a version** — + the version, the release date, the API-snapshot promotion and the tag are the maintainer's. +- Pull request process: [CONTRIBUTING.md](CONTRIBUTING.md#opening-the-pull-request). - Line endings are LF everywhere; `.gitattributes` and `.editorconfig` enforce this and CI verifies it. Never hand-convert line endings, and never compare a multi-line source literal against `Environment.NewLine` - the literal carries the file's bytes, `Environment.NewLine` carries the host's. @@ -191,8 +215,11 @@ Two skills and two review agents are checked in, each under a Codex and a Claude `adapter-parity-reviewer`. Claude Code (`.claude/settings.json`) and Codex (`.codex/hooks.json`) fire the same two PostToolUse hooks — -formatting and the public-API reminder — and both delegate to `scripts/`. Codex needs those hooks trusted once per -clone (`/hooks`); until then nothing fires and you run `scripts/tidy-code.ps1` yourself. +formatting and the public-API reminder — and both delegate to `scripts/`. Each one is **scoped to the file the +edit touched**: no fallback to every dirty file, no path outside the repository, and they never fail an edit. +Codex needs those hooks trusted once per clone (`/hooks`), and reviewed again whenever a pull request changes +one; until then nothing fires and you run `scripts/tidy-code.ps1` yourself. Details, including what to do when +no hook covers the edit: [.agents/README.md](.agents/README.md). Reusable skills and reference material belong in `.agents/`, executable checks in `scripts/`, and only metadata and hook wiring in `.codex/` and `.claude/`. Never fork a procedure or a check into a tool-specific copy — both From c9ebb4577bcf96c1e3beca36c9d5e82c32ab98e8 Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 03:28:48 +0200 Subject: [PATCH 10/17] ci: group the dependency updates and ignore only generated output Dependabot ran monthly and split what is reviewed together. It now runs quarterly with grouped pull requests: one review of "what does the build reject now" instead of one per analyzer, and one driver review instead of five. Drivers, analyzers and the test stack take minor and patch only, because a major version of a driver is a breaking change for the adapter that wraps it and deserves its own integration run. The coupled families - AutoFixture, Testcontainers, xunit, the CodeQL actions - move together at every update type, including major, because they will not resolve against each other across a major boundary and a split major produces a branch that cannot restore. Security updates are untouched and stay immediate. The quarterly cadence is about noise; a published advisory is not noise, and NuGetAudit reports the same advisories in every build in the meantime. The NSubstitute ignore was re-verified against AutoFixture.AutoNSubstitute's own nuspec rather than assumed: the [2.0.3, 6.0.0) constraint is still there. .gitignore now ignores generated output and nothing else. It ignored `**/AssemblyInfo.cs`, which would silently hide a hand-written one - an ignore rule that matches an authored file is worse than no rule, because the file never gets added and the build works for you and for nobody else. The bare `obj` and `bin` entries are anchored so a source directory that happens to be called `bin` is not swallowed. The pull-request checklist stops asking every contributor to confirm items that do not apply to their change - the AOT gate, the integration run, the public-API snapshot are conditional now - and no longer asks for a version bump. `docfx.json` moved to build/docfx/ in an earlier commit; the workflow's DOCFX_CONFIG follows it. Refs #28 Co-Authored-By: Claude Opus 5 --- .github/PULL_REQUEST_TEMPLATE.md | 52 ++++++++++++++----- .github/dependabot.yml | 86 ++++++++++++++++++++++++++++---- .github/workflows/ci.yml | 47 ++++++++++++----- .gitignore | 56 ++++++++++++++------- 4 files changed, 189 insertions(+), 52 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5f45a2e..2e7d2dd 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -6,15 +6,43 @@ ## Checklist -- [ ] `dotnet build DbConnectionPlus.slnx -c Release` succeeds with **zero warnings** (`TreatWarningsAsErrors` is on, so this also covers style, trim and public-API diagnostics). -- [ ] `pwsh -File scripts/preflight.ps1` passes. -- [ ] New behavior and fixed bugs are covered by tests. -- [ ] **Adapter parity**: a change to one adapter is mirrored into the other four, or does not apply to them (see `.agents/references/reviews/adapter-parity.md`). -- [ ] Integration tests run for every adapter the change touches (`.agents/skills/integration-db/SKILL.md`). -- [ ] No new `IL2xxx`/`IL3050` diagnostics. If a reflection path changed: `pwsh -File scripts/verify-package-aot.ps1 -Pack` passes for **both** frameworks. -- [ ] Public API changes are declared in the affected `PublicAPI.Unshipped.txt` (`scripts/update-public-api.ps1`). -- [ ] XML docs and `README.md` updated for public API changes. -- [ ] `CHANGELOG.md` updated, and the version in `src/Directory.Build.props` bumped if this release-bound change needs it. -- [ ] Style, formatting and member ordering applied (`pwsh -File scripts/tidy-code.ps1 -Scope all`, which - `preflight.ps1` also runs). -- [ ] Branch name follows [Conventional Branch](https://conventionalbranch.org/): `/issue--`. +Everything under **Always** applies to every pull request. The rest applies only when its trigger does — +tick it, or leave it and say why in the description. A box that does not apply is not a box to tick. + +### Always + +- [ ] `pwsh -File scripts/preflight.ps1` passes. It runs the public-API reminder, checks style, formatting + and member ordering, builds Release and runs the unit suite on both target frameworks. Use + `-Fix` to have it apply the tidying rather than only report it. +- [ ] The Release build produces **zero warnings**. `TreatWarningsAsErrors` is on, so this also covers style, + member ordering, trim and public-API diagnostics. +- [ ] Branch name follows [Conventional Branch](https://conventionalbranch.org/): + `/issue--`, or `/` when there is no issue. + +### If the change adds behaviour or fixes a bug + +- [ ] It is covered by tests. +- [ ] `CHANGELOG.md` has an entry under `## [Unreleased]`. Do **not** bump a version — the version, the + release date and the tag are the maintainer's, at release time. + +### If the change touches a database adapter, or the shared adapter seam + +- [ ] It is mirrored into the other four adapters, or it genuinely does not apply to them — see + [CONTRIBUTING.md](../CONTRIBUTING.md#database-adapters). +- [ ] The integration tests ran for every adapter the change touches, and the description says which. + +### If the change touches a reflection path + +Reflection, the `[DynamicallyAccessedMembers]` annotations, the materializers, or the temporary-table +readers. Nothing is trimmed on the just-in-time compiler, so no other check in this repository can see the +damage a mistake here does. + +- [ ] `pwsh -File scripts/verify-package-aot.ps1 -Pack` passes for **both** frameworks + (`-Framework net8.0` and the `net10.0` default). +- [ ] No new `IL2xxx` / `IL3050` diagnostics, and none suppressed. + +### If the change touches the public API + +- [ ] The affected `PublicAPI.Unshipped.txt` is updated with `pwsh -File scripts/update-public-api.ps1`, and + the diff was reviewed line by line. A `*REMOVED*` entry is a breaking change. +- [ ] The XML documentation and the affected pages under `docs/` are updated. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index c12d156..56f95ec 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,29 +1,69 @@ +# Dependabot configuration. +# +# This file governs VERSION updates only - the routine "a new version exists" pull requests. Dependabot +# SECURITY updates are a separate, repository-level setting and are deliberately left enabled and immediate: +# the quarterly cadence below is about noise, and a published advisory is not noise. `NuGetAudit` in +# Directory.Build.props reports the same advisories in every local build and in CI, so a vulnerable +# transitive package is visible long before a pull request arrives. +# +# Quarterly rather than monthly because these dependencies move faster than this repository needs them to. +# A grouped quarterly pull request is reviewed; twelve monthly ones are skimmed. version: 2 updates: - package-ecosystem: nuget directory: "/" schedule: - interval: monthly + interval: quarterly + commit-message: + # Conventional Commits, so a Dependabot branch reads like every other commit in the history and the + # changelog tooling does not have to special-case it. `build:` is right for a dependency that ships, + # `chore:` for one that only the build or the tests see. + prefix: build + prefix-development: chore + include: scope groups: # The five vendor ADO.NET drivers. They are independent of each other, but a driver bump is always - # reviewed the same way - run that adapter's integration tests - so one PR per week beats five. + # reviewed the same way - run that adapter's integration tests - so one pull request beats five. + # + # Minor and patch only: a major version of a driver is a breaking change for the adapter that wraps it, + # and it deserves its own pull request with its own integration run. database-drivers: + applies-to: version-updates + update-types: + - minor + - patch patterns: - "Microsoft.Data.SqlClient" - "Microsoft.Data.Sqlite*" - "MySqlConnector" - "Npgsql" - "Oracle.ManagedDataAccess*" - # Analyzers only ever change what the build rejects, never what ships. Grouping them keeps a week's - # worth of new diagnostics in one PR instead of one per analyzer package. - analyzers: + # Analyzers and the formatter. They only ever change what the build REJECTS, never what ships, so a + # group of them is one review of "what does the build complain about now" instead of one per package. + # + # CSharpier.MsBuild is in this group and needs a companion edit every time: the csharpier entry in + # .config/dotnet-tools.json has to move with it, or the build demands formatting the tool does not + # produce. Dependabot cannot make that edit. + analyzers-and-formatters: + applies-to: version-updates + update-types: + - minor + - patch patterns: + - "CSharpier.MsBuild" - "ErrorProne.NET.*" - "Microsoft.CodeAnalysis.PublicApiAnalyzers" - - "Roslynator.*" + - "NewStyleCop.Analyzers" - "NSubstitute.Analyzers.*" - # The test stack. A bump here can never affect a consumer. + - "Roslynator.*" + - "SonarAnalyzer.CSharp" + # The test stack. A bump here can never affect a consumer: none of these packages is referenced by a + # shipping project or appears in any package's dependency graph. test-stack: + applies-to: version-updates + update-types: + - minor + - patch patterns: - "AutoFixture*" - "AwesomeAssertions" @@ -32,19 +72,43 @@ updates: - "Mapster" - "NSubstitute" - "NSubstitute.Community.*" + - "RentADeveloper.ArgumentNullGuards" + - "System.Linq.AsyncEnumerable" + - "Testcontainers.*" + - "xunit*" + # Coupled families, at EVERY update type including major. These are packages that are versioned and + # released together and that will not resolve against each other across a major boundary, so splitting + # a major across pull requests produces a branch that cannot restore. + autofixture: + applies-to: version-updates + patterns: + - "AutoFixture*" + testcontainers: + applies-to: version-updates + patterns: + - "Testcontainers.*" + xunit: + applies-to: version-updates + patterns: - "xunit*" ignore: - # AutoFixture.AutoNSubstitute 4.18.1 requires NSubstitute [2.0.3, 6.0.0). Remove this once - # AutoFixture 5.0.0 ships as a stable release (5.0.0-rc.1 is a pre-release). + # AutoFixture.AutoNSubstitute 4.18.1 requires NSubstitute [2.0.3, 6.0.0) - verified against the + # package's own nuspec, not assumed. Remove this once AutoFixture 5.0.0 ships as a stable release + # (5.0.0-rc.1 is a pre-release), and check the constraint again before you do. - dependency-name: "NSubstitute" versions: [ ">=6.0.0" ] - + - package-ecosystem: github-actions directory: "/" schedule: interval: quarterly + commit-message: + prefix: chore + include: scope groups: - # Keep CodeQL action variants in sync since they're released together + # The CodeQL actions are released together and their versions have to match, so they move together at + # every update type - including major. codeql: + applies-to: version-updates patterns: - "github/codeql-action/*" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 32fc060..330abeb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,15 +22,15 @@ concurrency: env: CONFIGURATION: Release CONSUMER_DIRECTORY: tests/package-consumption - DOCFX_CONFIG: docs/docfx.json - DOCS_SITE: docs/_site + DOCFX_CONFIG: build/docfx/docfx.json + DOCS_SITE: artifacts/docs/site DOTNET_CLI_TELEMETRY_OPTOUT: true DOTNET_NOLOGO: true DOTNET_SKIP_FIRST_TIME_EXPERIENCE: true NUGET_CONFIG: NuGet.config NUGET_PACKAGES: ${{ github.workspace }}/.nuget/packages PACKAGE_OUTPUT: artifacts/packages - RELEASE_NOTES_FILE: release-notes.md + RELEASE_NOTES_FILE: artifacts/release/release-notes.md RELEASE_NOTES_SCRIPT: scripts/extract-release-notes.ps1 SOLUTION: DbConnectionPlus.slnx TEST_RESULTS: artifacts/test-results @@ -284,8 +284,11 @@ jobs: - name: Build DocFX metadata run: dotnet tool run docfx metadata ${{ env.DOCFX_CONFIG }} + # --warningsAsErrors, because a docfx warning is a broken cross-reference, a file the configuration + # points at that does not exist, or a link that resolves to nothing. Every one of those ships a + # documentation site with a hole in it, and none of them is visible unless the build refuses. - name: Build DocFX site - run: dotnet tool run docfx build ${{ env.DOCFX_CONFIG }} + run: dotnet tool run docfx build ${{ env.DOCFX_CONFIG }} --warningsAsErrors # IsPackable in the project files is the source of truth for which projects ship as packages: the six # under src/ do, the tests and the benchmarks do not. @@ -431,20 +434,39 @@ jobs: } JSON - - name: Confirm the active SDK is 8.0 + # An ASSERTION, not a print. Printing the version proves the step ran; it does not prove the job is + # testing what it claims to. If the 10.0 SDK were present on the runner and the pin above failed to + # apply, this job would quietly build the consumer on 10.0 and the documented net8.0 floor would go + # unverified while the job stayed green. + # + # working-directory matters and is not cosmetic here or in the step below: the SDK resolves global.json + # from the CURRENT directory upward, so running from the repository root would pick up the root + # global.json, which pins 10.0. + - name: Assert the active SDK is 8.0 shell: bash working-directory: ${{ env.CONSUMER_DIRECTORY }} - run: dotnet --version - - # working-directory matters and is not cosmetic: the SDK resolves global.json from the CURRENT - # DIRECTORY upward, so running this from the repository root would pick up the root global.json, which - # pins the 10.0 SDK - and the job would either fail outright or quietly test the wrong SDK on a runner - # image that happens to ship one. Run it where the 8.0 pin above lives. + run: | + version="$(dotnet --version)" + echo "Active SDK: ${version}" + case "${version}" in + 8.*) echo "The consumer subtree resolves an 8.0 SDK, as the pin requires." ;; + *) echo "::error title=Wrong SDK::The consumer subtree resolved SDK ${version}, not 8.0.x. The net8.0 floor is unverified in this run." + exit 1 ;; + esac + + # NUGET_PACKAGES is overridden for this step, to the consumer's own isolated cache. The environment + # variable takes precedence over the globalPackagesFolder setting in the consumer's nuget.config, and + # this workflow sets a workspace-wide NUGET_PACKAGES at the top - so without the override the consumer + # could restore a DbConnectionPlus assembly that did not come out of the packages under test. + # RestoreConfigFile names the consumer configuration explicitly for the same reason. - name: Run the all-adapters package consumer (net8.0 SDK) working-directory: ${{ env.CONSUMER_DIRECTORY }} + env: + NUGET_PACKAGES: ${{ github.workspace }}/${{ env.CONSUMER_DIRECTORY }}/.packages run: > dotnet run --project AllAdaptersConsumer/AllAdaptersConsumer.csproj --configuration ${{ env.CONFIGURATION }} + -p:RestoreConfigFile=${{ github.workspace }}/${{ env.CONSUMER_DIRECTORY }}/nuget.config -p:DbConnectionPlusVersion=${{ needs.package-and-docs.outputs.version }} # Publish the DocFX site to GitHub Pages on every push to main (requires Pages to be set to @@ -509,7 +531,8 @@ jobs: shell: pwsh run: ./${{ env.RELEASE_NOTES_SCRIPT }} -Version "$($env:GITHUB_REF_NAME -replace '^v', '')" - # The in src/Directory.Build.props is the single source of truth for all six packages; + # The in the repository-root Directory.Build.props is the single source of truth for all six + # packages; # tagging a different version would otherwise publish stale packages, or silently publish nothing at # all thanks to --skip-duplicate. - name: Verify the tag matches the packed version diff --git a/.gitignore b/.gitignore index 78612b2..de49270 100644 --- a/.gitignore +++ b/.gitignore @@ -1,29 +1,51 @@ -obj +# Only generated output belongs here. +# +# An ignore rule that matches an AUTHORED file is worse than no rule: the file is invisible to `git status`, +# so it is never added, and the failure shows up as a build that works for you and for nobody else. This file +# used to ignore `**/AssemblyInfo.cs`, which would have hidden a hand-written one; the generated one lives in +# obj/ and is covered by the first rule below. + +# --- Build output ------------------------------------------------------------------------------------- bin -.vs +obj -**/AssemblyInfo.cs -**/*.suo -**/*.DotSettings.user -**/*.csproj.user +# --- Repository-level generated directories ----------------------------------------------------------- +# artifacts/ holds everything this repository generates on purpose: packages, the AOT publish, the generated +# API metadata, the documentation site and the extracted release notes. +artifacts/ +BenchmarkDotNet.Artifacts/ -**/launchSettings.json -.sonarlint/ +# --- Test output -------------------------------------------------------------------------------------- +TestResults/ +*.trx +*.coverage +coverage.*.xml -BenchmarkDotNet.Artifacts/ -artifacts/ -docs/api -docs/_site -.idea/ +# --- Diagnostic logs ---------------------------------------------------------------------------------- +*.binlog +msbuild.log + +# --- Tool and package caches -------------------------------------------------------------------------- +.dotnet/ +.nuget/ +.sonarlint/ # The package consumers' isolated NuGet cache. See tests/package-consumption/nuget.config. tests/package-consumption/.packages/ -# Written by the CI "Consume packages on .NET 8 SDK" job to pin the SDK for the consumer subtree. +# Written by the CI "Consume packages on .NET 8 SDK" job to pin the SDK for the consumer subtree, and by a +# developer reproducing that job locally. Never committed - the repository's own global.json pins 10.0. tests/package-consumption/global.json -# Written by the CI publish and release jobs from the matching CHANGELOG.md section. -release-notes.md +# --- Editor and machine-local files ------------------------------------------------------------------- +.vs/ +.idea/ +**/*.suo +**/*.user +**/*.DotSettings.user +**/Properties/launchSettings.json -# Stale XML documentation files left next to the project files by an older build configuration. +# --- Stale generated documentation -------------------------------------------------------------------- +# XML documentation files an older build configuration wrote next to the project file. Nothing produces +# them now; the rule stays so that an old clone does not offer one for commit. src/*/RentADeveloper.*.xml From 8fd52d3af9bc60f574c256e503f69888bf718e91 Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 03:29:12 +0200 Subject: [PATCH 11/17] chore: point .git-blame-ignore-revs at the reindentation commit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The commit that re-indented the MSBuild and configuration files is mechanical: leading tabs to two spaces, 534 lines in 17 files, no non-whitespace byte moved. Recording it here keeps `git blame` on those files pointing at whoever wrote the line rather than at the transformation. It is a separate commit because a commit cannot record its own id. ⚠️ Merge this branch with a MERGE COMMIT. "Squash and merge" and "Rebase and merge" rewrite every SHA on the branch, the entry above stops resolving, and git does not warn - blame quietly goes back to pointing at the tool. CI's lint job fails on an unresolvable revision, which is what makes that visible. Refs #28 Co-Authored-By: Claude Opus 5 --- .git-blame-ignore-revs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 36e29a5..db76797 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -37,3 +37,8 @@ f80b2f75fa4a8331c1b63aef1686144560d1e904 # tests/package-consumption/ and the MySql primary constructor were applied by hand, because no tool # in the pipeline reaches them. 70f9ea67d055d9932c1ff9f5bf3204af9e1c78c4 + +# style: reindent the MSBuild and configuration files with two spaces +# Leading tabs to two spaces in the MSBuild, .slnx, .config, .xml and .DotSettings files. Whitespace +# only, 534 lines in 17 files; no .cs file was touched and no evaluated MSBuild property changed. +5ae159ab0031e0ea3ca373eb1c53495d3c633eb1 From 6442d2c2cf423a855d9890077bd8a6c1b5014864 Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 05:39:37 +0200 Subject: [PATCH 12/17] docs: keep the counts out of the prose and the comparison in the readmes Three unrelated fixes to the same class of problem - text that goes stale without anyone noticing. **Counts are gone.** "The six shipping libraries", "the two test projects", "all five adapters", "the four database servers", "both target frameworks": every one of those is a number that a new project, adapter or target framework silently falsifies, in about thirty places across the build files, the workflows, the agent guidance and the documentation. They now name what they mean - the shipping libraries, the adapters, net8.0 and net10.0 - and a number survives only where the items are listed in the same breath. **History is gone.** A reader wants to know how the repository works, not how it used to: what .gitignore once ignored, what clean-build-artifacts.ps1 once deleted, what readiness gate the CI job once carried by hand, and which sections once lived in the README. Each of those keeps the rule it was justifying and drops the story. **"Why not just use Dapper?" is back in README.md and PACKAGE_README.md**, as the first section after the introduction in both, which is where the question is actually asked. Moving it to a reference page put the one thing a new reader wants first behind a link. It is the same text as before, and docs/reference/ comparison.md is deleted rather than kept as a second copy that would drift. Also in .editorconfig: `csharp_style_var_elsewhere` becomes `true:silent`. It said the repository preferred an explicit type where the right-hand side does not name one, and the repository does not - `var` is used that way 455 times in src/ and the explicit form essentially never. It stays silent, because that case is a readability judgement rather than something a build should reject. The top-level-statements comment is removed; the setting says what it does. Refs #28 Co-Authored-By: Claude Opus 5 --- .agents/README.md | 4 +- .agents/references/code-style.md | 6 +- .agents/references/reviews/adapter-parity.md | 14 +- .agents/references/reviews/aot-compat.md | 4 +- .agents/skills/commit/SKILL.md | 4 +- .agents/skills/integration-db/SKILL.md | 6 +- .claude/agents/adapter-parity-reviewer.md | 2 +- .editorconfig | 25 +- .github/PULL_REQUEST_TEMPLATE.md | 4 +- .github/dependabot.yml | 4 +- .github/workflows/ci.yml | 24 +- .github/workflows/codeql.yml | 2 +- .gitignore | 6 +- AGENTS.md | 38 ++-- CONTRIBUTING.md | 20 +- Directory.Build.props | 14 +- Directory.Build.targets | 4 +- Directory.Packages.props | 10 +- PACKAGE_README.md | 83 ++++++- README.md | 213 +++++++++++++++--- SECURITY.md | 2 +- codecov.yml | 2 +- docs/DESIGN-DECISIONS.md | 24 +- docs/index.md | 5 +- docs/reference/comparison.md | 179 --------------- docs/toc.yml | 2 - scripts/clean-build-artifacts.ps1 | 10 +- scripts/preflight.ps1 | 2 +- scripts/public-api-guard.ps1 | 2 +- scripts/update-public-api.ps1 | 2 +- src/Directory.Build.props | 8 +- .../DbConnectionPlus.IntegrationTests.csproj | 2 +- .../DbConnectionPlus.UnitTests.csproj | 8 +- .../package-consumption/AotConsumer/README.md | 6 +- 34 files changed, 390 insertions(+), 351 deletions(-) delete mode 100644 docs/reference/comparison.md diff --git a/.agents/README.md b/.agents/README.md index f962dbc..80a681f 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -36,7 +36,7 @@ Both `PostToolUse` hooks are **scoped to the file the triggering edit touched**. adapter reads the `*** Add File:`, `*** Update File:` and `*** Move to:` headers out of it and skips `*** Delete File:`. -Three rules follow, and they are the point of the design: +These rules follow, and they are the point of the design: - **No fallback.** If the payload cannot be parsed, the hook formats nothing and says so in one line. It does not fall back to "every file git reports as changed" — that would rewrite work in progress that this edit @@ -79,7 +79,7 @@ a hook fired or not. The tidy hook runs the **default scope only**, on the file the edit touched — CSharpier, under a second. Code style and member ordering are not run on every edit: `dotnet format style` needs MSBuild and ReSharper loads -the whole solution, and neither belongs on the critical path of a single edit. All three are build errors, and +the whole solution, and neither belongs on the critical path of a single edit. All of them are build errors, and `scripts/preflight.ps1` checks `-Scope all` before a commit (`-Fix` applies it), so nothing reaches a pull request untidied. diff --git a/.agents/references/code-style.md b/.agents/references/code-style.md index bc3889f..cd0caea 100644 --- a/.agents/references/code-style.md +++ b/.agents/references/code-style.md @@ -4,7 +4,7 @@ Background for the rules in [AGENTS.md](../../AGENTS.md#code-style-formatting-an tool does something you did not expect, or when you are about to write a type name in a place the build does not check. -## Three concerns, three tools +## The concerns, and the tool that owns each | Concern | Tool | Configured in | |---|---|---| @@ -18,7 +18,7 @@ groups, which the ReSharper file layout applies and nothing checks. A member tha the wrong place inside it compiles, passes the analyzers, and is only visible by running the pipeline and looking at what it moves. -Each tool owns its concern completely, and all three are build errors rather than warnings, in `tests/` and +Each tool owns its concern completely, and every one of them is a build error rather than a warning, in `tests/` and `benchmarks/` as much as in `src/`. Two different mechanisms, both in the root `Directory.Build.props`: `EnforceCodeStyleInBuild=true` with `TreatWarningsAsErrors=true` covers style and ordering, and the `CSharpier.MsBuild` package covers formatting. It runs in check mode, so a build never rewrites your files — @@ -107,7 +107,7 @@ The benchmarks are the exception: nothing consumes them as an API, so they use p ## Member order StyleCop's order, applied by ReSharper. Write a new member straight into the right place rather than relying -on the fixer — and note that only the first five keys below are checked by an analyzer: +on the fixer — and note that only the leading keys below are checked by an analyzer: constants → fields → constructors → finalizers → delegates → events → enums → interfaces → properties → indexers → conversion operators → operators → methods → nested structs → nested classes diff --git a/.agents/references/reviews/adapter-parity.md b/.agents/references/reviews/adapter-parity.md index d5fab27..2531999 100644 --- a/.agents/references/reviews/adapter-parity.md +++ b/.agents/references/reviews/adapter-parity.md @@ -16,7 +16,7 @@ src/DbConnectionPlus.DatabaseAdapters.SqlServer Each contains `{Db}DatabaseAdapter.cs`, `{Db}EntityManipulator.cs`, `{Db}TemporaryTableBuilder.cs` and `{Db}ConfigurationExtensions.cs`. -A change to one adapter almost always has to be mirrored into the other four. The only thing that catches a +A change to one adapter almost always has to be mirrored into the others. The only thing that catches a miss is the integration suite, which needs Docker and about ten minutes for the full matrix — so it usually is not run. The job here is to catch it statically. @@ -29,21 +29,21 @@ This is a **review**: report findings, do not edit files. whether any core seam (`IDatabaseAdapter`, `IEntityManipulator`, `ITemporaryTableBuilder`, `DatabaseAdapters/Constants.cs`) changed. -2. **For each changed adapter member**, read the corresponding member in all four other adapters and classify: +2. **For each changed adapter member**, read the corresponding member in every other adapter and classify: - **Missing** — the other adapters were not updated at all, and they should have been. - **Diverged** — they were updated, but the logic differs in a way that is not explained by dialect differences. - **Correct** — either mirrored properly, or deliberately different for a real dialect reason. -3. **For each core-seam change**, enumerate all five implementations of the changed interface member and - confirm each compiles against the new contract. Search the whole repository for the member name — the five +3. **For each core-seam change**, enumerate every implementation of the changed interface member and + confirm each compiles against the new contract. Search the whole repository for the member name — the implementations are named `{Db}DatabaseAdapter`, `{Db}EntityManipulator` and `{Db}TemporaryTableBuilder`, so - a hit count below five is a missed mirror — and trust the compiler over the search. + a hit count below the number of adapter projects is a missed mirror — and trust the compiler over the search. 4. **Check test parity.** Adapter behaviour is covered in `tests/DbConnectionPlus.IntegrationTests/DatabaseAdapters/{MySql,Oracle,PostgreSql,Sqlite,SqlServer}/`. - If a behaviour change gained a test in one adapter's file, the other four normally need the same test. + If a behaviour change gained a test in one adapter's file, the others normally need the same test. ## Dialect differences that are legitimately asymmetric @@ -60,7 +60,7 @@ Do not report these as divergence unless the change actually gets them wrong: - MySQL's separate enum-handling behaviour in the temp-table reader path. `EnumerableReader` preserves this asymmetry deliberately — do not "fix" it as a side effect. - Oracle's entity manipulator genuinely has **two** `PropertyGetter`/`PropertySetter` call sites where the - other four have three. That is not a missing mirror. + others have three. That is not a missing mirror. ## Reporting diff --git a/.agents/references/reviews/aot-compat.md b/.agents/references/reviews/aot-compat.md index eef6c4e..4b081d6 100644 --- a/.agents/references/reviews/aot-compat.md +++ b/.agents/references/reviews/aot-compat.md @@ -78,7 +78,7 @@ surfaces IL diagnostics — and, as warnings-as-errors, fails on them: dotnet build DbConnectionPlus.slnx -c Release ``` -**The expected count is zero, on both target frameworks**, with no suppressions beyond the sanctioned ones +**The expected count is zero, on `net8.0` and `net10.0`**, with no suppressions beyond the sanctioned ones listed above (`IL2060`, `IL2065`, and the `net8.0`-only `IL3050` on the two `CreateMaterializer` dispatchers). Any other IL diagnostic in `src/` is a regression; re-measure rather than assuming. @@ -89,7 +89,7 @@ the just-in-time compiler, so a change to any reflection path also needs: pwsh -File scripts/verify-package-aot.ps1 -Pack ``` -It packs the six shipping projects, publishes `tests/package-consumption/AotConsumer` natively **from those +It packs the shipping projects, publishes `tests/package-consumption/AotConsumer` natively **from those packages**, gates its IL diagnostics and runs the binary. `-Framework net8.0` checks the documented floor, which behaves differently from the `net10.0` default. Needs a C++ toolchain: MSVC on Windows, `clang` + `zlib1g-dev` on Linux. Drop `-Pack` to reuse the packages already in `artifacts/packages`. diff --git a/.agents/skills/commit/SKILL.md b/.agents/skills/commit/SKILL.md index 5135168..59111f5 100644 --- a/.agents/skills/commit/SKILL.md +++ b/.agents/skills/commit/SKILL.md @@ -35,7 +35,7 @@ Before committing, check whether the change requires companion edits and raise a - **Interface or behaviour change?** The affected pages under `docs/` — the guides carry the examples, and `docs/reference/api-summary.md` carries the one-line index. `PACKAGE_README.md` (the NuGet package page) only needs touching if the change makes its short overview wrong. -- **Adapter change?** Was it mirrored into the other four adapters? Delegate the check to the +- **Adapter change?** Was it mirrored into the other adapters? Delegate the check to the `adapter_parity_reviewer` custom agent when the change meets that agent's scope. - **Touched a reflection path?** `pwsh -File scripts/verify-package-aot.ps1 -Pack` — the unit and integration suites cannot see silent trimming damage. Delegate the review to the `aot_compat_reviewer` custom agent. @@ -54,7 +54,7 @@ pwsh -File scripts/preflight.ps1 ``` That runs the public-API reminder, checks style, formatting and member ordering, builds Release and runs the -unit suite on both target frameworks. It does not edit your files; if it reports the tree as untidy, run +unit suite on `net8.0` and `net10.0`. It does not edit your files; if it reports the tree as untidy, run `pwsh -File scripts/preflight.ps1 -Fix` and review what changed before committing it. If either fails, report the failure and stop — do not commit over it. diff --git a/.agents/skills/integration-db/SKILL.md b/.agents/skills/integration-db/SKILL.md index 33986aa..31468f4 100644 --- a/.agents/skills/integration-db/SKILL.md +++ b/.agents/skills/integration-db/SKILL.md @@ -72,7 +72,7 @@ you now always pay it: | SQL Server only | 92 s | **975** | **0** | | Oracle only | 142 s | 863 | 100 | | MySQL only | 353 s | 855 | 118 | -| Full matrix (all five) | **597 s** | 4457 | 402 | +| Full matrix (every adapter) | **597 s** | 4457 | 402 | SQLite is free next to SQL Server's container start, which is why the pair costs no more than SQL Server alone - the two numbers are the same measurement within noise. @@ -92,9 +92,9 @@ the two numbers are the same measurement within noise. - **A change under `src/DbConnectionPlus.DatabaseAdapters.{MySql,Oracle,PostgreSql}`** — add exactly those adapters to the default pair, and no others. Touching the MySQL adapter buys a MySQL run, not a full matrix. - **A change to the shared adapter seam** — `IDatabaseAdapter`, `IEntityManipulator` or - `ITemporaryTableBuilder` — obliges you to run **all five**, because every adapter implements it. The five are + `ITemporaryTableBuilder` — obliges you to run **the full matrix**, because every adapter implements it. They are genuinely divergent code, and at least one asymmetry is deliberate: MySQL's temp-table reader applies - enum/`Char` handling that the other four do not. A two-provider run cannot see that. + enum/`Char` handling that the others do not. A two-provider run cannot see that. **A core-only change does not earn the full matrix.** The five-provider run is repeatedly byte-identical to the previous baseline for changes that touch no adapter, which is ten minutes for no signal. If you want the extra diff --git a/.claude/agents/adapter-parity-reviewer.md b/.claude/agents/adapter-parity-reviewer.md index 7a5f777..69386eb 100644 --- a/.claude/agents/adapter-parity-reviewer.md +++ b/.claude/agents/adapter-parity-reviewer.md @@ -1,6 +1,6 @@ --- name: adapter-parity-reviewer -description: Checks whether a change to one database adapter was correctly mirrored into the other four. Use after editing anything under src/DbConnectionPlus.DatabaseAdapters.*, or after changing IDatabaseAdapter, IEntityManipulator, or ITemporaryTableBuilder in core. +description: Checks whether a change to one database adapter was correctly mirrored into the others. Use after editing anything under src/DbConnectionPlus.DatabaseAdapters.*, or after changing IDatabaseAdapter, IEntityManipulator, or ITemporaryTableBuilder in core. tools: Read, Grep, Glob --- diff --git a/.editorconfig b/.editorconfig index 8515903..1f7d475 100644 --- a/.editorconfig +++ b/.editorconfig @@ -30,14 +30,14 @@ trim_trailing_whitespace = false # ====================================================================================================== # C# # -# Three tools share this file, and each one owns exactly one concern: +# The tools below share this file, and each one owns exactly one concern: # # CSharpier whitespace, line breaks, wrapping. Reads max_line_length and indent_size. # Roslyn analyzers code style (the settings below). Fixed by `dotnet format style`. # NewStyleCop.Analyzers member ordering (checking only). Fixed by ReSharper, see the ordering # section further down. # -# Run scripts/tidy-code.ps1 to apply all three. +# Run scripts/tidy-code.ps1 to apply them. # ====================================================================================================== [*.cs] @@ -56,19 +56,13 @@ max_line_length = 120 dotnet_style_predefined_type_for_locals_parameters_members = true:error dotnet_style_predefined_type_for_member_access = true:error -# `var` wherever the type is obvious from the right-hand side - a built-in type, or a right-hand side that -# names the type. Both are errors. +# `var` for a local declaration, in all three of the cases the analyzers distinguish: a built-in type, a +# right-hand side that names the type, and everything else. The first two are errors; the third is silent, +# because a declaration whose type nothing on the line reveals is a judgement call about readability rather +# than something a build should reject. csharp_style_var_for_built_in_types = true:error csharp_style_var_when_type_is_apparent = true:error - -# Everywhere else - `var x = Compute();`, where nothing on the line says what the type is - the preference -# is the explicit type. It is stated here rather than left to the analyzer's default so that all three -# halves of the `var` decision are answered in one place and none of them depends on a tool version. -# -# It is deliberately NOT an error. Raising it would be a rewrite of every such declaration in the -# repository, which is a code change rather than a configuration one; the two rules above already decide -# the cases this repository actually argues about. -csharp_style_var_elsewhere = false:silent +csharp_style_var_elsewhere = true:silent #### Member access: always `this.` #### @@ -76,7 +70,7 @@ csharp_style_var_elsewhere = false:silent # # A primary constructor parameter is assigned to a `private readonly` backing field and read through # `this.field`, never used directly in a member body. Nothing below enforces that - a parameter is not an -# instance member, so these four rules cannot see it - but a captured parameter compiles to a field with no +# instance member, so the rules below cannot see it - but a captured parameter compiles to a field with no # `readonly`, and using one directly would silently drop the guarantee that it cannot be reassigned. dotnet_style_qualification_for_event = true:error dotnet_style_qualification_for_field = true:error @@ -110,9 +104,6 @@ dotnet_style_namespace_match_folder = true:error csharp_prefer_braces = true:error csharp_style_prefer_primary_constructors = true:error -# All three executables here - the benchmarks and the two package consumers - have an explicit `Program` -# class, and the benchmark project names it in . Preferring top-level statements would -# suggest undoing that on every one of them, so the preference states what the repository does. csharp_style_prefer_top_level_statements = false:silent dotnet_style_prefer_auto_properties = true:error diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 2e7d2dd..34c20e0 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -12,7 +12,7 @@ tick it, or leave it and say why in the description. A box that does not apply i ### Always - [ ] `pwsh -File scripts/preflight.ps1` passes. It runs the public-API reminder, checks style, formatting - and member ordering, builds Release and runs the unit suite on both target frameworks. Use + and member ordering, builds Release and runs the unit suite on `net8.0` and `net10.0`. Use `-Fix` to have it apply the tidying rather than only report it. - [ ] The Release build produces **zero warnings**. `TreatWarningsAsErrors` is on, so this also covers style, member ordering, trim and public-API diagnostics. @@ -27,7 +27,7 @@ tick it, or leave it and say why in the description. A box that does not apply i ### If the change touches a database adapter, or the shared adapter seam -- [ ] It is mirrored into the other four adapters, or it genuinely does not apply to them — see +- [ ] It is mirrored into the other adapters, or it genuinely does not apply to them — see [CONTRIBUTING.md](../CONTRIBUTING.md#database-adapters). - [ ] The integration tests ran for every adapter the change touches, and the description says which. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 56f95ec..f9b9f93 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -22,8 +22,8 @@ updates: prefix-development: chore include: scope groups: - # The five vendor ADO.NET drivers. They are independent of each other, but a driver bump is always - # reviewed the same way - run that adapter's integration tests - so one pull request beats five. + # The vendor ADO.NET drivers. They are independent of each other, but a driver bump is always + # reviewed the same way - run that adapter's integration tests - so one pull request beats one per driver. # # Minor and patch only: a major version of a driver is a breaking change for the adapter that wraps it, # and it deserves its own pull request with its own integration run. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 330abeb..27d142c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -46,7 +46,7 @@ jobs: # idempotent alone, so comparing before against after is the only honest question. Writing to the # checkout is harmless here - it is thrown away at the end of the job. # - # All three are also build errors in the verify job below, for every project - style and ordering through + # All of them are also build errors in the verify job below, for every project - style and ordering through # the analyzers, formatting through CSharpier.MsBuild. This job still earns its place: it is much faster to # a red X, and it prints the exact diff that would fix things rather than only naming the file. It is also # the only check that can see a MISPLACED member the analyzers accept, because alphabetical order within a @@ -159,8 +159,8 @@ jobs: # # No `services:` block, and no connection strings: the integration suite starts its own containers through # Testcontainers, on the Docker daemon this runner already provides. That is the same code path a developer - # runs locally, so a database that is configured wrong now fails in both places or in neither - and the - # readiness gate this job used to carry by hand is the module wait strategies' job instead. + # runs locally, so a database that is configured wrong now fails in both places or in neither. Readiness is + # the module wait strategies' job, not this workflow's. verify: name: Build, test and analyze runs-on: ubuntu-latest @@ -198,7 +198,7 @@ jobs: - name: Restore run: dotnet restore ${{ env.SOLUTION }} --configfile ${{ env.NUGET_CONFIG }} - # TreatWarningsAsErrors is on for the six shipping projects, so this is also the style, trim-analyzer + # TreatWarningsAsErrors is on for the shipping projects, so this is also the style, trim-analyzer # and public-API gate: an IL2xxx/IL3050 diagnostic fails here, and so does an undeclared or vanished # public member (RS0016 / RS0017). - name: Build @@ -206,9 +206,9 @@ jobs: # --no-build, and the configuration named explicitly. Without both, `dotnet test` silently rebuilds the # whole solution in Debug and tests that instead of the Release build above. The xUnit TRX reporter and - # Coverlet MTP extension replace the VSTest logger and data collector used before the MTP migration. + # Coverlet MTP extension are what report results and coverage on Microsoft.Testing.Platform. # - # This step pulls and starts the four database images, so the bulk of its wall time is the suite plus a + # This step pulls and starts the database images, so the bulk of its wall time is the suite plus a # cold Oracle - keep an eye on the job timeout above when adding a database system. - name: Test run: dotnet test --solution ${{ env.SOLUTION }} --configuration ${{ env.CONFIGURATION }} --no-build --results-directory "${{ env.TEST_RESULTS }}" --report-xunit-trx --coverlet --coverlet-output-format opencover @@ -235,7 +235,7 @@ jobs: if-no-files-found: warn retention-days: 14 - # Build the documentation and pack the six packages. Everything downstream consumes the artifact this + # Build the documentation and pack the packages. Everything downstream consumes the artifact this # produces, so nothing reaches NuGet.org that was not built exactly once, here. package-and-docs: name: Package and docs @@ -290,7 +290,7 @@ jobs: - name: Build DocFX site run: dotnet tool run docfx build ${{ env.DOCFX_CONFIG }} --warningsAsErrors - # IsPackable in the project files is the source of truth for which projects ship as packages: the six + # IsPackable in the project files is the source of truth for which projects ship as packages: the projects # under src/ do, the tests and the benchmarks do not. - name: Pack NuGet packages run: dotnet pack ${{ env.SOLUTION }} --configuration ${{ env.CONFIGURATION }} --no-build --output ${{ env.PACKAGE_OUTPUT }} /p:ContinuousIntegrationBuild=true @@ -387,8 +387,8 @@ jobs: -PackageVersion ${{ needs.package-and-docs.outputs.version }} # Prove the documented net8.0 floor with the .NET 8 SDK and nothing else installed. The consumer installs - # all six packages, so this also proves every adapter's driver dependency flows and that one shared - # DbConnectionPlus assembly satisfies all five adapters - none of which a project-referenced test can see. + # every package, so this also proves each adapter's driver dependency flows and that one shared + # DbConnectionPlus assembly satisfies all of the adapters - none of which a project-referenced test can see. verify-net8-consumers: name: Consume packages on .NET 8 SDK (${{ matrix.os }}) runs-on: ${{ matrix.os }} @@ -531,8 +531,8 @@ jobs: shell: pwsh run: ./${{ env.RELEASE_NOTES_SCRIPT }} -Version "$($env:GITHUB_REF_NAME -replace '^v', '')" - # The in the repository-root Directory.Build.props is the single source of truth for all six - # packages; + # The in the repository-root Directory.Build.props is the single source of truth for every + # package; # tagging a different version would otherwise publish stale packages, or silently publish nothing at # all thanks to --skip-duplicate. - name: Verify the tag matches the packed version diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 7623d3a..bd47bd1 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -54,7 +54,7 @@ jobs: restore-keys: | ${{ runner.os }}-nuget- - # A traced build rather than buildless extraction: it resolves call targets across the six shipping + # A traced build rather than buildless extraction: it resolves call targets across the shipping # projects and the adapter seam, which is where a real finding in this codebase would live. - name: Initialize CodeQL uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 diff --git a/.gitignore b/.gitignore index de49270..9dbac44 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,9 @@ # Only generated output belongs here. # # An ignore rule that matches an AUTHORED file is worse than no rule: the file is invisible to `git status`, -# so it is never added, and the failure shows up as a build that works for you and for nobody else. This file -# used to ignore `**/AssemblyInfo.cs`, which would have hidden a hand-written one; the generated one lives in -# obj/ and is covered by the first rule below. +# so it is never added, and the failure shows up as a build that works for you and for nobody else. So no rule +# here names a source file - `AssemblyInfo.cs`, for instance, is generated into obj/ and is already covered by +# the first rule below, and a hand-written one has to stay visible. # --- Build output ------------------------------------------------------------------------------------- bin diff --git a/AGENTS.md b/AGENTS.md index 422d875..42e3b61 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -33,16 +33,16 @@ Build settings live in shared files, not in the `.csproj` files: | `Directory.Build.props` | all nine solution projects | shared metadata, ``, the **style gate** (`EnforceCodeStyleInBuild` + `TreatWarningsAsErrors`), `IsPackable=false`, the dependency audit | | `Directory.Build.targets` | all nine | the files the packages carry, conditioned on `IsPackable` — which is why they cannot be in a `.props` file | | `Directory.Packages.props` | all nine | **every dependency version**. A `PackageReference` here carries no `Version`; adding one is `NU1008` | -| `src/Directory.Build.props` | the six shipping projects | `TargetFrameworks`, `IsAotCompatible`, `AnalysisLevel=latest-all`, the AOT and public-API analyzers, the package metadata and package validation | -| `tests/Directory.Build.props` | the two test projects | `OutputType=Exe`, the xUnit v3 / Microsoft.Testing.Platform references and the coverage extension | +| `src/Directory.Build.props` | the shipping projects | `TargetFrameworks`, `IsAotCompatible`, `AnalysisLevel=latest-all`, the AOT and public-API analyzers, the package metadata and package validation | +| `tests/Directory.Build.props` | the test projects | `OutputType=Exe`, the xUnit v3 / Microsoft.Testing.Platform references and the coverage extension | MSBuild stops at the nearest `Directory.Build.props`, so the `src/` and `tests/` ones **import the root explicitly** — without that import their projects would silently lose all of it. `AnalysisLevel=latest-all` has to stay under `src/`: CA1707 alone objects 2100 times to the test suite's `Method_ShouldDoSomething` naming. -`` is one edit for all six packages, at the repository root. `scripts/verify-package-aot.ps1` reads it +`` is one edit for every package, at the repository root. `scripts/verify-package-aot.ps1` reads it from there when `-PackageVersion` is omitted. **Packing is opt-in**: the root sets `IsPackable=false` and each of -the six shipping projects sets `IsPackable=true` itself, so a new project ships nothing by accident. +the shipping projects sets `IsPackable=true` itself, so a new project ships nothing by accident. Target frameworks differ per project on purpose: benchmarks `net10.0`, unit tests `net8.0;net10.0` (the shipping libraries' two builds are not the same code), integration tests `net8.0`. @@ -50,7 +50,7 @@ libraries' two builds are not the same code), integration tests `net8.0`. `tests/package-consumption/` sits **outside** all of this on purpose. It carries its own empty `Directory.Build.props`/`.targets`, plus a `Directory.Packages.props` that turns central package management back off, all of which stop the upward search — so those projects get the library only from the packed packages, at a -version CI hands them. Do not "fix" that by deleting the three files. +version CI hands them. Do not "fix" that by deleting those files. **Two readmes, and they are not interchangeable.** `README.md` is the repository's reference documentation, what a GitHub visitor reads, and what an API change updates. `PACKAGE_README.md` is the short overview nuget.org renders @@ -60,11 +60,11 @@ touch it only when the overview itself stops being true. ### The adapter seam `IDatabaseAdapter` (`src/DbConnectionPlus/DatabaseAdapters/IDatabaseAdapter.cs`) exposes `IEntityManipulator` and -`ITemporaryTableBuilder`, and each of the five adapter projects implements all three. +`ITemporaryTableBuilder`, and each of the adapter projects implements all three. -**A change to one adapter almost always needs mirroring into the other four.** Only the integration suite catches +**A change to one adapter almost always needs mirroring into the others.** Only the integration suite catches a miss, and that needs Docker. Run the adapter-parity reviewer, walk -[its checklist](.agents/references/reviews/adapter-parity.md) over the diff, or check the other four by hand. +[its checklist](.agents/references/reviews/adapter-parity.md) over the diff, or check the others by hand. ## Build & test @@ -86,26 +86,26 @@ eight projects. The Release build covers every project, so it turns a missed cal The Native AOT gate is the **only** check that can see silent trimming damage, because nothing is trimmed on the JIT. Run it whenever you change reflection, DAM annotations, the materializers or the temp-table readers. It packs -the six projects, publishes `AotConsumer` natively **from the packages**, gates its IL diagnostics and runs the +the shipping projects, publishes `AotConsumer` natively **from the packages**, gates its IL diagnostics and runs the binary. It needs a C++ toolchain and minutes, so it is not in `preflight.ps1`; `-Framework net8.0` checks the documented AOT floor, and CI runs that as well as the `net10.0` default. Integration tests need only a running Docker daemon; [Testcontainers](https://dotnet.testcontainers.org/) starts a container per database system and removes it afterwards. **Scope the run:** the default is **SQLite + SQL Server** -(~90 s, against ~600 s for all five); add another adapter's tests **only if you changed that adapter's code**; run -all five only for a change to `IDatabaseAdapter`, `IEntityManipulator` or `ITemporaryTableBuilder`. Commands, +(~90 s, against ~600 s for the full matrix); add another adapter's tests **only if you changed that adapter's code**; run +the full matrix only for a change to `IDatabaseAdapter`, `IEntityManipulator` or `ITemporaryTableBuilder`. Commands, timings and troubleshooting: [the `integration-db` skill](.agents/skills/integration-db/SKILL.md). ## Code style, formatting and ordering -Formatting is CSharpier's, style is the Roslyn analyzers', ordering is ReSharper's and NewStyleCop's. All three are -build errors, in `tests/` and `benchmarks/` as much as in `src/`. Details, and the three cases where a tool does +Formatting is CSharpier's, style is the Roslyn analyzers', ordering is ReSharper's and NewStyleCop's. All of them are +build errors, in `tests/` and `benchmarks/` as much as in `src/`. Details, and the cases where a tool does something surprising: [the code-style reference](.agents/references/code-style.md). - **C# keywords, never BCL type names**: `string`, `object?`, `int`, `bool`, `nint`, `nuint` — not `String`, `Object?`, `Int32`, `Boolean`, `IntPtr`, `UIntPtr`. ⚠️ **The build does not catch all of this.** `IDE0049` ignores `nint`/`nuint` and never looks inside `nameof(...)`, and `tests/package-consumption/` has no style gate - at all. Apply the rule by hand in those three places — reasons in + at all. Apply the rule by hand in those places — reasons in [the reference](.agents/references/code-style.md#where-the-build-misses-a-bcl-type-name). - **Always `this.`** for instance fields, properties, methods and events; fields are never `_camelCase` (`SA1309`). A **primary constructor parameter** is assigned to a `private readonly` backing field and read through @@ -133,7 +133,7 @@ something surprising: [the code-style reference](.agents/references/code-style.m - Primary constructors, `=>` for single-expression members, file-scoped namespaces, the 120-column limit, nullable and `ImplicitUsings` — [the build fully enforces these](.agents/references/code-style.md#rules-the-build-fully-enforces). -One entry point applies all of it, in three scopes: +One entry point applies all of it, and takes a scope: ```bash pwsh -File scripts/tidy-code.ps1 # ~1s formatting, on the files git reports as changed @@ -155,7 +155,7 @@ xUnit v3 with `[Fact]` / `[Theory]`, assertions via **AwesomeAssertions**, fakes ## The declared public API -Each of the six shipping projects declares its public surface next to its `.csproj`, in `PublicAPI.Shipped.txt` +Each shipping project declares its public surface next to its `.csproj`, in `PublicAPI.Shipped.txt` (what shipped in the last release) and `PublicAPI.Unshipped.txt` (what is new since, plus `*REMOVED*` lines for what is gone). `Microsoft.CodeAnalysis.PublicApiAnalyzers` enforces them **at build time**: `RS0016` for an undeclared public member, `RS0017` for a declared one that is gone, both errors, so an accidental change to the @@ -169,7 +169,7 @@ release time. The **reflection paths are AOT-safe**: no companion package, no source generator, no consumer opt-in. Rationale and measurements: [docs/DESIGN-DECISIONS.md](docs/DESIGN-DECISIONS.md#native-aot-and-trimming). Before changing anything that -reflects, run the AOT/trim reviewer or walk [its checklist](.agents/references/reviews/aot-compat.md). Three +reflects, run the AOT/trim reviewer or walk [its checklist](.agents/references/reviews/aot-compat.md). These constraints must not be broken: - **Nothing may generate code at run time** — that, not reflection, is what Native AOT forbids. Accessors use @@ -205,8 +205,8 @@ constraints must not be broken: ### Releases **Never `dotnet pack` and push by hand.** A release is a pushed tag and CI does the rest: it checks the tag against -the packed version, publishes all six packages to NuGet.org, and creates the GitHub release from the `CHANGELOG.md` -section. The three steps: [CONTRIBUTING.md](CONTRIBUTING.md#releasing). +the packed version, publishes every package to NuGet.org, and creates the GitHub release from the `CHANGELOG.md` +section. The steps: [CONTRIBUTING.md](CONTRIBUTING.md#releasing). ## Working procedures diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4fb17eb..fd2a09a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -92,7 +92,7 @@ branch has one. ### Style, formatting and member ordering -Three tools own three concerns, and all three are build errors: +Each tool below owns one concern, and every one of them is a build error: | Concern | Tool | |---|---| @@ -100,7 +100,7 @@ Three tools own three concerns, and all three are build errors: | Style — `var`, `=>`, `this.`, null checks, usings | the Roslyn analyzers | | Ordering — types and their members | ReSharper applies it, NewStyleCop checks most of it | -One command applies all three: +One command applies them: ```shell pwsh -File scripts/tidy-code.ps1 -Scope all @@ -120,7 +120,7 @@ pwsh -File scripts/preflight.ps1 ``` That is the gate to run before every commit. It checks the public API, checks style, formatting and ordering, -builds Release and runs the unit suite on both target frameworks. **By default it writes build output and +builds Release and runs the unit suite on `net8.0` and `net10.0`. **By default it writes build output and nothing else** — it does not edit your files and it does not touch the git index. Pass `-Fix` to have it apply the tidying first: @@ -144,18 +144,18 @@ Two gates are deliberately outside preflight, because each takes minutes and nei | Native AOT | the change touches reflection, the `[DynamicallyAccessedMembers]` annotations, the materializers or the temporary-table readers | `pwsh -File scripts/verify-package-aot.ps1 -Pack` | **Scope the integration run.** The default scope is SQLite + SQL Server (about 90 seconds, against about 10 -minutes for all five); add `--filter-class "*MySql*"` and the like only for an adapter you actually changed. -A change to `IDatabaseAdapter`, `IEntityManipulator` or `ITemporaryTableBuilder` obliges all five. +minutes for the full matrix); add `--filter-class "*MySql*"` and the like only for an adapter you actually changed. +A change to `IDatabaseAdapter`, `IEntityManipulator` or `ITemporaryTableBuilder` obliges the full matrix. -The AOT gate runs both frameworks — `-Framework net8.0` for the documented floor, `net10.0` by default — and +The AOT gate runs `net8.0` for the documented floor and `net10.0` by default, and CI runs both, on Linux and Windows, against the exact packages it will publish. ### Database adapters -There are five adapter projects under `src/DbConnectionPlus.DatabaseAdapters.*`, each implementing +The adapter projects under `src/DbConnectionPlus.DatabaseAdapters.*` each implement `IDatabaseAdapter`, `IEntityManipulator` and `ITemporaryTableBuilder` with per-dialect SQL. -**A change to one adapter almost always has to be mirrored into the other four**, and only the integration +**A change to one adapter almost always has to be mirrored into the others**, and only the integration suite catches a miss. Some asymmetries are legitimate and should be left alone: identifier quoting, parameter prefixes, temporary-table syntax, `GetDataType` mapping, generated-key readback, the bulk-insert paths, and MySQL's separate enum handling in the temporary-table reader. @@ -199,7 +199,7 @@ by LF". Run the tidy script first, or use `core.safecrlf warn`. Releases are cut by CI from a pushed tag; nothing is packed or pushed by hand. The versioning scheme is [SemVer](https://semver.org/). **This is the maintainer's procedure**, not a contributor's. -1. Bump `` in the repository-root `Directory.Build.props` — one edit for all six packages — and move +1. Bump `` in the repository-root `Directory.Build.props` — one edit for every package — and move `PackageValidationBaselineVersion` in `src/Directory.Build.props` to the version being replaced. 2. Fold the accumulated public-API entries into the shipped snapshots: `pwsh -File scripts/update-public-api.ps1 -MarkShipped`. @@ -212,4 +212,4 @@ Releases are cut by CI from a pushed tag; nothing is packed or pushed by hand. T ``` CI verifies the tag against the packed version, runs every gate — including the package-consumption and Native -AOT jobs, which nothing can bypass — then pushes all six packages to NuGet.org and creates the GitHub release. +AOT jobs, which nothing can bypass — then pushes every package to NuGet.org and creates the GitHub release. diff --git a/Directory.Build.props b/Directory.Build.props index 87886fa..45fbcc7 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ 4.0.0 @@ -39,9 +39,9 @@ @@ -34,9 +34,9 @@ diff --git a/PACKAGE_README.md b/PACKAGE_README.md index 0e9bf2b..c470850 100644 --- a/PACKAGE_README.md +++ b/PACKAGE_README.md @@ -17,6 +17,86 @@ tracking, no LINQ provider, and nothing to opt into under Native AOT. `IL2xxx` / `IL3xxx` warnings in your publish. - **Minimal overhead** — close to hand-written ADO.NET, and minimal allocations. +## Why not just use Dapper? + +This library follows the same philosophy as Dapper: extension methods on `DbConnection`, your own SQL, no change tracking and no LINQ provider, but with the following differences and additions: + +- Querying, CRUD and AOT support in one package. For dapper you need Dapper, Dapper.Contrib and Dapper.AOT. +- Pass parameters directly inside interpolated strings. SQL injection safe. +- Pass collections as on-the-fly temporary tables directly inside interpolated strings. Also SQL injection safe. +- Optimistic concurrency out-of-the-box. +- Full native AOT compatibility (including support for ValueTuples, which Dapper.AOT lacks). +- Customizable Enum serialization (string/integer). +- Great performance. + +**Your values stay inside your query** + +```csharp +// Dapper +connection.Query( + "SELECT * FROM Product WHERE SupplierId = @SupplierId AND UnitsInStock < @Threshold", + new { SupplierId = supplierId, Threshold = threshold } +); + +// DbConnectionPlus +connection.Query( + $"SELECT * FROM Product WHERE SupplierId = {Parameter(supplierId)} AND UnitsInStock < {Parameter(threshold)}" +); +``` + +- No anonymous object to keep in sync with the SQL +- No renamed variable that compiles and then fails at run time +- Full auto-complete +- Full compiler verification + +**Throw a whole collection at the database** + +```csharp +connection.Query( + $"SELECT * FROM Product WHERE SupplierId IN (SELECT Value FROM {TemporaryTable(retiredSupplierIds)})" +); +``` + +A temporary table is created, your collection is bulk-loaded into it (`SqlBulkCopy` and the equivalent elsewhere) +and it is dropped afterwards. Objects work too, so you can `JOIN` against in-memory data. Dapper expands +`IN @ids` into one parameter per element instead - a new query plan per list length, and a hard stop at the +provider's parameter limit. + +**Full optimistic concurrence support** + +```csharp +connection.InsertEntity(newProduct); // database-generated values come back filled in +connection.UpdateEntity(product); // throws if someone else changed the row first +connection.DeleteEntities(discontinuedProducts); +``` + +One entity or a sequence, sync or `…Async` - and all of it supports optimistic concurrency: mark a property +`[Timestamp]` or `[ConcurrencyCheck]` and a stale write raises `DbUpdateConcurrencyException` instead of quietly +matching no rows. Composite keys included. Dapper has no CRUD at all; Dapper.Contrib adds some, but its `UPDATE` +matches on the key alone - so there is **no optimistic concurrency anywhere in the Dapper family** - and it has no +composite keys, no Oracle support and no release since 2020. + +**Native AOT support with no extra steps** + +```xml +true +``` + +That is the whole setup - no companion package, no source generator, no attributes, no `IL2xxx` / `IL3xxx` +warnings. Entities, records, **value tuples**, temporary tables, CRUD and enums all keep working. Dapper needs the +separate Dapper.AOT package plus a `[DapperAot]` opt-in per method, still has no value-tuple support and no +Dapper.Contrib support, and silently leaves call sites it cannot generate as ordinary Dapper - so the build +passes and the failure appears in your published binary. + +**Plus** the data annotations you already use (`[Table]`, `[Column]`, `[Key]`, `[NotMapped]`, … - or a fluent API), +one-line enum serialization as strings or integers, untyped rows through `row["Name"]` rather than `dynamic`, and +a single hook to log or tweak every command before it runs. + +Dapper is still the better pick for multi-mapping and `QueryMultiple`, custom `ITypeHandler` conversions, .NET +Framework / .NET Standard 2.0, and databases without an adapter here. The +[full comparison](https://github.com/rent-a-developer/DbConnectionPlus#why-not-just-use-dapper), with benchmark +numbers, is in the repository README. + ## Installation Install this package plus the adapter for the database you use: @@ -34,7 +114,7 @@ dotnet add package DbConnectionPlus.DatabaseAdapters.SqlServer | SQLite | `DbConnectionPlus.DatabaseAdapters.Sqlite` | `Microsoft.Data.Sqlite` | | Oracle | `DbConnectionPlus.DatabaseAdapters.Oracle` | `Oracle.ManagedDataAccess.Core` | -All six packages are versioned and released together, so a release's adapter always matches its core package. +The packages are versioned and released together, so a release's adapter always matches its core package. ## Getting started @@ -83,7 +163,6 @@ command type and cancellation token. - **[Documentation and guides](https://rent-a-developer.github.io/DbConnectionPlus/)** — querying, parameters and temporary tables, entity mapping and CRUD, configuration, custom adapters, and Native AOT - [API reference](https://rent-a-developer.github.io/DbConnectionPlus/api/RentADeveloper.DbConnectionPlus.DbConnectionExtensions.html) -- [Comparison with Dapper](https://rent-a-developer.github.io/DbConnectionPlus/reference/comparison.html) - [Benchmarks](https://rent-a-developer.github.io/DbConnectionPlus/reference/performance.html) - [Source code](https://github.com/rent-a-developer/DbConnectionPlus) - [Change log](https://github.com/rent-a-developer/DbConnectionPlus/blob/main/CHANGELOG.md) diff --git a/README.md b/README.md index b04a1c4..0b3bf35 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,186 @@ var lowStockProducts = connection.Query( ); ``` +## Why not just use Dapper? + +This library follows the same philosophy as Dapper: extension methods on `DbConnection`, your own SQL, no change tracking and no LINQ provider, but with the following differences and additions: + +- Querying, CRUD and AOT support in one package. For dapper you need Dapper, Dapper.Contrib and Dapper.AOT. +- Pass parameters directly inside interpolated strings. SQL injection safe. +- Pass collections as on-the-fly temporary tables directly inside interpolated strings. Also SQL injection safe. +- Optimistic concurrency out-of-the-box. +- Full native AOT compatibility (including support for ValueTuples, which Dapper.AOT lacks). +- Customizable Enum serialization (string/integer). +- Great performance. + +### Your values stay inside your query + +```csharp +// Dapper +connection.Query( + "SELECT * FROM Product WHERE SupplierId = @SupplierId AND UnitsInStock < @Threshold", + new { SupplierId = supplierId, Threshold = threshold } +); + +// DbConnectionPlus +connection.Query( + $"SELECT * FROM Product WHERE SupplierId = {Parameter(supplierId)} AND UnitsInStock < {Parameter(threshold)}" +); +``` + +- No anonymous object to keep in sync with the SQL +- No renamed variable that compiles and then fails at run time +- Full auto-complete +- Full compiler verification + +Each value is written where it is used, and still reaches the database as a real `DbParameter`. Nothing to name +twice, no anonymous object to keep in sync with the SQL, and no renamed variable that compiles happily and blows +up at run time. + +### Throw a whole collection at the database + +```csharp +var retiredSupplierIds = suppliers.Where(a => a.IsRetired).Select(a => a.Id); + +var affectedProducts = connection.Query( + $""" + SELECT * + FROM Product + WHERE SupplierId IN (SELECT Value FROM {TemporaryTable(retiredSupplierIds)}) + """ +); +``` + +DbConnectionPlus creates a temporary table, bulk-loads your collection into it (`SqlBulkCopy` and the equivalent +for every other supported database) and drops it when the statement is done. Ten values or a million: no `IN` list +to build, no parameter limit to stay under, one query plan. + +Objects work just as well - each property becomes a column - so you can `JOIN` straight against in-memory data: + +```csharp +var orderedProducts = connection.Query<(long ProductId, int Quantity, decimal UnitPrice)>( + $""" + SELECT TOrderItem.ProductId, TOrderItem.Quantity, Product.UnitPrice + FROM Product + JOIN {TemporaryTable(orderItems)} TOrderItem ON TOrderItem.ProductId = Product.Id + """ +); +``` + +Dapper expands `IN @ids` into one parameter per element instead - a fresh query plan for every list length, and a +hard ceiling at the provider's parameter limit (2,100 on SQL Server). Table-valued parameters lift that ceiling, +but only on SQL Server, and only after you have created a table type in the database and hand-built a `DataTable`. + +### Full optimistic concurrence support + +```csharp +class Product +{ + [Key] + [DatabaseGenerated(DatabaseGeneratedOption.Identity)] + public long Id { get; set; } + + [Timestamp] + public byte[] Version { get; set; } + + public decimal UnitPrice { get; set; } +} + +connection.InsertEntity(newProduct); // Id and Version come back filled in +connection.UpdateEntity(product); // throws if someone else changed the row first +connection.DeleteEntities(discontinuedProducts); +``` + +Insert, update and delete - one entity or a whole sequence, synchronous or `…Async` - and **every one of them +supports optimistic concurrency**. Mark a property with `[Timestamp]` or `[ConcurrencyCheck]` and its original +value joins the `WHERE` clause, so an update or delete against a row that changed in the meantime raises +`DbUpdateConcurrencyException` instead of quietly matching nothing. Database-generated values are read back onto +your entity, and composite keys are supported. + +Dapper has no CRUD at all - you write every `INSERT`, `UPDATE` and `DELETE` by hand. Dapper.Contrib is the usual +answer, and it does add `Insert` / `Update` / `Delete` - but its `UPDATE` matches on the key alone, so **there is +no optimistic concurrency anywhere in the Dapper family**. It also cannot handle composite keys, has no Oracle +support, and has not shipped a release since 2020. + +### The attributes you already use + +```csharp +[Table("Products")] +class Product +{ + [Key] public long Id { get; set; } + [Column("ProductName")] public string Name { get; set; } + [NotMapped] public decimal TotalPrice => this.UnitPrice * this.Quantity; +} +``` + +Plain `System.ComponentModel.DataAnnotations` - the same attributes EF Core reads, so your entities usually need +no changes at all. Prefer to keep your domain model clean? A fluent API configures the same things from one place. +Dapper.Contrib brings its own smaller attribute set and cannot even map a column name. + +### Native AOT support with no extra steps + +```xml +true +``` + +That is the entire setup. No companion package, no source generator, no attribute on anything, and no `IL2xxx` or +`IL3xxx` warning in your build. Everything keeps working: entities, records and other immutable types, **value +tuples**, temporary tables, CRUD and enums. + +Dapper cannot run under Native AOT at all; you need the separate Dapper.AOT package and a `[DapperAot]` opt-in on +every method, type or module you want covered. Even then it does not support value tuples, and it does not cover +Dapper.Contrib's CRUD - so those simply have no Dapper answer under Native AOT. Worse, a call site Dapper.AOT +cannot handle is left as ordinary Dapper without any warning: the build stays green and the failure shows up in +your published binary. + +### Enums, your way, in one line + +```csharp +DbConnectionExtensions.Configure(config => config.EnumSerializationMode = EnumSerializationMode.Strings); +``` + +Readable strings or compact integers - the choice applies everywhere at once: entity properties, parameters and +temporary tables. Reading understands both, whichever your columns happen to hold. Dapper writes the underlying +number unless you write and register a type handler yourself - which Dapper.AOT's generated code then ignores. + +### Untyped rows without `dynamic` + +```csharp +var row = connection.QueryFirst($"SELECT * FROM Product WHERE Id = {Parameter(productId)}"); +var name = row["Name"]; +``` + +No cast, no `dynamic`, and it works under Native AOT - where Dapper's `dynamic` rows cannot be bound at all. If you +do want member access, one `dynamic` reference gives you `row.Name`; it is an option here rather than the only way +in. + +### See every command your app sends + +```csharp +DbConnectionExtensions.Configure(config => +{ + config.InterceptDbCommand = (command, _) => logger.LogDebug("SQL: {Sql}", command.CommandText); +}); +``` + +One hook, every statement DbConnectionPlus builds, right before it executes - for logging, a timeout, or a query +hint. Dapper has nothing comparable. + +### And it costs you nothing + +The [benchmark suite](docs/reference/performance.md) in this repository runs every feature three ways - hand-written `DbCommand`, +Dapper and DbConnectionPlus - against in-memory SQLite, the harshest possible setting because the query itself is +almost free there. The two libraries trade places from category to category, both within a small multiple of raw +ADO.NET, and DbConnectionPlus allocates less than Dapper in eleven of the seventeen categories. + +### When Dapper is still the better pick + +Being honest about it: Dapper has multi-mapping (`splitOn`) and `QueryMultiple` for several result sets from one +command, custom `ITypeHandler` conversions, support for .NET Framework and .NET Standard 2.0, and fifteen years of +ecosystem. DbConnectionPlus has none of the first three, requires `net8.0` or later, and needs a +[custom adapter](docs/guides/custom-adapters.md) for database systems beyond the [database systems it supports](#installation). + ## Requirements | | | @@ -57,7 +237,7 @@ dotnet add package DbConnectionPlus.DatabaseAdapters.SqlServer | SQLite | `DbConnectionPlus.DatabaseAdapters.Sqlite` | `Microsoft.Data.Sqlite` | | Oracle | `DbConnectionPlus.DatabaseAdapters.Oracle` | `Oracle.ManagedDataAccess.Core` | -All six packages are versioned and released together, so a release's adapter always matches its core package. +The packages are versioned and released together, so a release's adapter always matches its core package. ## Quick start @@ -118,8 +298,7 @@ command type and cancellation token. The detail lives in [the guides](docs/guides/), the API reference is [published from the XML docs](https://rent-a-developer.github.io/DbConnectionPlus/), and -[the reference section](docs/reference/) carries the -[comparison with Dapper](docs/reference/comparison.md) and the +[the reference section](docs/reference/) carries the [API summary](docs/reference/api-summary.md) and the [benchmark results](docs/reference/performance.md). ## Support limits @@ -151,31 +330,3 @@ Read these before adopting it; each one is a thing this library does **not** do. ## Contributors - David Liebeherr ([info@rent-a-developer.de](mailto:info@rent-a-developer.de)) - ---- - -## Moved sections - -Four sections used to live in this file, and links to their anchors are already published — the -`PACKAGE_README.md` inside the 4.0.0 packages on nuget.org points at one of them. The headings below keep -those anchors working. Nothing else is here. - -### Why not just use Dapper? - -Moved to [docs/reference/comparison.md](docs/reference/comparison.md). - -### API summary - -Moved to [docs/reference/api-summary.md](docs/reference/api-summary.md). - -### Benchmarks - -Moved to [docs/reference/performance.md](docs/reference/performance.md). - -### Custom database adapter - -Moved to [docs/guides/custom-adapters.md](docs/guides/custom-adapters.md). - -### Native AOT and trimming - -Moved to [docs/guides/native-aot.md](docs/guides/native-aot.md). diff --git a/SECURITY.md b/SECURITY.md index 042f148..ac93d63 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -7,7 +7,7 @@ | 4.x | ✅ | | < 4.0 | ❌ | -Fixes are released from `main` as a new patch version of all six packages, which are versioned and released +Fixes are released from `main` as a new patch version of every package, which are versioned and released together. ## Reporting a vulnerability diff --git a/codecov.yml b/codecov.yml index ac75822..1ae2e07 100644 --- a/codecov.yml +++ b/codecov.yml @@ -11,7 +11,7 @@ coverage: # Informational: a PR is never blocked on patch coverage, but the delta is reported. informational: true -# Only the six shipping libraries under src/ are measured. The benchmarks are BenchmarkDotNet harnesses and +# Only the shipping libraries under src/ are measured. The benchmarks are BenchmarkDotNet harnesses and # the package consumers are compile-and-run smoke tests, so neither is part of the shipped surface. ignore: - "benchmarks" diff --git a/docs/DESIGN-DECISIONS.md b/docs/DESIGN-DECISIONS.md index 7628e47..94bd8d8 100644 --- a/docs/DESIGN-DECISIONS.md +++ b/docs/DESIGN-DECISIONS.md @@ -291,8 +291,9 @@ for free: with a registry, an undiscovered type fails loudly. Measured under Nat | Broken annotation chain, **with** guard | - | **throws** | | Correct annotation chain, with guard | 6 | **OK** - no false positive | -The guard also fixed a latent bug on the JIT: a result set matching no property previously returned -default-valued objects, so a typo in a `SELECT` alias produced a sequence of empty objects with no error. +The guard also covers a failure that is not AOT-specific: on the JIT, a result set matching no property +would otherwise produce a sequence of default-valued objects, so a typo in a `SELECT` alias would return empty +objects with no error. ⚠️ **All three cases pass on the JIT.** Nothing is trimmed there, so the entire unit and integration suite passes with a broken annotation chain. That is why verification lives in a natively published smoke test - see @@ -341,7 +342,7 @@ of bounds and untouched. The two sanctioned suppressions cover value-tuple **BCL eight framework types, preserved by a shipped descriptor, guarded by a unit test and by native smoke cases. **The `net8.0` `IL3050` suppression is a transcription, not an assertion.** The `net10.0` inner build compiles -the same source *without* it. That is why both target frameworks are gated in CI: the newer one verifies the +the same source *without* it. That is why `net8.0` and `net10.0` are both gated in CI: the newer one verifies the reasoning the older one has to state by hand. **Why bother.** A warning a consumer cannot act on, and that does not correspond to any way their application @@ -697,7 +698,7 @@ Oracle's private temporary table name must carry the server's `private_temp_tabl back. The one exception it does translate is cancellation - SQL Server's `OperationAbortedException` becomes `OperationCanceledException`, matching every other cancellation path in the library. -**One reader feeds all five.** The bulk-copy APIs and the `INSERT` loops both consume an `EnumerableReader`: a +**One reader feeds every adapter.** The bulk-copy APIs and the `INSERT` loops both consume an `EnumerableReader`: a `DbDataReader` implementation over an `IEnumerable`, exposing a single `Value` column for scalars or one column per mapped readable property for complex objects. Nothing materializes the sequence into an intermediate table or array first, and the same code runs on the JIT and under Native AOT. @@ -711,8 +712,8 @@ or array first, and the same code runs on the JIT and under Native AOT. | Tier | What it is | Scale | |---|---|---| | **Unit tests** (`DbConnectionPlus.UnitTests`) | core logic in isolation, `DbConnection` / `DbDataReader` substituted with NSubstitute | ~3,270 executed plus ~200 skipped per target framework, in a few seconds - which is what makes them the default verification loop | -| **Integration tests** (`DbConnectionPlus.IntegrationTests`) | real databases: Testcontainers-managed containers for MySQL, Oracle, PostgreSQL and SQL Server, SQLite in-process | ~600 s for all five, ~90 s for the default SQLite + SQL Server pair | -| **Package-consumption tests** (`tests/package-consumption/`) | console apps consuming the **packed packages**, not the projects. `AotConsumer` is published with Native AOT; `AllAdaptersConsumer` installs all six packages and builds on the .NET 8 SDK alone, which is what makes the documented `net8.0` floor a checked fact | two CI gates | +| **Integration tests** (`DbConnectionPlus.IntegrationTests`) | real databases: Testcontainers-managed containers for MySQL, Oracle, PostgreSQL and SQL Server, SQLite in-process | ~600 s for the full matrix, ~90 s for the default SQLite + SQL Server pair | +| **Package-consumption tests** (`tests/package-consumption/`) | console apps consuming the **packed packages**, not the projects. `AotConsumer` is published with Native AOT; `AllAdaptersConsumer` installs every package and builds on the .NET 8 SDK alone, which is what makes the documented `net8.0` floor a checked fact | two CI gates | | **Benchmarks** (`DbConnectionPlus.Benchmarks`) | regression detection against a raw `DbCommand` baseline and against Dapper | BenchmarkDotNet | ### Unit tests @@ -729,7 +730,7 @@ a new one. **The public surface is not tested - it is declared.** Every shipping project carries `PublicAPI.Shipped.txt` and `PublicAPI.Unshipped.txt`, and `Microsoft.CodeAnalysis.PublicApiAnalyzers` turns an undeclared public member into `RS0016` and a declared-but-vanished one into `RS0017`. With `TreatWarningsAsErrors=true` that is a -build error in all six projects on both target frameworks, so an accidental break cannot compile, let alone +build error in every shipping project on `net8.0` and `net10.0`, so an accidental break cannot compile, let alone reach a test run. `scripts/update-public-api.ps1` records a deliberate change. ### Integration tests @@ -743,7 +744,7 @@ servers; the container definitions are the fixtures in `tests/DbConnectionPlus.IntegrationTests/TestDatabase/Containers/`. There is no compose file to bring up, no `testconfig.json`, and no `ConnectionString_*` environment variable: each fixture builds its connection string in code from the free host port Docker published its container on, which is what removes both the port collision -with a locally installed server and the second set of connection strings CI used to carry. CI declares no service +with a locally installed server and any second set of connection strings for CI. CI declares no service containers either - it runs the same code path a developer does, so a container configured wrong fails in both places or in neither. @@ -754,10 +755,9 @@ enough to skip database systems the run does not touch and early enough for the open a connection. The container behind it is shared by every test class of that database system and removed by an assembly fixture when the run ends. -**What that costs.** Every run now starts from a freshly created server rather than from whatever a long-lived -compose stack had accumulated, and pays the startup. Measured against the numbers this suite used to record, the -full matrix went from 533 s to 597 s - about a minute for four containers (PostgreSQL 4.7 s, SQL Server 11.1 s, -MySQL 19.8 s, Oracle 23.9 s). +**What that costs.** Every run starts from a freshly created server rather than from whatever a long-lived +compose stack would have accumulated, and pays the startup: about a minute of the full matrix's 597 s goes on +container startup (PostgreSQL 4.7 s, SQL Server 11.1 s, MySQL 19.8 s, Oracle 23.9 s). That is also why the Oracle fixture pins the `faststart` image variant, whose database is already created, over the plain one that spends minutes creating `FREEPDB1` on first start. diff --git a/docs/index.md b/docs/index.md index b731427..54c932f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -23,13 +23,12 @@ and a quick start. These pages are the full documentation. | Page | What it covers | |---|---| | [API summary](reference/api-summary.md) | Every public entry point, one line each, linked to the generated reference | -| [Comparison with Dapper](reference/comparison.md) | Where the two differ, and where Dapper is still the better pick | | [Performance](reference/performance.md) | The benchmark results, and how they were measured | | [Design decisions](DESIGN-DECISIONS.md) | Why the library works the way it does | ## API reference -The generated reference for all six packages starts at +The generated reference for every package starts at [`DbConnectionExtensions`](xref:RentADeveloper.DbConnectionPlus.DbConnectionExtensions) — the entry point for nearly every operation. @@ -50,7 +49,7 @@ Install the core package plus the adapter for your database, then register the a DbConnectionExtensions.Configure(configuration => configuration.UseSqlServer()); ``` -All six are versioned and released together, so a given release's adapter always matches its core package. +They are versioned and released together, so a given release's adapter always matches its core package. ## Project diff --git a/docs/reference/comparison.md b/docs/reference/comparison.md deleted file mode 100644 index d13a057..0000000 --- a/docs/reference/comparison.md +++ /dev/null @@ -1,179 +0,0 @@ -# Comparison with Dapper - -This library follows the same philosophy as Dapper: extension methods on `DbConnection`, your own SQL, no change tracking and no LINQ provider, but with the following differences and additions: - -- Querying, CRUD and AOT support in one package. For dapper you need Dapper, Dapper.Contrib and Dapper.AOT. -- Pass parameters directly inside interpolated strings. SQL injection safe. -- Pass collections as on-the-fly temporary tables directly inside interpolated strings. Also SQL injection safe. -- Optimistic concurrency out-of-the-box. -- Full native AOT compatibility (including support for ValueTuples, which Dapper.AOT lacks). -- Customizable Enum serialization (string/integer). -- Great performance. - -## Your values stay inside your query - -```csharp -// Dapper -connection.Query( - "SELECT * FROM Product WHERE SupplierId = @SupplierId AND UnitsInStock < @Threshold", - new { SupplierId = supplierId, Threshold = threshold } -); - -// DbConnectionPlus -connection.Query( - $"SELECT * FROM Product WHERE SupplierId = {Parameter(supplierId)} AND UnitsInStock < {Parameter(threshold)}" -); -``` - -- No anonymous object to keep in sync with the SQL -- No renamed variable that compiles and then fails at run time -- Full auto-complete -- Full compiler verification - -Each value is written where it is used, and still reaches the database as a real `DbParameter`. Nothing to name -twice, no anonymous object to keep in sync with the SQL, and no renamed variable that compiles happily and blows -up at run time. - -## Throw a whole collection at the database - -```csharp -var retiredSupplierIds = suppliers.Where(a => a.IsRetired).Select(a => a.Id); - -var affectedProducts = connection.Query( - $""" - SELECT * - FROM Product - WHERE SupplierId IN (SELECT Value FROM {TemporaryTable(retiredSupplierIds)}) - """ -); -``` - -DbConnectionPlus creates a temporary table, bulk-loads your collection into it (`SqlBulkCopy` and the equivalent -for every other supported database) and drops it when the statement is done. Ten values or a million: no `IN` list -to build, no parameter limit to stay under, one query plan. - -Objects work just as well - each property becomes a column - so you can `JOIN` straight against in-memory data: - -```csharp -var orderedProducts = connection.Query<(long ProductId, int Quantity, decimal UnitPrice)>( - $""" - SELECT TOrderItem.ProductId, TOrderItem.Quantity, Product.UnitPrice - FROM Product - JOIN {TemporaryTable(orderItems)} TOrderItem ON TOrderItem.ProductId = Product.Id - """ -); -``` - -Dapper expands `IN @ids` into one parameter per element instead - a fresh query plan for every list length, and a -hard ceiling at the provider's parameter limit (2,100 on SQL Server). Table-valued parameters lift that ceiling, -but only on SQL Server, and only after you have created a table type in the database and hand-built a `DataTable`. - -## Full optimistic concurrence support - -```csharp -class Product -{ - [Key] - [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public long Id { get; set; } - - [Timestamp] - public byte[] Version { get; set; } - - public decimal UnitPrice { get; set; } -} - -connection.InsertEntity(newProduct); // Id and Version come back filled in -connection.UpdateEntity(product); // throws if someone else changed the row first -connection.DeleteEntities(discontinuedProducts); -``` - -Insert, update and delete - one entity or a whole sequence, synchronous or `…Async` - and **every one of them -supports optimistic concurrency**. Mark a property with `[Timestamp]` or `[ConcurrencyCheck]` and its original -value joins the `WHERE` clause, so an update or delete against a row that changed in the meantime raises -`DbUpdateConcurrencyException` instead of quietly matching nothing. Database-generated values are read back onto -your entity, and composite keys are supported. - -Dapper has no CRUD at all - you write every `INSERT`, `UPDATE` and `DELETE` by hand. Dapper.Contrib is the usual -answer, and it does add `Insert` / `Update` / `Delete` - but its `UPDATE` matches on the key alone, so **there is -no optimistic concurrency anywhere in the Dapper family**. It also cannot handle composite keys, has no Oracle -support, and has not shipped a release since 2020. - -## The attributes you already use - -```csharp -[Table("Products")] -class Product -{ - [Key] public long Id { get; set; } - [Column("ProductName")] public string Name { get; set; } - [NotMapped] public decimal TotalPrice => this.UnitPrice * this.Quantity; -} -``` - -Plain `System.ComponentModel.DataAnnotations` - the same attributes EF Core reads, so your entities usually need -no changes at all. Prefer to keep your domain model clean? A fluent API configures the same things from one place. -Dapper.Contrib brings its own smaller attribute set and cannot even map a column name. - -## Native AOT support with no extra steps - -```xml -true -``` - -That is the entire setup. No companion package, no source generator, no attribute on anything, and no `IL2xxx` or -`IL3xxx` warning in your build. Everything keeps working: entities, records and other immutable types, **value -tuples**, temporary tables, CRUD and enums. - -Dapper cannot run under Native AOT at all; you need the separate Dapper.AOT package and a `[DapperAot]` opt-in on -every method, type or module you want covered. Even then it does not support value tuples, and it does not cover -Dapper.Contrib's CRUD - so those simply have no Dapper answer under Native AOT. Worse, a call site Dapper.AOT -cannot handle is left as ordinary Dapper without any warning: the build stays green and the failure shows up in -your published binary. - -## Enums, your way, in one line - -```csharp -DbConnectionExtensions.Configure(config => config.EnumSerializationMode = EnumSerializationMode.Strings); -``` - -Readable strings or compact integers - the choice applies everywhere at once: entity properties, parameters and -temporary tables. Reading understands both, whichever your columns happen to hold. Dapper writes the underlying -number unless you write and register a type handler yourself - which Dapper.AOT's generated code then ignores. - -## Untyped rows without `dynamic` - -```csharp -var row = connection.QueryFirst($"SELECT * FROM Product WHERE Id = {Parameter(productId)}"); -var name = row["Name"]; -``` - -No cast, no `dynamic`, and it works under Native AOT - where Dapper's `dynamic` rows cannot be bound at all. If you -do want member access, one `dynamic` reference gives you `row.Name`; it is an option here rather than the only way -in. - -## See every command your app sends - -```csharp -DbConnectionExtensions.Configure(config => -{ - config.InterceptDbCommand = (command, _) => logger.LogDebug("SQL: {Sql}", command.CommandText); -}); -``` - -One hook, every statement DbConnectionPlus builds, right before it executes - for logging, a timeout, or a query -hint. Dapper has nothing comparable. - -## And it costs you nothing - -The [benchmark suite](performance.md) in this repository runs every feature three ways - hand-written `DbCommand`, -Dapper and DbConnectionPlus - against in-memory SQLite, the harshest possible setting because the query itself is -almost free there. The two libraries trade places from category to category, both within a small multiple of raw -ADO.NET, and DbConnectionPlus allocates less than Dapper in eleven of the seventeen categories. - -## When Dapper is still the better pick - -Being honest about it: Dapper has multi-mapping (`splitOn`) and `QueryMultiple` for several result sets from one -command, custom `ITypeHandler` conversions, support for .NET Framework and .NET Standard 2.0, and fifteen years of -ecosystem. DbConnectionPlus has none of the first three, requires `net8.0` or later, and needs a -[custom adapter](../guides/custom-adapters.md) for database systems beyond the [five it supports](https://github.com/rent-a-developer/DbConnectionPlus/blob/main/README.md#installation). diff --git a/docs/toc.yml b/docs/toc.yml index 7a5f126..0f2821b 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -18,8 +18,6 @@ items: - name: API summary href: reference/api-summary.md - - name: Comparison with Dapper - href: reference/comparison.md - name: Performance href: reference/performance.md - name: Design decisions diff --git a/scripts/clean-build-artifacts.ps1 b/scripts/clean-build-artifacts.ps1 index 3cb8e45..90ec9ff 100644 --- a/scripts/clean-build-artifacts.ps1 +++ b/scripts/clean-build-artifacts.ps1 @@ -16,11 +16,11 @@ - a fixed list of known generated directories, named below; - every bin/ and obj/ directory found INSIDE the resolved repository root. - It does not guess. It used to also delete any XML file beside a project file whose name matched that - project's AssemblyName, on the theory that such a file could only be a generated documentation file. That - theory is not provable from the file: an authored XML file is allowed to have that name, and a delete based - on a name pattern cannot tell the two apart. The leftovers it targeted are covered by .gitignore, so - `git clean -X` removes them with git's own knowledge of what is generated. + It does not guess. In particular it never deletes an XML file because its name matches a project's + AssemblyName: an authored XML file is allowed to have that name, and a delete based on a name pattern + cannot tell a generated documentation file from a hand-written one. Generated files that this script does + not know about are covered by .gitignore, so `git clean -X` removes them with git's own knowledge of what + is generated. Directories that are reparse points - symbolic links, junctions, mount points - are skipped rather than followed: deleting "recursively" through one deletes the target, which may be anywhere on the machine. The diff --git a/scripts/preflight.ps1 b/scripts/preflight.ps1 index b3c0f13..bffe6ab 100644 --- a/scripts/preflight.ps1 +++ b/scripts/preflight.ps1 @@ -1,7 +1,7 @@ <# .SYNOPSIS The pre-commit gate: repository hygiene, style/formatting/ordering, a Release build, and the unit - test suite on both target frameworks. + test suite on net8.0 and net10.0. .DESCRIPTION CONTRIBUTING.md requires that all tests pass and the build succeeds with no warnings. Because diff --git a/scripts/public-api-guard.ps1 b/scripts/public-api-guard.ps1 index 8d6c52e..f544d05 100644 --- a/scripts/public-api-guard.ps1 +++ b/scripts/public-api-guard.ps1 @@ -3,7 +3,7 @@ Prints the CONTRIBUTING.md companion-edit checklist when a project's public API files change. .DESCRIPTION - The public surface of the six shipping projects is declared in their PublicAPI.Shipped.txt and + The public surface of the shipping projects is declared in their PublicAPI.Shipped.txt and PublicAPI.Unshipped.txt files and enforced by Microsoft.CodeAnalysis.PublicApiAnalyzers - the build fails on a public member that is not declared (RS0016) or declared but gone (RS0017), so the build already stops an *accidental* change. diff --git a/scripts/update-public-api.ps1 b/scripts/update-public-api.ps1 index fb6e9d3..78e341f 100644 --- a/scripts/update-public-api.ps1 +++ b/scripts/update-public-api.ps1 @@ -21,7 +21,7 @@ maintainer step - see -MarkShipped - and is never part of an ordinary contribution. .PARAMETER Project - One or more project files to update. Defaults to all six shipping projects under src/. + One or more project files to update. Defaults to the shipping projects under src/. .PARAMETER MarkShipped The MAINTAINER's release step, not the edit step: fold PublicAPI.Unshipped.txt into diff --git a/src/Directory.Build.props b/src/Directory.Build.props index c5326a0..7f2e91f 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -13,7 +13,7 @@ AssemblyTitle, RootNamespace, PackageId, Description, PackageTags - stays in each .csproj, and so does IsPackable=true, because packing is opt-in. - Two things are deliberately NOT here any more. is in the repository-root + Two things live elsewhere on purpose. is in the repository-root Directory.Build.props, so one file answers "what version is this repository at". The files the packages carry are in the repository-root Directory.Build.targets, because they are conditioned on IsPackable, which a project sets after every .props file has been read. @@ -25,8 +25,8 @@ @@ -65,7 +65,7 @@ diff --git a/tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj b/tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj index 48c05a5..e717128 100644 --- a/tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj +++ b/tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj @@ -2,15 +2,15 @@ net8.0;net10.0 RentADeveloper.DbConnectionPlus.UnitTests diff --git a/tests/package-consumption/AotConsumer/README.md b/tests/package-consumption/AotConsumer/README.md index 9adebb8..c103188 100644 --- a/tests/package-consumption/AotConsumer/README.md +++ b/tests/package-consumption/AotConsumer/README.md @@ -33,9 +33,9 @@ repository's own build wiring out of here. pwsh -File scripts/verify-package-aot.ps1 -Pack ``` -That packs the six projects, publishes this consumer with `-p:PublishAot=true`, gates the IL diagnostics, and +That packs the shipping projects, publishes this consumer with `-p:PublishAot=true`, gates the IL diagnostics, and runs the native binary. Pass `-Framework net8.0` for the documented AOT floor; the default is `net10.0`. CI -runs the same script for both frameworks, on Linux and Windows, against the exact packages it will publish. +runs the same script for `net8.0` and `net10.0`, on Linux and Windows, against the exact packages it will publish. For the JIT baseline - the same assertions with the expression-tree materializers instead of the reflection ones - run it as an ordinary application: @@ -110,7 +110,7 @@ point the materializer is built - reflection reports no writable property that a guard covers both. It is also why the "broken chain, no guard, silent corruption" case cannot be reproduced through the public API at all, which is the whole point of having the guard. -Cases 1-14 all run against SQLite, the one provider that needs no server. The other four adapters are covered +Cases 1-14 all run against SQLite, the one provider that needs no server. The other adapters are covered for breadth by [`AllAdaptersConsumer`](../AllAdaptersConsumer) and against real databases by the integration suite. From 11e1bbe8f7ac190278e35edae06ff505f05aa4c7 Mon Sep 17 00:00:00 2001 From: David Liebeherr Date: Mon, 7 Sep 2026 05:42:01 +0200 Subject: [PATCH 13/17] refactor: rename preflight.ps1 to pre-commit-gate.ps1 "Preflight" says an aviation metaphor; it does not say when to run the script or what it decides. This is the gate you run before a commit - hygiene, style, formatting, member ordering, a Release build and the unit suite - and the name now says so, next to the pre-release gate that runs the heavier jobs. Every reference moves with it: the contribution and agent guidance, the pull-request template, both editor hook adapters, the sibling scripts and the comment in Directory.Build.props. Nothing about the script's behaviour changes; CI never referenced it by name. Refs #28 Co-Authored-By: Claude Opus 5 --- .agents/README.md | 6 +++--- .agents/skills/commit/SKILL.md | 4 ++-- .agents/skills/integration-db/SKILL.md | 2 +- .claude/hooks/public-api-guard.ps1 | 2 +- .claude/hooks/tidy-code.ps1 | 2 +- .codex/hooks/tidy-code.ps1 | 2 +- .github/PULL_REQUEST_TEMPLATE.md | 2 +- AGENTS.md | 12 ++++++------ CONTRIBUTING.md | 6 +++--- Directory.Build.props | 2 +- scripts/{preflight.ps1 => pre-commit-gate.ps1} | 10 +++++----- scripts/public-api-guard.ps1 | 2 +- scripts/tidy-code.ps1 | 2 +- 13 files changed, 27 insertions(+), 27 deletions(-) rename scripts/{preflight.ps1 => pre-commit-gate.ps1} (97%) diff --git a/.agents/README.md b/.agents/README.md index 80a681f..1feb2e9 100644 --- a/.agents/README.md +++ b/.agents/README.md @@ -70,17 +70,17 @@ not formatted by anything. So: ```bash pwsh -File scripts/tidy-code.ps1 # format what git reports as changed -pwsh -File scripts/preflight.ps1 # and before committing, check the whole tree +pwsh -File scripts/pre-commit-gate.ps1 # and before committing, check the whole tree ``` -`preflight.ps1` is the backstop for all of it, and the build is the backstop for `preflight`: formatting, +`pre-commit-gate.ps1` is the backstop for all of it, and the build is the backstop for it: formatting, style and member ordering are build errors, so an unformatted file cannot reach a green pull request whether a hook fired or not. The tidy hook runs the **default scope only**, on the file the edit touched — CSharpier, under a second. Code style and member ordering are not run on every edit: `dotnet format style` needs MSBuild and ReSharper loads the whole solution, and neither belongs on the critical path of a single edit. All of them are build errors, and -`scripts/preflight.ps1` checks `-Scope all` before a commit (`-Fix` applies it), so nothing reaches a pull +`scripts/pre-commit-gate.ps1` checks `-Scope all` before a commit (`-Fix` applies it), so nothing reaches a pull request untidied. When changing behavior, edit the canonical file. Keep only required names, descriptions, policies, tool/model diff --git a/.agents/skills/commit/SKILL.md b/.agents/skills/commit/SKILL.md index 59111f5..a165cfc 100644 --- a/.agents/skills/commit/SKILL.md +++ b/.agents/skills/commit/SKILL.md @@ -50,12 +50,12 @@ the maintainer's, at release time. If the change looks like it needs a release, succeeds with no warnings and the tests pass. ```bash -pwsh -File scripts/preflight.ps1 +pwsh -File scripts/pre-commit-gate.ps1 ``` That runs the public-API reminder, checks style, formatting and member ordering, builds Release and runs the unit suite on `net8.0` and `net10.0`. It does not edit your files; if it reports the tree as untidy, run -`pwsh -File scripts/preflight.ps1 -Fix` and review what changed before committing it. +`pwsh -File scripts/pre-commit-gate.ps1 -Fix` and review what changed before committing it. If either fails, report the failure and stop — do not commit over it. diff --git a/.agents/skills/integration-db/SKILL.md b/.agents/skills/integration-db/SKILL.md index 31468f4..e17922f 100644 --- a/.agents/skills/integration-db/SKILL.md +++ b/.agents/skills/integration-db/SKILL.md @@ -51,7 +51,7 @@ To scope to one database system, add a class filter — e.g. `--filter-class "*S ## Scoping the run — don't pay 10 minutes for every iteration **First decide whether you need database tests at all.** They are not part of the default verification loop — -`scripts/preflight.ps1` (hygiene, tidiness, Release build and the unit suite) is. Reach for the integration suite when the +`scripts/pre-commit-gate.ps1` (hygiene, tidiness, Release build and the unit suite) is. Reach for the integration suite when the change can only be proven against a real database: SQL generation, an adapter, the CRUD or temp-table paths, type mapping, or anything a substituted `DbDataReader` cannot exercise honestly. A refactor whose behaviour the unit tests already pin down does not need them. diff --git a/.claude/hooks/public-api-guard.ps1 b/.claude/hooks/public-api-guard.ps1 index b675c42..eda67a2 100644 --- a/.claude/hooks/public-api-guard.ps1 +++ b/.claude/hooks/public-api-guard.ps1 @@ -1,6 +1,6 @@ # Claude Code PostToolUse hook: remind about the companion edits a public API change needs. # -# The checklist itself lives in scripts/public-api-guard.ps1, so that Codex's hook and preflight produce +# The checklist itself lives in scripts/public-api-guard.ps1, so that Codex's hook and the pre-commit gate produce # exactly the same text. This file is only the hook wiring: read the tool payload off stdin, pull the edited # path out of it, and delegate. # diff --git a/.claude/hooks/tidy-code.ps1 b/.claude/hooks/tidy-code.ps1 index 58f3fac..8cb3417 100644 --- a/.claude/hooks/tidy-code.ps1 +++ b/.claude/hooks/tidy-code.ps1 @@ -11,7 +11,7 @@ # # Formatting only, which is the default scope and takes under a second. Style and member ordering are not run # here: `dotnet format style` needs MSBuild and ReSharper loads the whole solution, and neither belongs on the -# critical path of every single edit. All three are build errors, and scripts/preflight.ps1 checks `-Scope +# critical path of every single edit. All three are build errors, and scripts/pre-commit-gate.ps1 checks `-Scope # all` before a commit, so nothing slips through. # # Never fails the edit - a formatter problem is surfaced as text and the hook still exits 0. A PostToolUse diff --git a/.codex/hooks/tidy-code.ps1 b/.codex/hooks/tidy-code.ps1 index 5d7dd2e..519e077 100644 --- a/.codex/hooks/tidy-code.ps1 +++ b/.codex/hooks/tidy-code.ps1 @@ -17,7 +17,7 @@ # exactly the surprise a scoped hook exists to avoid. # # Formatting only, which is the default scope. Style and member ordering are build errors and -# scripts/preflight.ps1 checks `-Scope all` before a commit; neither belongs on the critical path of every +# scripts/pre-commit-gate.ps1 checks `-Scope all` before a commit; neither belongs on the critical path of every # edit. # # Contract (https://learn.chatgpt.com/docs/hooks): exit 0 and write the response JSON to stdout. Exit code 2 diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 34c20e0..6f56f60 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -11,7 +11,7 @@ tick it, or leave it and say why in the description. A box that does not apply i ### Always -- [ ] `pwsh -File scripts/preflight.ps1` passes. It runs the public-API reminder, checks style, formatting +- [ ] `pwsh -File scripts/pre-commit-gate.ps1` passes. It runs the public-API reminder, checks style, formatting and member ordering, builds Release and runs the unit suite on `net8.0` and `net10.0`. Use `-Fix` to have it apply the tidying rather than only report it. - [ ] The Release build produces **zero warnings**. `TreatWarningsAsErrors` is on, so this also covers style, diff --git a/AGENTS.md b/AGENTS.md index 42e3b61..242f0a1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,7 @@ methods on `DbConnection`, with per-database dialect support from pluggable adap | `docs/` | docfx config, the site landing page and implementation plans. | | `.agents/`, `.codex/`, `.claude/` | Canonical skills and references, plus each tool's agent metadata and hook wiring. | | `.github/workflows/` | `ci.yml` (lint → build/test → package + docs → package-consumption gates → publish), `codeql.yml`, `dependency-review.yml`. | -| `scripts/` | The commands you type: `preflight`, `verify-package-aot`, `benchmarks`, `update-public-api`, `clean-build-artifacts`, `extract-release-notes`. Plus `tidy-code` and `public-api-guard`, which the editor hooks run for you. | +| `scripts/` | The commands you type: `pre-commit-gate`, `verify-package-aot`, `benchmarks`, `update-public-api`, `clean-build-artifacts`, `extract-release-notes`. Plus `tidy-code` and `public-api-guard`, which the editor hooks run for you. | The solution file is `DbConnectionPlus.slnx` (XML `.slnx`, not `.sln`). **New projects must be added to it.** @@ -71,12 +71,12 @@ a miss, and that needs Docker. Run the adapter-parity reviewer, walk ```bash dotnet build DbConnectionPlus.slnx -c Release dotnet test --project tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj -pwsh -File scripts/preflight.ps1 # the default loop: hygiene, tidiness CHECK, build, unit tests -pwsh -File scripts/preflight.ps1 -Fix # the same, but tidy the working tree first +pwsh -File scripts/pre-commit-gate.ps1 # the default loop: hygiene, tidiness CHECK, build, unit tests +pwsh -File scripts/pre-commit-gate.ps1 -Fix # the same, but tidy the working tree first pwsh -File scripts/verify-package-aot.ps1 -Pack # the Native AOT gate ``` -`preflight.ps1` **writes build output and nothing else by default** — it does not edit source and it does not +`pre-commit-gate.ps1` **writes build output and nothing else by default** — it does not edit source and it does not touch the git index. `-Fix` is what rewrites files. `tidy-code.ps1 -Check` never writes at any scope: at `-Scope all` it runs the pipeline on a disposable copy of the tree and prints the diff from there. @@ -87,7 +87,7 @@ eight projects. The Release build covers every project, so it turns a missed cal The Native AOT gate is the **only** check that can see silent trimming damage, because nothing is trimmed on the JIT. Run it whenever you change reflection, DAM annotations, the materializers or the temp-table readers. It packs the shipping projects, publishes `AotConsumer` natively **from the packages**, gates its IL diagnostics and runs the -binary. It needs a C++ toolchain and minutes, so it is not in `preflight.ps1`; `-Framework net8.0` checks the +binary. It needs a C++ toolchain and minutes, so it is not in `pre-commit-gate.ps1`; `-Framework net8.0` checks the documented AOT floor, and CI runs that as well as the `net10.0` default. Integration tests need only a running Docker daemon; [Testcontainers](https://dotnet.testcontainers.org/) starts a @@ -141,7 +141,7 @@ pwsh -File scripts/tidy-code.ps1 -Scope style # ~15s + the code-style fixers pwsh -File scripts/tidy-code.ps1 -Scope all # ~3min + member ordering, whole solution ``` -**Before you commit, run `-Scope all`** — or `scripts/preflight.ps1 -Fix`, which does it for you. That is the +**Before you commit, run `-Scope all`** — or `scripts/pre-commit-gate.ps1 -Fix`, which does it for you. That is the only scope that reorders members, because ReSharper loads the whole solution either way. Claude Code and Codex run the **default scope** on the file each edit touched, through a PostToolUse hook; style and ordering are not run there, because they are too slow for a single edit and the build catches them. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fd2a09a..14f7a5e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -116,7 +116,7 @@ pipeline on a disposable copy and prints the diff it produced there. ### Verifying it ```shell -pwsh -File scripts/preflight.ps1 +pwsh -File scripts/pre-commit-gate.ps1 ``` That is the gate to run before every commit. It checks the public API, checks style, formatting and ordering, @@ -125,7 +125,7 @@ nothing else** — it does not edit your files and it does not touch the git ind the tidying first: ```shell -pwsh -File scripts/preflight.ps1 -Fix +pwsh -File scripts/pre-commit-gate.ps1 -Fix ``` `TreatWarningsAsErrors` is on for **every** project, so the build is also the style, member-ordering, @@ -136,7 +136,7 @@ build never rewrites your files — it fails and names them. trimming annotations are complete, and an incomplete chain means entities that come back silently empty under trimming. -Two gates are deliberately outside preflight, because each takes minutes and neither applies to every change: +Two gates are deliberately outside the pre-commit gate, because each takes minutes and neither applies to every change: | Gate | Run it when | Command | |---|---|---| diff --git a/Directory.Build.props b/Directory.Build.props index 45fbcc7..e6b132f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -92,7 +92,7 @@