Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
60f16ff
Start the Dapper/Dapper.AOT parity accounting under notes/
mgravell Aug 18, 2026
f00bdbd
GetTypeDeserializer is valid API, not cache plumbing
mgravell Aug 18, 2026
050d63c
Scope the accounting to the public API and observable behavior
mgravell Aug 18, 2026
570fcdf
Record the decision: internals-asserting tests get adjusted, not main…
mgravell Aug 18, 2026
36d7942
New work item: warn (new DAP id) on use of the has-no-meaning APIs
mgravell Aug 18, 2026
728a3be
Measurement caveat: build-time DAP counts are an upper bound
mgravell Aug 18, 2026
ee006d9
First harness baseline: 'handled 396 of 396' alongside 96 compile errors
mgravell Aug 18, 2026
8c41bf0
Generator audit: the capture model snapshots Roslyn nodes; fix first
mgravell Aug 18, 2026
b77dd83
Record the agreed plan: gap table, then generator model, then features
mgravell Aug 18, 2026
62720a1
Round 2 numbers, and log the modern-interceptor-syntax work item
mgravell Aug 18, 2026
01ed04b
Round 3: the suite compiles with AOT enabled (4 fix PRs + 2 severity …
mgravell Aug 18, 2026
468317b
Scoreboard: all three TFM legs compile; local SQL Server available
mgravell Aug 18, 2026
e2e19d4
Work item: [UnsafeAccessor] may lift the accessibility refusals (net8+)
mgravell Aug 18, 2026
423a07e
Round 4: the honest scorecard says 53%, not 100%
mgravell Aug 18, 2026
17c080e
Harvest the skip breakdown; flag the DAP016 corpus-shape decision
mgravell Aug 18, 2026
a126782
Round 5: first behavioral run - 84 failures, every one compiled clean
mgravell Aug 18, 2026
476fd01
Note that aot-harness is deliberately local-only
mgravell Aug 18, 2026
096fdf7
Phase 2 log: approach and increments
mgravell Aug 18, 2026
d4193e9
Phase 2 log: increment 1 done
mgravell Aug 18, 2026
320c442
Phase 2 log: 3a done
mgravell Aug 18, 2026
44b234a
Phase 2 log: 3b done
mgravell Aug 18, 2026
9ea89c3
Phase 2 log: 3c-i done; two cached symbols remain
mgravell Aug 18, 2026
19acc0b
Phase 2 log: result-side plan done; one symbol left
mgravell Aug 18, 2026
577cabf
Phase 2 log: cached model fully plain; only increment 4 remains
mgravell Aug 18, 2026
cc94b53
Phase 2 log: complete - PRs #187 + #188
mgravell Aug 18, 2026
8febfb9
Phase 2 log: caching tests landed
mgravell Aug 18, 2026
03fb6f2
Phase 2 log: readonly-field quirk fixed (#190)
mgravell Aug 18, 2026
ed9881d
Round 6: DAP051 + restructure takes interception to 68.1%
mgravell Aug 18, 2026
bf9de1b
Round 6b: 612/760 behavioral; failures track interception growth hone…
mgravell Aug 18, 2026
3ac35dc
DynamicParameters design: delegate to the bag; needs one small Dapper…
mgravell Aug 18, 2026
5bffe78
Round 7: DynamicParameters at 73.5%; First-pipeline drain divergence …
mgravell Aug 18, 2026
df1fc4e
Round 7b: 612/762; every failure class maps to a planned feature
mgravell Aug 18, 2026
0d79ccb
Interceptor-syntax migration: soft-target requirement recorded
mgravell Aug 18, 2026
eb632e3
Tokens: runtime-SQL design - per-factory memoized role scan
mgravell Aug 18, 2026
a257808
Record the feature-detection rule (DAP052) in the design note
mgravell Aug 18, 2026
eb7b4a5
Round 8: CommandBehavior parity fix (PR #196)
mgravell Aug 18, 2026
6d4ee76
Round 8b: 616/762, suite loop 17s
mgravell Aug 18, 2026
4ef5a9a
Round 9: list expansion lands (PR #197), 638/762
mgravell Aug 18, 2026
7c8046e
Round 10: custom parameters + the two bugs they uncovered, 658/793
mgravell Aug 18, 2026
a084a82
Round 11: dynamic-record fidelity, 672/793
mgravell Aug 18, 2026
8386910
Merge remote-tracking branch 'origin/main' into parity-notes
mgravell Aug 19, 2026
4001e87
Sync with main; point parity rows at their open PRs
mgravell Aug 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions notes/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
# Dapper / Dapper.AOT unification — working notes

These notes are the working set for closing the gap between vanilla Dapper and Dapper.AOT,
with the eventual goal of retiring ref-emit entirely.

## The success criterion

> We turn Dapper.AOT "on" in the **Dapper test suite** (`Dapper/tests/Dapper.Tests`), announce
> the types in play via (new) attributes, and Dapper.AOT swallows *everything* — every call site
> intercepted, all tests green, with Dapper.AOT doing all the work. Bonus: the project compiles
> in AOT mode without warnings.

That makes the Dapper test suite the **acceptance corpus**: parity is not "the API list looks
covered", it is "these tests pass through generated code". The corpus conveniently already
exercises the obscure corners (list expansion, literals, pseudo-positional parameters, type
handlers, multi-map, GridReader, dynamic rows, output parameters, ...), across many providers.

"Announce the types" is the one concession we ask of consumers: the `Type`-based (non-generic)
APIs and anything else that discovers types at runtime need the candidate types stated at
build time. See [type-vs-generic.md](type-vs-generic.md).

## The documents

| doc | contents |
| --- | --- |
| [plan.md](plan.md) | **the agreed plan**: complete the gap table → fix the generator → close the gaps |
| [parity.md](parity.md) | the feature parity table: Dapper's surface vs Dapper.AOT today |
| [tokens.md](tokens.md) | special string-token handling: `@ids` expansion, `{=literal}`, `?foo?`, etc |
| [type-vs-generic.md](type-vs-generic.md) | `Type`-based vs `<T>` APIs, and the "announce your types" design space |
| [test-suite-audit.md](test-suite-audit.md) | the Dapper test files as acceptance corpus, and what blocks each |
| [harness-baseline.md](harness-baseline.md) | real numbers from the suite with AOT enabled (Dapper repo, `aot-harness` branch) |
| [dynamicparameters-design.md](dynamicparameters-design.md) | phase 3 item 1: delegate to the bag's own vanilla protocol; needs a small Dapper-side API |
| [generator-audit.md](generator-audit.md) | **fix-first gate**: the capture model snapshots Roslyn symbols/nodes — retention + cache defeat |

## Scope: the public API, by observable behavior

Parity is defined over Dapper's **public API** (`Dapper/PublicAPI.Shipped.txt` is the
checklist) and the **observable behavior** of each member: the SQL text and parameter set
that reach the provider, and the values that come back. How Dapper implements any of it —
internal types, regexes, caches — is irrelevant, except as *evidence* of the observable
behavior. Two consequences:

- internal implementation types (e.g. the dynamic-row class) need behavioral fidelity (the
returned object's public contracts), never type fidelity;
- public-but-infrastructure members (`PackListParameters`, `FindOrAddParameter`,
`TypeHandlerCache<T>`, ...) are in scope *because they are public* — Contrib-style
extenders call them — even though they exist to serve Dapper's own generated IL.

## Honesty rules

- A status in these tables is only worth having if it was **verified against code** — the
interceptor's dispatch switch, a test fixture, or the Dapper source. Anything inferred or
remembered is marked ❓ until checked.
- "Compiles" is not "works": a call site left un-intercepted still compiles and passes tests
on a JIT runtime via vanilla Dapper. The corpus only measures us when interception is
*confirmed* (DAP000 counts, or Dapper is removed from the runtime closure).
- Statuses: ✅ supported (verified) · ⚠️ partial/constrained · ❌ not supported today ·
🚫 deliberate non-goal (decision recorded) · ❓ unverified.

## Where the current status came from

- Dapper.AOT's supported-method set: the dispatch switch in
`src/Dapper.AOT.Analyzers/Internal/Inspection.cs` (`IsDapperMethod`, `OperationFlags`).
- Fixture evidence: `test/Dapper.AOT.Test/Interceptors/*.input.cs` — a fixture with an
`.output.cs` generates; one without (e.g. `QueryMultiple`, `QueryMultiType`,
`DynamicParameters`) does not.
- Dapper's surface: `Dapper/Dapper/PublicAPI.Shipped.txt` plus source (`SqlMapper.cs` for the
token machinery).
76 changes: 76 additions & 0 deletions notes/dynamicparameters-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# Phase 3, item 1: DynamicParameters under Dapper.AOT

The largest measured gap: DAP015 ×114 call-sites, plus it underlies most of the
ParameterTests/ProcedureTests/SqlBuilderTests behavioral failures.

## The key insight: delegate to the bag itself

`DynamicParameters` *is* runtime state - no generator can know its members. But it also
already knows how to apply itself to a command: `IDynamicParameters.AddParameters(command,
identity)` is the whole vanilla protocol, including per-parameter DbType/direction/size,
templates, literal replacement, `RemoveUnused` filtering, and storing the attached
`DbParameter`s so `Get<T>` reads outputs afterwards.

So the generated command factory for a `DynamicParameters`-typed argument should not try to
understand the bag - it should **call the bag's own vanilla implementation**. That gives
byte-exact behavioral parity for the entire DynamicParameters surface in one stroke, because
it *is* the vanilla implementation:

- `Add(name, value, dbType, direction, size, precision, scale)` - full fidelity;
- output/return parameters + `Get<T>` - automatic (the bag holds the attached parameters);
- `IParameterCallbacks.OnCompleted` - generated `PostProcess` calls it;
- templates (`new DynamicParameters(template)`) - work on JIT exactly as vanilla (it is
vanilla's code path); under **native AOT** they fail inside Dapper's own ref-emit, which
is the same failure vanilla has there - no regression, honestly documented;
- `AddDynamicParams`, `ReplaceLiterals` - along for the ride.

## The blocker, and the Dapper-side fix **[NEEDS SIGN-OFF]**

`AddParameters(command, identity)` consumes `identity.Sql` on its first line (literal
tokens), so a null identity NREs - and `Identity`'s constructor is internal, so generated
code cannot build one. There is also no public way to *reimplement* the protocol from
outside: the per-parameter metadata lives in private `ParamInfo`.

Proposed: a small public overload on `DynamicParameters` (or a `SqlMapper` static) in
**Dapper** itself:

```csharp
public void AddParameters(IDbCommand command)
=> AddParameters(command, /* identity built from command.CommandText */);
```

i.e. self-apply against the command's own SQL - which is what `identity.Sql` is in practice.
Useful beyond AOT (extenders have wanted a way to invoke bags directly). No release needed
yet: the harness consumes Dapper by project reference, so this can sit on a Dapper branch
until the next ship.

**Consumer-version safety (the rule, per Marc)**: generated code calling a new Dapper API
would break consumers on older Dapper, so for *every* Dapper-side dependency the generator
must (a) probe for the symbol, (b) never emit code that cannot compile against the
referenced Dapper, and (c) refuse with a diagnostic that names exactly which API is missing
and what it enables - **DAP052** ("Feature requires a newer Dapper"), not a generic message,
and never a baffling compiler error. Implemented; the DAP052 verifier runs against the
packaged (older) Dapper, which is exactly the scenario it exists for.

## Generator/runtime shape

- Recognition: parameter type is `DynamicParameters`, or implements
`SqlMapper.IDynamicParameters` (the interface case needs the same overload story - start
with the concrete type, where the new overload is definitely present).
- Emit: `CommandFactory<global::Dapper.DynamicParameters>` whose `AddParameters` calls
`args.AddParameters(cmd.UnderlyingCommand)`; `RequirePostProcess => true` with
`PostProcess` invoking `((SqlMapper.IParameterCallbacks)args).OnCompleted()` when the bag
implements it (runtime test - implementers are runtime state);
- **No command caching / no prepare** for these factories: re-applying a bag to a reused
command would double-add parameters, and prepare needs statically-known types. Refuse
`[CacheCommand]` combination (diagnostic) rather than misbehave;
- `UpdateParameters` for batch reuse: not applicable (a bag is a single-command concept;
multi-exec over `IEnumerable<DynamicParameters>` stays unsupported for now);
- DAP015 splits: `DynamicParameters`-typed args become supported; `object`/`dynamic`-typed
stay DAP015 (that is announced-types territory - the runtime type is unknowable).

## Scope check against the corpus

Statically-`DynamicParameters` sites cover the bulk of the 114; the remainder are
`object`-typed pass-throughs and helper indirection, which stay refused with an honest
diagnostic. Measure the split after implementation rather than guessing it now.
72 changes: 72 additions & 0 deletions notes/generator-audit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# Generator audit: the capture model snapshots Roslyn nodes

**Work item, and a sequencing gate: fix this before building new generator features**, so the
multi-map / DynamicParameters / QueryMultiple work is written against the fixed model rather
than needing a second pass over brand-new code.

## The rule being violated

An incremental generator's cached pipeline values must be **plain, equatable data**. Holding
`ISymbol` / `SyntaxNode` / `IOperation` / `Location` / `Compilation` in them causes two silent
failures at once:

1. **retention** — a symbol pins its entire `Compilation` (and transitively the syntax trees)
alive for as long as the driver caches the value: in a long-running IDE session, that is
whole Roslyn trees held non-collectible;
2. **cache defeat** — symbol/location equality does not hold across compilations, so the
incremental cache never hits anyway; you pay the leak *and* get none of the benefit.

## Findings (2026-08-18, `main` @ `e3b1037`)

Verified by reading the code, not assumed:

- **`DapperInterceptorGenerator.SuccessSourceState`** (`DapperInterceptorGenerator.cs:1505`)
holds `IMethodSymbol Method`, `ITypeSymbol? ResultType`, `ITypeSymbol? ParameterType`, and
`Location` — this is the cached per-call-site value (`CreateSyntaxProvider(PreFilter, Parse)`).
`CommonComparer` groups with `SymbolEqualityComparer`, which also does not hold across
compilations.
- **`TypeAccessorInterceptorGenerator.SourceState`** (`TypeAccessorInterceptorGenerator.cs:184`)
same shape: `Location` + `ITypeSymbol` + `IMethodSymbol`.
- **`MemberMap`** (`Internal/MemberMap.cs`) holds `ITypeSymbol`s, `IMethodSymbol`s, a
`Location`, and an **`IOperation`** — and is reachable from `AdditionalCommandState`, which
rides inside `SuccessSourceState`. Audit everything transitively reachable from the cached
states; a single symbol anywhere in the graph is enough to pin the compilation.
- **The pipeline combines the raw compilation**: `DapperInterceptorGenerator.cs:58-61` —
`context.CompilationProvider.Combine(nodes.Collect())` feeding `RegisterSourceOutput`. Even
with a clean node model, this re-runs the generate step on *every* compilation change
(every keystroke); the compilation must not be an input to the output step. (This is the
documented Roslyn anti-pattern; RS1041-family guidance.)
- `FaultSourceState` holds `Exception` + `Location` — same treatment (message/type name +
span data).

Net: the generator currently behaves as a **full-recompute generator with a memory leak**,
not an incremental one. Correctness is unaffected — which is why nothing ever flagged it.

## The fix shape (proven in protobuf-net's AOT generator)

This exact trap sank earlier attempts at protobuf-net's generator; the working pattern there:

- model types are hand-written **equatable plain-data values** — strings, enums, packed
flags, hand-rolled `EquatableArray<T>` (note `ImmutableArray<T>` equality is
reference-based and silently defeats caching too — don't swap one trap for another);
- locations are stored as Roslyn **value** types (`TextSpan` + `LinePositionSpan` + path —
plain data), reconstituted into a `Location` only at report/emit time (`PlanLocation`);
- symbols are fully **projected during parse**: everything emit needs (qualified type names,
member lists, flags) is extracted into the model while the `SemanticModel` is in hand;
- diagnostics ride a **separate** pipeline branch from the emit model, because they carry
locations that shift with every edit and the emit model should not;
- a **shape-enforcing test** walks the model types by reflection and fails on any field of a
Roslyn reference type (`ProtoModelPlanShapeTests` pattern) — the constraint has to have
teeth or it erodes; it eroding silently is precisely how it got here.

The interceptor output itself needs `InterceptsLocation` data — that is file/position values,
not `Location` objects, so it survives projection fine.

## Why fix-first is the right order

Every gap-closing feature in [parity.md](parity.md) §1–§3 adds parse-time state (multi-map
adds per-type splits, DynamicParameters adds member graphs, announced types add a type
inventory). Built on the current model, each addition deepens the symbol snapshot and has to
be re-done when the model is fixed. Built after the fix, each lands on plain data from day
one. The harness ([harness-baseline.md](harness-baseline.md)) is unaffected — it measures
behavior, not model shape — so the two tracks can run in parallel.
Loading
Loading