diff --git a/.agents/README.md b/.agents/README.md index 78b915d..1feb2e9 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:`. + +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 + 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/pre-commit-gate.ps1 # and before committing, check the whole tree +``` + +`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/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 settings, and reference instructions in tool-specific files. diff --git a/.agents/references/code-style.md b/.agents/references/code-style.md index 83f459b..cd0caea 100644 --- a/.agents/references/code-style.md +++ b/.agents/references/code-style.md @@ -4,15 +4,21 @@ 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 | |---|---|---| | 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` | -Each tool owns its concern completely, and all three are build errors rather than warnings, in `tests/` and +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 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 — @@ -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 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 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/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 31b2861..4b081d6 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. @@ -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 ee3e096..a165cfc 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. -- **Adapter change?** Was it mirrored into the other four adapters? Delegate the check to the +- **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 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. -## 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". +⚠️ **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. -```bash -pwsh -File scripts/preflight.ps1 -``` +## 3. Verify it builds -That script runs the public-API reminder, the Release build and the unit tests. Equivalent by hand: +`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 -dotnet build DbConnectionPlus.slnx -c Release +pwsh -File scripts/pre-commit-gate.ps1 ``` -```bash -dotnet test --project tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj -``` +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/pre-commit-gate.ps1 -Fix` and review what changed before committing it. -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..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 + Release build + unit tests) 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. @@ -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 3d0c5fb..69386eb 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 +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 --- # 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/.claude/hooks/public-api-guard.ps1 b/.claude/hooks/public-api-guard.ps1 index f6e811b..eda67a2 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 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. # -# 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..8cb3417 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/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 +# 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/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/.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..519e077 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/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 # 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)" } diff --git a/.editorconfig b/.editorconfig index b47816e..1f7d475 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. @@ -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,9 +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. +# `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 +csharp_style_var_elsewhere = true:silent #### Member access: always `this.` #### @@ -66,7 +70,7 @@ csharp_style_var_when_type_is_apparent = true:error # # 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 @@ -100,7 +104,7 @@ 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 +csharp_style_prefer_top_level_statements = false:silent dotnet_style_prefer_auto_properties = true:error #### Modern language features #### @@ -168,9 +172,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 +200,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 +228,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 +246,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/.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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 0e1f62d..e722d3d 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -25,7 +25,7 @@ body: options: - The core library (applies to every database) - One database adapter - - All five database adapters (a change to the adapter seam) + - Every database adapter (a change to the adapter seam) - Not sure - type: textarea id: alternatives diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 5f45a2e..6f56f60 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/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, + 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 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..f9b9f93 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. + # 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. 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..27d142c 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 @@ -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 @@ -284,10 +284,13 @@ 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 + # 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 @@ -384,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 }} @@ -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 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 78612b2..9dbac44 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. 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 -.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 diff --git a/AGENTS.md b/AGENTS.md index d16036e..ac69bd9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,22 +22,35 @@ 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`, `pre-release-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, and `verify-line-endings`, which both gates run. | 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 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 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 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 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 @@ -47,47 +60,53 @@ 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 ```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/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 +pwsh -File scripts/pre-release-gate.ps1 # everything CI checks that can be checked locally ``` +`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. + 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. 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 -binary. It needs a C++ toolchain and minutes, so it is not in `preflight.ps1`; `-Framework net8.0` checks 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 `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 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 @@ -115,7 +134,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 @@ -123,10 +142,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/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. ## Tests @@ -137,21 +156,21 @@ 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 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 -reflects, run the AOT/trim reviewer or walk [its checklist](.agents/references/reviews/aot-compat.md). Three +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). These constraints must not be broken: - **Nothing may generate code at run time** — that, not reflection, is what Native AOT forbids. Accessors use @@ -164,16 +183,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. @@ -181,8 +206,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 @@ -191,8 +216,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 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/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..c65e51b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,176 +1,237 @@ # 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 -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`. +The Native AOT gate and the benchmarks' AOT job publish a native binary, which needs a C++ toolchain: -To refresh a clone made before this policy (commit or stash your changes first - the second command -discards uncommitted work): +| 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. + +Add a body whenever the *why* is not obvious from the subject, and reference the issue number there when the +branch has one. + +### Style, formatting and member ordering + +Each tool below owns one concern, and every one of them is a build error: + +| 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 them: ```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/pre-commit-gate.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 `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: + +```shell +pwsh -File scripts/pre-commit-gate.ps1 -Fix +``` + +`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. + +⚠️ **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. + +Two gates are deliberately outside the pre-commit gate, because each takes minutes and neither applies to every change: + +| 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` | -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). +**Scope the integration run.** The default scope is SQLite + SQL Server (about 90 seconds, against about 10 +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. -## Code of Conduct +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. -### Our Pledge +### Before you push -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. +The pre-commit gate is the per-commit loop. Before pushing a branch you want CI to go green on, there is a +wider one that runs everything CI checks and can be checked here — the full integration matrix, the +documentation build with warnings as errors, the pack with package validation, the Native AOT gate on both +frameworks, and the all-adapters package consumer: -### Our Standards +```shell +pwsh -File scripts/pre-release-gate.ps1 +``` + +It stops at the first failure and ends with one line: `PASSED: All checks passed.` or +`FAILED: Check X failed. See output.` What it cannot cover — CodeQL, the dependency review, the Codecov +upload, the Pages deployment, the publish itself, the Linux legs and the .NET-8-SDK-only leg — is listed in +the script's own help. + +### Database adapters -Examples of behavior that contributes to creating a positive environment -include: +The adapter projects under `src/DbConnectionPlus.DatabaseAdapters.*` each implement +`IDatabaseAdapter`, `IEntityManipulator` and `ITemporaryTableBuilder` with per-dialect SQL. -* 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 +**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. -Examples of unacceptable behavior by participants include: +### Companion edits -* 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 +| 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/` | -### Our Responsibilities +Internal formatting and tooling work needs no changelog entry: the changelog is for what a consumer sees. -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. +**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. -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. +### Opening the pull request -### Scope +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. -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. +A maintainer will review it, and may ask for changes. -### Enforcement +## Line endings -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. +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. -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. +`.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 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`. +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. Run the pre-release gate with the version you are releasing. `-Version` adds the two checks CI runs + immediately before publishing — the declared version matches, and the changelog section for it is dated + and non-empty: + ```shell + pwsh -File scripts/pre-release-gate.ps1 -Version 4.1.0 + ``` +5. 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 every package to NuGet.org and creates the GitHub release. diff --git a/DbConnectionPlus.slnx.DotSettings b/DbConnectionPlus.slnx.DotSettings index 54a5ad3..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"> + <?xml version="1.0" encoding="utf-16"?> +<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" /> @@ -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..e6b132f 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,107 +1,155 @@ - - - - David Liebeherr - rent-a-developer - Copyright © rent-a-developer / David Liebeherr - - - - enable - latest - enable - true - - - - True - true - - - true - - - - - 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 - - - - - - - + + + + + 4.0.0 + + + + David Liebeherr + rent-a-developer + Copyright © rent-a-developer / David Liebeherr + + + + enable + latest + enable + true + + + + + false + + + + + true + all + moderate + NU1901;NU1902;NU1903;NU1904 + + + + True + true + + + true + + + + + 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 + + + + + + + diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 0000000..bb5e387 --- /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..76cb544 --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,106 @@ + + + + + + true + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/PACKAGE_README.md b/PACKAGE_README.md index 4096efb..c470850 100644 --- a/PACKAGE_README.md +++ b/PACKAGE_README.md @@ -1,23 +1,18 @@ # 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. @@ -104,14 +99,14 @@ 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 +114,57 @@ 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. +The 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) +- [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..0b3bf35 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,43 +19,16 @@ 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) +var lowStockProducts = connection.Query( + $"SELECT * FROM Product WHERE UnitsInStock < {Parameter(threshold)}" +); +``` ## Why not just use Dapper? @@ -114,7 +87,7 @@ 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)>( +var orderedProducts = connection.Query<(long ProductId, int Quantity, decimal UnitPrice)>( $""" SELECT TOrderItem.ProductId, TOrderItem.Quantity, Product.UnitPrice FROM Product @@ -134,12 +107,12 @@ class Product { [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] - public Int64 Id { get; set; } + public long Id { get; set; } [Timestamp] - public Byte[] Version { get; set; } + public byte[] Version { get; set; } - public Decimal UnitPrice { get; set; } + public decimal UnitPrice { get; set; } } connection.InsertEntity(newProduct); // Id and Version come back filled in @@ -164,9 +137,9 @@ support, and has not shipped a release since 2020. [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; + [Key] public long Id { get; set; } + [Column("ProductName")] public string Name { get; set; } + [NotMapped] public decimal TotalPrice => this.UnitPrice * this.Quantity; } ``` @@ -225,7 +198,7 @@ hint. Dapper has nothing comparable. ### And it costs you nothing -The [benchmark suite](#benchmarks) in this repository runs every feature three ways - hand-written `DbCommand`, +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. @@ -235,69 +208,75 @@ ADO.NET, and DbConnectionPlus allocates less than Dapper in eleven of the sevent 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). +[custom adapter](docs/guides/custom-adapters.md) for database systems beyond the [database systems it supports](#installation). -## Quick start +## Requirements + +| | | +|---|---| +| 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 | + +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` | + +The packages are versioned and released together, so a release's adapter always matches its core package. -### Register Database Adapters +## Quick start -Before using DbConnectionPlus, register the adapter(s) for the database system(s) you use. This should be done -once at application startup: +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,1016 +285,46 @@ 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: - -```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: +## What it does -| 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`: - -```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; - -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](#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`, `Int32`, `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 Int64 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](#enumserializationmode), and the column is typed `NVARCHAR(200)` for `Strings` and -`INT` for `Integers`. - -### 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(); - - // 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. - -## 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. +| [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 [API summary](docs/reference/api-summary.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. ## Links -- [API documentation](https://rent-a-developer.github.io/DbConnectionPlus/) +- [Documentation](docs/) — guides, reference, and the design record +- [API reference](https://rent-a-developer.github.io/DbConnectionPlus/) - [Change log](CHANGELOG.md) -- [Design decisions](DESIGN-DECISIONS.md) - why the library works the way it does +- [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) ## Contributors 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/benchmarks/DbConnectionPlus.Benchmarks/DbConnectionPlus.Benchmarks.csproj b/benchmarks/DbConnectionPlus.Benchmarks/DbConnectionPlus.Benchmarks.csproj index 7d9fcb8..3f7ce76 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/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/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/DESIGN-DECISIONS.md b/docs/DESIGN-DECISIONS.md similarity index 96% rename from DESIGN-DECISIONS.md rename to docs/DESIGN-DECISIONS.md index 469a876..94bd8d8 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) | @@ -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 @@ -431,7 +432,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 @@ -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,15 +755,14 @@ 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. 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..54c932f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,12 +4,33 @@ 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 | +| [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 every package starts at +[`DbConnectionExtensions`](xref:RentADeveloper.DbConnectionPlus.DbConnectionExtensions) — the entry point for +nearly every operation. ## The packages @@ -28,12 +49,11 @@ 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. - -## Native AOT +They are versioned and released together, so a given release's adapter always matches its core package. -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/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..0f2821b 100644 --- a/docs/toc.yml +++ b/docs/toc.yml @@ -1,4 +1,36 @@ - 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: 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/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..90ec9ff 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. 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 + .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..9c9aaa2 100644 --- a/scripts/extract-release-notes.ps1 +++ b/scripts/extract-release-notes.ps1 @@ -1,15 +1,24 @@ <# .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 gate the publication come from one place. The CHANGELOG must contain exactly one dated `## [x.y.z] - YYYY-MM-DD` heading for the version, with no - TBD placeholder and a non-empty body. Those three failures all mean the same thing - a release was tagged - 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. + TBD placeholder and a non-empty body. Those failures all mean the same thing - a release was tagged + before its changelog entry was finished - and it is much cheaper to fail here than to publish 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. @@ -17,12 +26,13 @@ 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/pre-commit-gate.ps1 b/scripts/pre-commit-gate.ps1 new file mode 100644 index 0000000..6eaf533 --- /dev/null +++ b/scripts/pre-commit-gate.ps1 @@ -0,0 +1,315 @@ +<# +.SYNOPSIS + The pre-commit gate: repository hygiene, line endings, style/formatting/ordering, a Release build, + and the unit test suite on net8.0 and net10.0. + +.DESCRIPTION + CONTRIBUTING.md requires that all tests pass and the build succeeds with no warnings. Because + TreatWarningsAsErrors=true, the build is also the style, trim-analyzer and public-API gate: IL2xxx / + IL3050 diagnostics fail it, and so does an undeclared or vanished public member (RS0016 / RS0017). + + AI agents have hooks that nag about the public API files as you edit, but a hook only sees edits made + 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. + + 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. + + 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 the full matrix. + 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 the style, formatting and ordering step entirely. The build still fails on any of them. + +.PARAMETER SkipBuild + Skip the Release build (implies -SkipTests). + +.PARAMETER SkipTests + Skip the unit test run. + +.PARAMETER Configuration + Build configuration. Release by default, because that is what CI and CONTRIBUTING.md use. + +.EXAMPLE + pwsh -File scripts/pre-commit-gate.ps1 + +.EXAMPLE + pwsh -File scripts/pre-commit-gate.ps1 -Fix +#> +#requires -Version 7.0 +[CmdletBinding()] +param( + [Switch] $Fix, + [Switch] $SkipBuild, + [Switch] $SkipTests, + [Switch] $SkipTidy, + [String] $Configuration = 'Release' +) + +$ErrorActionPreference = 'Stop' + +# 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' +$lineEndings = Join-Path $repositoryRoot 'scripts/verify-line-endings.ps1' +$tidy = Join-Path $repositoryRoot 'scripts/tidy-code.ps1' + +$failures = New-Object System.Collections.Generic.List[String] + +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, $lineEndings, $tidy)) +{ + if (-not (Test-Path -LiteralPath $required -PathType Leaf)) + { + Write-Output "pre-commit-gate: 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. It reads; it never writes. + +Write-Section 'Hygiene: public API' + +$guardOutput = & pwsh -NoProfile -NonInteractive -File $publicApiGuard +$guardExitCode = $LASTEXITCODE + +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 +{ + Write-Output 'Unchanged.' +} + +# --- 2. Line endings ---------------------------------------------------------------------------------- +# Cheap, and it has to come before the tidiness check rather than after it, because the tidiness check +# cannot see this: it commits a disposable copy of the tree before running the tools, and committing is +# what normalizes line endings away. A CRLF tree passes step 3 and fails step 4 with one +# "Was not formatted." error per file, none of which mentions a line ending. +# +# It reads; it never writes, and it never touches the git index. scripts/verify-line-endings.ps1 says why +# both columns of `git ls-files --eol` are checked and what fixes each. + +Write-Section 'Hygiene: line endings' + +& pwsh -NoProfile -NonInteractive -File $lineEndings + +if ($LASTEXITCODE -ne 0) +{ + $failures.Add('line endings') +} + +# --- 3. Style, formatting and member ordering --------------------------------------------------------- +# 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. +# +# 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. + +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 + $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 + { + $dirtyAfter = @(& git -C $repositoryRoot diff --name-only -- '*.cs' 2>$null | Where-Object { $_ }) + $tidied = @($dirtyAfter | Where-Object { $_ -notin $dirtyBefore }) + + Write-Output '' + 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) + { + # 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 + { + Write-Output 'Already tidy.' + } + } +} +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/pre-commit-gate.ps1 -Fix' + } + else + { + # The check above asks whether the tools would change a COPY of the tree. CSharpier.MsBuild asks + # about the tree itself, during the build, and anything that lives around the files rather than in + # them can make the two disagree - the line endings step 2 covers, or a .csharpierrc or + # .editorconfig in a directory ABOVE this repository, which a copy under the temp directory never + # sees. Asking CSharpier here costs about a second and names the file and the reason; the build + # names every file in the solution and calls all of them unformatted. + Invoke-FromRepositoryRoot { & dotnet csharpier check . } + + if ($LASTEXITCODE -ne 0) + { + $failures.Add('formatting') + Write-Output '' + Write-Output 'FAIL - CSharpier rejects the tree as it stands on disk, though the check above' + Write-Output 'passed on a copy of it. The build fails the same way, less legibly.' + } + } +} + +# --- 4. Build ---------------------------------------------------------------------------------------- + +if ($SkipBuild) +{ + Write-Section 'Build' + Write-Output 'Skipped (-SkipBuild).' +} +else +{ + Write-Section "Build ($Configuration)" + + 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.' + } +} + +# --- 5. 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 ($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 'Pre-commit gate summary' + +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, 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/pre-release-gate.ps1 b/scripts/pre-release-gate.ps1 new file mode 100644 index 0000000..2682055 --- /dev/null +++ b/scripts/pre-release-gate.ps1 @@ -0,0 +1,481 @@ +<# +.SYNOPSIS + The pre-release gate: everything CI checks that can honestly be checked on this machine, in the order + CI checks it. + +.DESCRIPTION + Run this before pushing a release - or any branch you want CI to go green on. It is the pre-commit + gate plus the jobs that gate a publish: the full integration matrix, the documentation build with + warnings as errors, the pack with package validation, the Native AOT gate against the packed packages, + and the all-adapters package consumer. + + It stops at the first failure. Later steps consume what earlier ones produce - the AOT gate and the + consumer run against the packages the pack step wrote - so continuing past a failure would only + produce a second, misleading one. + + WHAT IT DOES NOT COVER, and why. None of this is a judgement about importance; each one either needs + infrastructure this machine does not have or is meaningless outside CI: + + CodeQL, dependency review Need GitHub's analysis and advisory services. + Codecov upload Needs the OIDC token CI holds. The coverage numbers are produced by the + test run here; it is only the upload that cannot happen. + GitHub Pages deployment Needs the Pages environment. The site itself IS built here. + NuGet publication Needs Trusted Publishing and a tag. Nothing here publishes anything. + The Linux legs CI runs the AOT gate and the consumers on Linux and Windows. This runs + them on whichever platform you are on. + The .NET-8-SDK-only leg CI installs the 8.0 SDK alone to prove the documented net8.0 floor. + This machine resolves the SDK the root global.json pins, so the + consumer step here proves the packages work - not that they work with + nothing but an 8.0 SDK installed. + + A dirty working tree is reported, not rejected: CI tests the commit you push, so anything uncommitted + is untested by definition, but you may well still be iterating. + +.PARAMETER Version + The version you are about to release, for example 4.1.0. When given, two extra checks run - the same + two CI runs immediately before it publishes: the in the repository-root Directory.Build.props + must match, and CHANGELOG.md must hold exactly one dated, non-empty section for it. Omit it and both + are skipped, because a branch push publishes nothing. + +.PARAMETER SkipIntegrationTests + Skip the integration suite. It needs a running Docker daemon and takes about ten minutes. + +.PARAMETER SkipNativeAot + Skip the Native AOT gate. It needs a C++ toolchain - MSVC on Windows, clang and zlib1g-dev on Linux. + +.PARAMETER Configuration + Build configuration. Release by default, because that is what CI uses and what gets published. + +.EXAMPLE + pwsh -File scripts/pre-release-gate.ps1 + +.EXAMPLE + pwsh -File scripts/pre-release-gate.ps1 -Version 4.1.0 +#> +#requires -Version 7.0 +[CmdletBinding()] +param( + [String] $Version, + [Switch] $SkipIntegrationTests, + [Switch] $SkipNativeAot, + [String] $Configuration = 'Release' +) + +$ErrorActionPreference = 'Stop' + +# scripts/ - the repository root is one level up, whatever the current directory is. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path +$solutionFileName = 'DbConnectionPlus.slnx' +$unitTestProject = 'tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj' +$consumerDirectory = Join-Path $repositoryRoot 'tests/package-consumption' +$packageOutput = Join-Path $repositoryRoot 'artifacts/packages' +$docfxConfiguration = 'build/docfx/docfx.json' + +$failure = $null +$stepSucceeded = $true + +function Write-Section +{ + param([Parameter(Mandatory)] [String] $Title) + + Write-Output '' + Write-Output "=== $Title ===" +} + +function Invoke-FromRepositoryRoot +{ + <# + Runs a command with the repository root as the working directory, restoring the caller's location + in a finally block. It returns NOTHING and the caller reads $LASTEXITCODE - returning the exit code + would mix it into the pipeline with everything the command printed, and that output is exactly what + you need when this step is the one that failed. + + 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 and selects the + Microsoft.Testing.Platform test runner. + #> + param([Parameter(Mandatory)] [ScriptBlock] $Command) + + Push-Location -LiteralPath $repositoryRoot + try + { + & $Command + } + finally + { + Pop-Location + } +} + +function Get-DeclaredVersion +{ + # The single source of truth for the version of every package, and what `dotnet pack` produces. + $sharedProperties = Join-Path $repositoryRoot 'Directory.Build.props' + + return ([Xml] (Get-Content -Raw $sharedProperties)).Project.PropertyGroup.Version | + Where-Object { $_ } | + Select-Object -First 1 +} + +function Invoke-Step +{ + <# + Runs one step unless an earlier one failed. $script:failure holds the name of the first failure and + every later step becomes a no-op, so the output ends with the failure that matters rather than with + whatever fell over as a consequence of it. + + A step reports its result through $script:stepSucceeded, and NOT by returning a value. In PowerShell + a script block's output IS its return value, so `if (-not (& $Command))` would capture every line the + build, the test run and the AOT gate print - the caller would see an array of build output rather + than a result, nothing would reach the screen until the step ended, and a failing step that printed + anything at all would evaluate as true and be recorded as a pass. Calling it bare lets its output + stream straight to the console, which is where "See output" points. + #> + param( + [Parameter(Mandatory)] [String] $Name, + [Parameter(Mandatory)] [ScriptBlock] $Command + ) + + if ($script:failure) + { + return + } + + Write-Section $Name + + # A step that returns without saying otherwise passed. + $script:stepSucceeded = $true + + & $Command + + if (-not $script:stepSucceeded) + { + $script:failure = $Name + } +} + +# --- Working tree ------------------------------------------------------------------------------------- + +Write-Section 'Working tree' + +$uncommitted = @(& git -C $repositoryRoot status --porcelain | Where-Object { $_ }) + +if ($uncommitted) +{ + Write-Output "$($uncommitted.Count) uncommitted change(s). CI tests the commit you push, so these are not covered:" + $uncommitted | Select-Object -First 10 | ForEach-Object { Write-Output " $_" } + + if ($uncommitted.Count -gt 10) + { + Write-Output " ... and $($uncommitted.Count - 10) more" + } +} +else +{ + Write-Output 'Clean.' +} + +# --- Release identity --------------------------------------------------------------------------------- +# CI verifies both of these immediately before it publishes. A tag that does not match the packed version +# publishes stale packages or, thanks to --skip-duplicate, nothing at all; a missing changelog section +# produces a release whose notes are empty. Neither runs on a branch push, so both are opt-in here. + +Invoke-Step 'Release identity' { + if (-not $Version) + { + Write-Output 'Skipped - pass -Version to check the declared version and the changelog section.' + + return + } + + $declared = Get-DeclaredVersion + + if ($declared -ne $Version) + { + Write-Output "FAILED - Directory.Build.props declares $declared, not $Version." + + $script:stepSucceeded = $false + + return + } + + Write-Output "Directory.Build.props declares $declared." + + Invoke-FromRepositoryRoot { + & pwsh -NoProfile -NonInteractive -File 'scripts/extract-release-notes.ps1' -Version $Version + } + + $script:stepSucceeded = ($LASTEXITCODE -eq 0) +} + +# --- Ignored revisions -------------------------------------------------------------------------------- +# The lint job's first check, and the cheapest. A revision in .git-blame-ignore-revs that no longer +# resolves is skipped silently by git, and blame goes back to pointing at the tool that reformatted the +# line - which is exactly what squashing or rebasing a branch that added one does. + +Invoke-Step 'Ignored revisions' { + $revisions = @(Get-Content -LiteralPath (Join-Path $repositoryRoot '.git-blame-ignore-revs') | + Where-Object { $_ -match '^[0-9a-f]{40}$' }) + + if (-not $revisions) + { + Write-Output 'FAILED - .git-blame-ignore-revs lists no revisions.' + + $script:stepSucceeded = $false + + return + } + + $unresolved = @($revisions | Where-Object { + & git -C $repositoryRoot cat-file -e "$_^{commit}" 2>$null + + $LASTEXITCODE -ne 0 + }) + + if ($unresolved) + { + Write-Output 'FAILED - these revisions are not commits in this repository:' + $unresolved | ForEach-Object { Write-Output " $_" } + Write-Output 'A rebase or a squash merge rewrote them. Replace each with the SHA it became.' + + $script:stepSucceeded = $false + + return + } + + Write-Output "$($revisions.Count) revision(s), all resolve." + + return +} + +# --- Line endings ------------------------------------------------------------------------------------- +# The lint job's second check, and the one whose absence here was expensive: a tree whose files are CRLF +# fails the build two steps below with one "Was not formatted." error per file, every one of them naming +# the formatter rather than the line endings, and nothing before it - not the working-tree step above, not +# the tidiness check below - can see it. scripts/verify-line-endings.ps1 says why in full. + +Invoke-Step 'Line endings' { + Invoke-FromRepositoryRoot { + & pwsh -NoProfile -NonInteractive -File 'scripts/verify-line-endings.ps1' + } + + $script:stepSucceeded = ($LASTEXITCODE -eq 0) +} + +# --- Style, formatting and ordering ------------------------------------------------------------------- +# The lint job. All of it is also a build error, so this is only the faster way to find out - but it prints +# the diff that would fix things, where the build names the file and stops. It checks a disposable copy of +# the tree and never writes to yours. + +Invoke-Step 'Style, formatting and ordering' { + Invoke-FromRepositoryRoot { + & pwsh -NoProfile -NonInteractive -File 'scripts/tidy-code.ps1' -Scope all -Check + } + + if ($LASTEXITCODE -ne 0) + { + Write-Output '' + Write-Output 'FAILED - the tree is not tidy. Apply it with: pwsh -File scripts/pre-commit-gate.ps1 -Fix' + + $script:stepSucceeded = $false + + return + } + + # And then the same question of THIS tree, which is not the same question. + # + # The check above runs the tools for real on a copy placed outside the repository and asks git whether + # anything changed there. That is the only honest way to check ordering - ReSharper has no check mode - + # but it answers about the copy, and two things that decide how CSharpier formats are not copied with + # the files: the line endings, which git normalizes away the moment the copy is committed, and anything + # a directory ABOVE the repository contributes, which a copy under the temp directory does not have. + # + # CSharpier.MsBuild asks about the tree instead, during the build, and when the two disagree the build + # is where you find out: one "Was not formatted." error per file, naming a formatter that has just + # reported the tree tidy. Asking CSharpier directly here costs a second or two and turns that into a + # named file and a diff, one step earlier. + Invoke-FromRepositoryRoot { & dotnet csharpier check . } + + if ($LASTEXITCODE -ne 0) + { + Write-Output '' + Write-Output 'FAILED - CSharpier rejects the tree as it stands on disk, though the check above passed' + Write-Output 'on a copy of it. The build would fail the same way. When the files themselves look' + Write-Output 'right, the difference is around them: line endings (the step above), or a .csharpierrc' + Write-Output 'or .editorconfig in a directory above this repository that the copy never saw.' + + $script:stepSucceeded = $false + + return + } + + return +} + +# --- Build -------------------------------------------------------------------------------------------- +# TreatWarningsAsErrors is on for every project, 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. + +Invoke-Step "Build ($Configuration)" { + Invoke-FromRepositoryRoot { & dotnet build $solutionFileName -c $Configuration } + + $script:stepSucceeded = ($LASTEXITCODE -eq 0) +} + +# --- Tests -------------------------------------------------------------------------------------------- +# CI runs the whole solution in one command: the unit suite on net8.0 and net10.0, plus the full +# integration matrix. --no-build and the configuration are both required - without them `dotnet test` +# silently rebuilds in Debug and tests that instead of the Release build above. + +Invoke-Step 'Tests' { + if ($SkipIntegrationTests) + { + Write-Output 'Integration suite skipped (-SkipIntegrationTests). Unit suite only.' + + Invoke-FromRepositoryRoot { + & dotnet test --project $unitTestProject -c $Configuration --no-build + } + + $script:stepSucceeded = ($LASTEXITCODE -eq 0) + + return + } + + & docker version --format '{{.Server.Version}}' *> $null + + if ($LASTEXITCODE -ne 0) + { + Write-Output 'FAILED - the integration suite needs a running Docker daemon. Start Docker, or pass' + Write-Output '-SkipIntegrationTests to run the unit suite alone.' + + $script:stepSucceeded = $false + + return + } + + Invoke-FromRepositoryRoot { + & dotnet test --solution $solutionFileName -c $Configuration --no-build + } + + $script:stepSucceeded = ($LASTEXITCODE -eq 0) +} + +# --- Documentation ------------------------------------------------------------------------------------ +# --warningsAsErrors, because a docfx warning is a broken cross-reference or a file the configuration does +# not reach, and both of those reach the published site as a hole. + +Invoke-Step 'Documentation' { + Invoke-FromRepositoryRoot { & dotnet tool run docfx metadata $docfxConfiguration } + + if ($LASTEXITCODE -ne 0) + { + Write-Output 'FAILED - docfx metadata. Run `dotnet tool restore` if docfx is missing.' + + $script:stepSucceeded = $false + + return + } + + Invoke-FromRepositoryRoot { & dotnet tool run docfx build $docfxConfiguration --warningsAsErrors } + + $script:stepSucceeded = ($LASTEXITCODE -eq 0) +} + +# --- Pack --------------------------------------------------------------------------------------------- +# Package validation runs here, against the last published version: removing or changing a public +# signature fails the pack rather than reaching nuget.org. Both steps below consume what this writes. + +Invoke-Step 'Pack' { + Invoke-FromRepositoryRoot { + & dotnet pack $solutionFileName -c $Configuration --no-build -o $packageOutput + } + + if ($LASTEXITCODE -ne 0) + { + $script:stepSucceeded = $false + + return + } + + # `dotnet pack --no-build` says nothing at all when it succeeds, which in a gate reads like a step that + # did not run. Name what it produced instead. + Get-ChildItem -LiteralPath $packageOutput -Filter '*.nupkg' | + Sort-Object Name | + ForEach-Object { Write-Output " $($_.Name)" } +} + +# --- Native AOT gate ---------------------------------------------------------------------------------- +# The only check in the repository that can see silent trimming damage: nothing is trimmed on the JIT, so +# the entire unit and integration suite passes with a broken annotation chain. Both target frameworks, +# because net8.0 is not the same code as net10.0 - it carries an IL3050 suppression net10.0 does not. + +Invoke-Step 'Native AOT gate' { + if ($SkipNativeAot) + { + Write-Output 'Skipped (-SkipNativeAot).' + + return + } + + foreach ($framework in @('net8.0', 'net10.0')) + { + Invoke-FromRepositoryRoot { + & pwsh -NoProfile -NonInteractive -File 'scripts/verify-package-aot.ps1' ` + -Framework $framework -Configuration $Configuration + } + + if ($LASTEXITCODE -ne 0) + { + Write-Output "FAILED - the Native AOT gate failed on $framework." + + $script:stepSucceeded = $false + + return + } + } + + return +} + +# --- Package consumer --------------------------------------------------------------------------------- +# Installs every package the way a stranger would, and asserts that each adapter's driver dependency flowed +# transitively and that one shared DbConnectionPlus assembly satisfies all of them - none of which a +# project-referenced test can see. NUGET_PACKAGES and RestoreConfigFile are overridden so the run cannot +# quietly resolve a DbConnectionPlus assembly that did not come out of the packages just packed. + +Invoke-Step 'Package consumer' { + $packageVersion = Get-DeclaredVersion + $originalNuGetPackages = $env:NUGET_PACKAGES + $consumerNuGetConfig = Join-Path $consumerDirectory 'nuget.config' + + Push-Location -LiteralPath $consumerDirectory + try + { + $env:NUGET_PACKAGES = Join-Path $consumerDirectory '.packages' + + & dotnet run --project 'AllAdaptersConsumer/AllAdaptersConsumer.csproj' -c $Configuration ` + -p:RestoreConfigFile=$consumerNuGetConfig -p:DbConnectionPlusVersion=$packageVersion + + $script:stepSucceeded = ($LASTEXITCODE -eq 0) + } + finally + { + $env:NUGET_PACKAGES = $originalNuGetPackages + Pop-Location + } +} + +# --- Summary ------------------------------------------------------------------------------------------ + +Write-Section 'Pre-release gate summary' + +if ($failure) +{ + Write-Output "FAILED: Check $failure failed. See output." + + exit 1 +} + +Write-Output 'PASSED: All checks passed.' + +exit 0 diff --git a/scripts/preflight.ps1 b/scripts/preflight.ps1 deleted file mode 100644 index 3bef234..0000000 --- a/scripts/preflight.ps1 +++ /dev/null @@ -1,165 +0,0 @@ -<# -.SYNOPSIS - The pre-commit gate: repo-hygiene checks, style/formatting/ordering, a Release build, and the unit - test suite. - -.DESCRIPTION - CONTRIBUTING.md requires that all tests pass and the build succeeds with no warnings. Because - TreatWarningsAsErrors=true, the build is also the style, trim-analyzer and public-API gate: IL2xxx / - IL3050 diagnostics fail it, and so does an undeclared or vanished public member (RS0016 / RS0017). - - AI agents have hooks that nag about the public API files as you edit, but a hook only sees edits made - 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: - - - 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. - -.PARAMETER SkipTidy - Skip applying style, formatting and member ordering. The build still fails on any of them. - -.PARAMETER SkipBuild - Skip the Release build (implies -SkipTests). - -.PARAMETER SkipTests - Skip the unit test run. - -.PARAMETER Configuration - Build configuration. Release by default, because that is what CI and CONTRIBUTING.md use. - -.EXAMPLE - pwsh -File scripts/preflight.ps1 -#> -[CmdletBinding()] -param( - [Switch] $SkipBuild, - [Switch] $SkipTests, - [Switch] $SkipTidy, - [String] $Configuration = 'Release' -) - -$ErrorActionPreference = 'Stop' - -$repositoryRoot = Split-Path -Parent $PSScriptRoot -$solution = Join-Path $repositoryRoot 'DbConnectionPlus.slnx' -$unitTests = Join-Path $repositoryRoot '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) - - Write-Output '' - Write-Output "=== $Title ===" -} - -# --- 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. - -Write-Section 'Hygiene: public API' - -$guardOutput = & $publicApiGuard - -if ($guardOutput) { - $guardOutput | ForEach-Object { Write-Output $_ } -} -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. -# -# It rewrites files. That is the point, and it is why this runs before the build. - -if (-not $SkipTidy) { - Write-Section 'Style, formatting and ordering' - - # 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) { - $failures.Add('tidy') - Write-Output 'FAIL - tidy-code could not finish. Run `dotnet tool restore` if the tools are missing.' - } - else { - $dirtyAfter = @(& git -C $repositoryRoot diff --name-only -- '*.cs' 2>$null | Where-Object { $_ }) - $tidied = @($dirtyAfter | Where-Object { $_ -notin $dirtyBefore }) - - Write-Output '' - 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) { - # 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 { - Write-Output 'Already tidy.' - } - } -} -else { - Write-Section 'Style, formatting and ordering' - Write-Output 'Skipped (-SkipTidy).' -} - -# --- 3. Build ---------------------------------------------------------------------------------------- - -if (-not $SkipBuild) { - Write-Section "Build ($Configuration)" - - & dotnet build $solution -c $Configuration - if ($LASTEXITCODE -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 ----------------------------------------------------------------------------------- - -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 { - Write-Section 'Unit tests' - Write-Output 'Skipped.' -} - -# --- Summary ----------------------------------------------------------------------------------------- - -Write-Section 'Preflight summary' - -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).' -exit 0 diff --git a/scripts/public-api-guard.ps1 b/scripts/public-api-guard.ps1 index 563c5ac..ac8c29f 100644 --- a/scripts/public-api-guard.ps1 +++ b/scripts/public-api-guard.ps1 @@ -3,18 +3,19 @@ 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. 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 - scripts/preflight.ps1 runs it over the whole working tree. It only ever reports; it never fails + scripts/pre-commit-gate.ps1 runs it over the whole working tree. It only ever reports; it never fails anything. .PARAMETER Path @@ -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..5635f9b 100644 --- a/scripts/tidy-code.ps1 +++ b/scripts/tidy-code.ps1 @@ -17,28 +17,31 @@ 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: format ~1s CSharpier only. The default, and what the editor hooks use. style ~15s + the Roslyn code-style fixers. all ~3min + member reordering. Whole solution only - ReSharper loads all of it either way. - This is the one to run before committing; scripts/preflight.ps1 does it for you. + This is the one to run before committing; scripts/pre-commit-gate.ps1 does it for you. .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 + ) -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 + $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.' + + 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,239 @@ 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. +# +# What that diff CANNOT see is line endings. The copy carries .gitattributes with it, so committing the +# baseline stores every file as LF whatever is on disk, and CSharpier rewriting a CRLF file to LF is then +# a change git reports as nothing at all. A tree with the wrong line endings passes this check and fails +# the build, where CSharpier.MsBuild reads the files rather than the index and rejects every one of them. +# scripts/pre-release-gate.ps1 checks for that separately, before it gets here. + +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..5dd3c2f 100644 --- a/scripts/update-public-api.ps1 +++ b/scripts/update-public-api.ps1 @@ -3,7 +3,7 @@ Records the current public surface of the shipping projects in their PublicAPI.Unshipped.txt files. .DESCRIPTION - The six shipping projects are guarded by Microsoft.CodeAnalysis.PublicApiAnalyzers: a public member that + The shipping projects are guarded by Microsoft.CodeAnalysis.PublicApiAnalyzers: a public member that is not listed in the project's PublicAPI.Shipped.txt or PublicAPI.Unshipped.txt is RS0016, and a listed member that no longer exists is RS0017. Both are build errors here, because TreatWarningsAsErrors is on - so an unintended change to the public surface breaks the build rather than slipping through review. @@ -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/. + One or more project files to update. Defaults to the 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 diff --git a/scripts/verify-line-endings.ps1 b/scripts/verify-line-endings.ps1 new file mode 100644 index 0000000..94b0b63 --- /dev/null +++ b/scripts/verify-line-endings.ps1 @@ -0,0 +1,118 @@ +<# +.SYNOPSIS + Verifies that every file's line endings match what .gitattributes declares - both as git stored them + and as they stand on disk. + +.DESCRIPTION + The check CI runs as "Verify line endings are normalized", plus the half CI cannot run. + + It matters here more than anywhere, because a tree with the wrong endings does not fail with a message + about line endings. .editorconfig sets end_of_line = lf and CSharpier writes what it asks for, so a + CRLF file is unformatted by definition and CSharpier.MsBuild fails the build with one + "Was not formatted." error per file - naming the formatter, which is not the problem, in every one of + them. + + Nothing else in the repository sees it first. Git normalizes on read, so `git status` reports a clean + tree and the tidiness check reports a tidy one: scripts/tidy-code.ps1 -Check -Scope all commits the + disposable copy before it runs the tools, which stores every file as LF whatever was on disk, and + CSharpier rewriting CRLF to LF is then a change `git diff` reports as nothing at all. + + `git ls-files --eol` is the one question that is not laundered, and it writes nothing: it reports, per + file, what git STORED (i/), what is ON DISK (w/), and the attributes that decide both. Both columns are + checked, and only one of them is CI's: + + i/ is CI's question, which it asks by renormalizing. It fails when a file was committed past + .gitattributes - by a rule added after the file, or by a commit created server-side on GitHub, + which bypasses the filter entirely. + w/ only a working copy can answer, and it is what the compiler and the formatter actually read. A CI + checkout is written from the index seconds earlier, so its working tree cannot disagree with it; + a clone made with a filter that overrode the attribute, an unzipped archive, an editor that + rewrote a file, or a copy through a tool that "helpfully" converts, all can. + + A drifted working tree is not repaired by checking it out again, which is the first thing anyone + reaches for: git skips every file whose stat information matches the index BEFORE it considers --force, + and a tree written wrong by whatever produced it matches perfectly. `git checkout-index --force --all` + exits 0 without writing a byte. The files have to be deleted first, and the failure message says so. + + The expected ending is read from each file's own eol attribute rather than assumed, so this keeps + checking the right thing if .gitattributes ever declares something else. A file that declares no eol + attribute is not this script's business. + +.EXAMPLE + pwsh -File scripts/verify-line-endings.ps1 +#> +#requires -Version 7.0 +[CmdletBinding()] +param() + +$ErrorActionPreference = 'Stop' + +# scripts/ - the repository root is one level up, whatever the current directory is. +$repositoryRoot = (Resolve-Path -LiteralPath (Split-Path -Parent $PSScriptRoot)).Path + +$violations = New-Object System.Collections.Generic.List[String] + +foreach ($entry in @(& git -C $repositoryRoot ls-files --eol)) +{ + # i/ w/ attr/. The path is tab-separated because it can + # contain spaces; the three columns before it are space-padded to a fixed width. + $pattern = '^i/(?\S+)\s+w/(?\S+)\s+attr/(?.*?)\s*\t(?.+)$' + $parsed = [Regex]::Match($entry, $pattern) + + if (-not $parsed.Success) + { + continue + } + + $declared = [Regex]::Match($parsed.Groups['attributes'].Value, '(?:^|\s)eol=(?lf|crlf)(?:\s|$)') + + if (-not $declared.Success) + { + continue + } + + $expected = $declared.Groups['eol'].Value + $stored = $parsed.Groups['index'].Value + $onDisk = $parsed.Groups['worktree'].Value + + # 'none' is an empty file or one with no line breaks and '-text' is binary. Neither has a line ending + # to be wrong about. + $wrong = @($stored, $onDisk) | Where-Object { $_ -notin @('none', '-text', $expected) } + + if ($wrong) + { + $violations.Add(" $($parsed.Groups['path'].Value) - stored $stored, on disk $onDisk, expected $expected") + } +} + +if (-not $violations.Count) +{ + Write-Output 'Every file matches the ending .gitattributes declares.' + + exit 0 +} + +Write-Output "FAILED - $($violations.Count) file(s) do not match the ending .gitattributes declares:" +$violations | Select-Object -First 10 | ForEach-Object { Write-Output $_ } + +if ($violations.Count -gt 10) +{ + Write-Output " ... and $($violations.Count - 10) more" +} + +Write-Output '' +Write-Output 'Wrong on disk (i/lf w/crlf): the working tree drifted from the index. Re-checking-out over it' +Write-Output 'does NOT fix it - git skips every file whose stat matches the index, which is exactly these' +Write-Output 'files, so `git checkout-index --force --all` and `git checkout -- .` both exit 0 having done' +Write-Output 'nothing. Deleting them first is what makes git write them again:' +Write-Output '' +Write-Output ' git ls-files | ForEach-Object { Remove-Item -LiteralPath $_ -Force }' +Write-Output ' git checkout -- .' +Write-Output '' +Write-Output 'That deletes tracked files before restoring them from the index, so commit or stash anything' +Write-Output 'uncommitted first. Re-run this check afterwards: if the endings come back, they are being' +Write-Output 'written by whatever produced this working tree rather than by git.' +Write-Output '' +Write-Output 'Wrong in the repository (i/crlf): git add --renormalize . and commit the result.' + +exit 1 diff --git a/scripts/verify-package-aot.ps1 b/scripts/verify-package-aot.ps1 index 2fa5f26..0479388 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,14 +30,14 @@ 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. .PARAMETER Pack - Pack the six shipping projects into artifacts/packages first, and clear the consumer's isolated package + Pack the shipping projects into artifacts/packages first, and clear the consumer's isolated package cache so the fresh build of an unchanged version number is actually picked up. CI does not use this - it downloads the exact packages the publish job produced. @@ -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 + # The single source of truth for the version of every package. 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 "Every package is 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 diff --git a/src/DbConnectionPlus.DatabaseAdapters.MySql/DbConnectionPlus.DatabaseAdapters.MySql.csproj b/src/DbConnectionPlus.DatabaseAdapters.MySql/DbConnectionPlus.DatabaseAdapters.MySql.csproj index 8b818ba..346f068 100644 --- a/src/DbConnectionPlus.DatabaseAdapters.MySql/DbConnectionPlus.DatabaseAdapters.MySql.csproj +++ b/src/DbConnectionPlus.DatabaseAdapters.MySql/DbConnectionPlus.DatabaseAdapters.MySql.csproj @@ -1,19 +1,21 @@ - - 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. + + true + 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..9852f5f 100644 --- a/src/DbConnectionPlus.DatabaseAdapters.Oracle/DbConnectionPlus.DatabaseAdapters.Oracle.csproj +++ b/src/DbConnectionPlus.DatabaseAdapters.Oracle/DbConnectionPlus.DatabaseAdapters.Oracle.csproj @@ -1,19 +1,21 @@ - - 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. + + true + 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..3b31df5 100644 --- a/src/DbConnectionPlus.DatabaseAdapters.PostgreSql/DbConnectionPlus.DatabaseAdapters.PostgreSql.csproj +++ b/src/DbConnectionPlus.DatabaseAdapters.PostgreSql/DbConnectionPlus.DatabaseAdapters.PostgreSql.csproj @@ -1,19 +1,21 @@ - - 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. + + true + 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..c768343 100644 --- a/src/DbConnectionPlus.DatabaseAdapters.SqlServer/DbConnectionPlus.DatabaseAdapters.SqlServer.csproj +++ b/src/DbConnectionPlus.DatabaseAdapters.SqlServer/DbConnectionPlus.DatabaseAdapters.SqlServer.csproj @@ -1,19 +1,21 @@ - - 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. + + true + 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..cf92ed8 100644 --- a/src/DbConnectionPlus.DatabaseAdapters.Sqlite/DbConnectionPlus.DatabaseAdapters.Sqlite.csproj +++ b/src/DbConnectionPlus.DatabaseAdapters.Sqlite/DbConnectionPlus.DatabaseAdapters.Sqlite.csproj @@ -1,19 +1,21 @@ - - 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. + + true + DbConnectionPlus.DatabaseAdapters.Sqlite + RentADeveloper.DbConnectionPlus.DatabaseAdapters.Sqlite + - - - + + + - - - + + + diff --git a/src/DbConnectionPlus/DbConnectionPlus.csproj b/src/DbConnectionPlus/DbConnectionPlus.csproj index 11868b3..7072bae 100644 --- a/src/DbConnectionPlus/DbConnectionPlus.csproj +++ b/src/DbConnectionPlus/DbConnectionPlus.csproj @@ -1,27 +1,29 @@ - - 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. + + true + 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..7f2e91f 100644 --- a/src/Directory.Build.props +++ b/src/Directory.Build.props @@ -1,109 +1,124 @@ - - - - - - - 4.0.0 - - - - - net8.0;net10.0 - - - - - true - - - - - latest-all - 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 - - - - - - - - - - - - - - all - runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - - - + + + + + + + net8.0;net10.0 + + + + + true + + + + + latest-all + True + + + + + false + true + true + snupkg + true + + + + + logo-128.png + MIT + https://github.com/rent-a-developer/DbConnectionPlus + PACKAGE_README.md + 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/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 } } } diff --git a/tests/DbConnectionPlus.IntegrationTests/DbConnectionPlus.IntegrationTests.csproj b/tests/DbConnectionPlus.IntegrationTests/DbConnectionPlus.IntegrationTests.csproj index 0f92514..08a385c 100644 --- a/tests/DbConnectionPlus.IntegrationTests/DbConnectionPlus.IntegrationTests.csproj +++ b/tests/DbConnectionPlus.IntegrationTests/DbConnectionPlus.IntegrationTests.csproj @@ -1,55 +1,41 @@ - - Exe - net8.0 - RentADeveloper.DbConnectionPlus.IntegrationTests - rent-a-developer DbConnectionPlus.IntegrationTests - RentADeveloper.DbConnectionPlus.IntegrationTests - + + net8.0 + RentADeveloper.DbConnectionPlus.IntegrationTests + rent-a-developer DbConnectionPlus.IntegrationTests + RentADeveloper.DbConnectionPlus.IntegrationTests + - - - + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - 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..e717128 100644 --- a/tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj +++ b/tests/DbConnectionPlus.UnitTests/DbConnectionPlus.UnitTests.csproj @@ -1,60 +1,46 @@ - - 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 the database containers, + not by the runtime, and doubling a large test suite buys nothing the materializer tests here do not + already cover on net8.0 and net10.0. + --> + net8.0;net10.0 + RentADeveloper.DbConnectionPlus.UnitTests + rent-a-developer DbConnectionPlus.UnitTests + RentADeveloper.DbConnectionPlus.UnitTests + - - - + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - 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/AllAdaptersConsumer/AllAdaptersConsumer.csproj b/tests/package-consumption/AllAdaptersConsumer/AllAdaptersConsumer.csproj index 8824d12..c08af20 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/AotConsumer/README.md b/tests/package-consumption/AotConsumer/README.md index 0eb1fdd..c103188 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. @@ -32,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: @@ -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 @@ -109,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. 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 @@ - + 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 + + + diff --git a/tests/package-consumption/README.md b/tests/package-consumption/README.md index 39e58f4..5085d30 100644 --- a/tests/package-consumption/README.md +++ b/tests/package-consumption/README.md @@ -8,13 +8,13 @@ makes them able to see defects that a solution build cannot: `[assembly: AssemblyMetadata("IsTrimmable", "True")]` marker survive packing, - each adapter package really declares its driver dependency (`MySqlConnector`, `Npgsql`, `Oracle.ManagedDataAccess.Core`, `Microsoft.Data.SqlClient`, `Microsoft.Data.Sqlite`), -- all five adapters resolve **one** `DbConnectionPlus` assembly, not five copies, +- every adapter resolves **one** `DbConnectionPlus` assembly, not a copy each, - the `net8.0` asset of the multi-targeted packages is the one a `net8.0` consumer gets, and it runs. | Consumer | Packages | What it is for | |---|---|---| | `AotConsumer` | core + SQLite | The Native AOT gate. Multi-targets `net8.0;net10.0`, published natively for both. See [its README](AotConsumer/README.md) — it is the only check in the repository that can see silent trimming damage. | -| `AllAdaptersConsumer` | all six | Breadth. Registers all five adapters, asserts the driver packages flowed transitively and that one core assembly is shared. Built by CI with the **.NET 8 SDK alone**, which is what makes the documented `net8.0` floor a checked fact rather than a claim. | +| `AllAdaptersConsumer` | every package | Breadth. Registers every adapter, asserts the driver packages flowed transitively and that one core assembly is shared. Built by CI with the **.NET 8 SDK alone**, which is what makes the documented `net8.0` floor a checked fact rather than a claim. | Both apps exit non-zero on failure and hand-roll their assertions (`Check.cs`, linked into both): xUnit, NSubstitute and AwesomeAssertions all need run-time code generation, which a Native AOT binary does not have.