Skip to content

RowMap<T>: declare a column mapping once, reuse it on every query (11.0.0-preview02) - #37

Open
electricessence wants to merge 31 commits into
masterfrom
feature/declared-mapping
Open

electricessence wants to merge 31 commits into
masterfrom
feature/declared-mapping

Conversation

@electricessence

@electricessence electricessence commented Nov 6, 2026 •

Copy link
Copy Markdown
Collaborator

Ready to merge for 11.0.0-preview02. 948 of 948 tests in Release and Debug; 0 warnings and 0 errors on netstandard2.0, netstandard2.1 and net10.0; samples build and pass; merges cleanly with master.

What this adds: RowMap<T>

A validated, read-only mapping of a model's properties to differently named columns, built once from an ImmutableArray<KeyValuePair<string, string?>> and reused on every query. The type is small on purpose: one public constructor. The pairs are validated there (ArgumentException) and reduced to one column table; the compiled row readers are built lazily, per result-set layout, on the first query and cached inside the map.

static RowMap<Post> PostMap => field ??= new([new(nameof(Post.Id), "post_id"), new(nameof(Post.Body), null)]);

var posts = connection.Command("select * from posts").Map<Post>(PostMap).ToList();
  • null as a column means "do not map this property"; properties the map does not mention still map by name (columns match ignoring case, property names exactly).
  • One strict overload per method: RowMap<T>? map (before the CancellationToken) in Core, Channel and Dataflow. Removed: every IEnumerable<KeyValuePair>/pair overload, the inline params ImmutableArray overload, the content-keyed cache and string-key builder, public For, the lenient path. The value-tuple overloads remain as thin convenience wrappers that build a strict map per call (about 29 KB and a reader compile per call, against 464 B for a declared map), and say so in their docs.
  • Reduced after review: no collection-expression builder, no implicit conversion, no enumeration, no RowMap builder class, no span round trip. Clean syntactic conveniences can be added later; the function stays the same.
  • Instance-only property mapping (a static property used to pass validation and then fail on the first read). Version bumped to 11.0.0-preview02.

Review

A six-reviewer read-only swarm of the earlier head found two real defects, both fixed with tests (IExecuteReader.Results<T>() with no map threw; a bare null map was ambiguous for ResultsAsync<T>(command, null, ct)). A second six-reviewer swarm questioned the type itself; its unanimous cut list is applied here. Two first-use race tests no longer compare the racing instances' enumerations (the enumerator is gone); they still assert the rows each racer read.

Breaking changes (preview02)

IEnumerable<KeyValuePair> and params pair arguments to the mapping parameter (pass a RowMap<T>); the named argument fieldMappingOverrides: (the parameter is map); passing a RowMap<A> to a query for model B; tuple overloads now validate strictly and no longer share a cache. Full list in docsite/articles/whats-new-11.md.

Known, deliberately deferred

  • No timing run after the reshaping; allocation figures were re-measured, timings in Benchmarks/RESULTS.md predate the final API.
  • Open question for the maintainer: whether the compiled readers should hang off the array (identity-keyed) instead of a RowMap<T>; the reviewers' trade-offs are in the PR discussion.
  • The cancellation-token change (a per-call token on every async terminal, UseCancellationToken kept as the default) is the next preview.

🤖 Generated with Claude Code

electricessence and others added 13 commits November 5, 2026 15:29
… by every override parameter

A RowMap<T> is built once (validated where it is built: unknown or unsettable property,
a property named twice, two properties on one column throw ArgumentException) and holds its
own RowPlan<T>. It implements IEnumerable<KeyValuePair<string,string?>> and
IEnumerable<(string Field, string? Column)> explicitly, so Map, Results, ToChannel, Dataflow
and every other override parameter accept it with no new overload; RowPlan<T>.For returns the
map's own plan (a type check), so passing it costs no allocation, key or lookup.
The unprioritised tuple Results<T>(IExecuteReader, ...) overload gets priority -1 so a RowMap
(which is both shapes) binds to the KeyValuePair overload.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A mapping that is never read no longer holds two empty ConcurrentDictionary instances.
Measured (managed heap, 12-field RowMap, 1000 mappings): building one allocates 6000 B -> 3392 B;
retained while unused 4144 B -> 1536 B; retained after its first read 9209 B -> 7905 B.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… Dapper SetTypeMap), lazy-holding micro-benchmark, mapping memory study

In-memory and LocalDB classes read a 12-property model whose every column is named differently;
the control is Map<T>() by name over the same result set. Adds NonQuery8 to the LocalDB class,
LazyMapBenchmarks (static readonly / field ??= / EnsureInitialized / Lazy<T>), and --map-memory.
RESULTS.md records allocation numbers only (timing pending a quiet-machine run).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…idance in the XML docs

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…y was accepted by a RowMap and failed on the first read)

typeof(T).GetProperties() includes static properties, so a RowMap naming one passed validation and the
compiled reader then failed on an instance call to a static setter. The property table is now built with
BindingFlags.Public | BindingFlags.Instance, so a static property is rejected like an unknown property and
the default mapping ignores it. Pins an init-only setter as accepted.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A map that does not touch two properties differing only by case no longer throws for a model Map<T>()
accepts (the collision check now needs one of the two properties to be mentioned by the map).
An empty column name is a mistake, so it throws ArgumentException; null still ignores the property.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…mes (0, 1 and 100 rows)

Identical result shapes read through an allocation-free typed reader; per-call overrides are the control
that shows the measurement can tell a difference. Measured warm with GC.GetAllocatedBytesForCurrentThread.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…cache overflow under concurrent reads

The first call is made right after a barrier, every racer's instance is checked (equivalent, and the same
instance for EnsureInitialized) instead of a tautological Assert.Same after the race; 240 distinct layouts are
pushed through one map while other threads keep reading, so the 128-layout cache fills and clears under them.
A comment states that x86 cannot expose a missing release barrier, so these tests prove correct rows only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ite the memory-model sections; document error timing

README and XML docs claim no per-call allocation, key or dictionary lookup (the per-query layout match still
happens) and allocation equal to Map<T>(), not speed parity. The nanosecond figures and the static readonly
"no faster" claim are removed pending a quiet-machine run. Memory-model citations add "Data-dependent reads are
ordered" and "Examples and common patterns". A bad mapping is an ArgumentException at first use with field ??=
and a TypeInitializationException wrapping it in a static readonly field. RESULTS.md: the Lazy<T> cost is two
objects (the Lazy and its helper, delegate not counted) and the retained-memory rows say what --map-memory prints.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…aims with the measured ranges

RESULTS.md: time and allocation per class (in-memory, LocalDB, lazy-map) with settings (5 warmup, 12 iterations,
1 launch, in-process), CPU samples before and after each class, and a verdict on criterion 2 including the one
row (in-memory List of 1000, +2.2%) that is slightly outside its error bars. README and XML docs state only
what was measured: allocation equal to Map<T>() on matching names, 0.95-1.02x its time, 0.12-0.35x Dapper's
time in memory and 0.92-1.01x on LocalDB (a tie), and 1-2 ns per read of a held mapping.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The README is rewritten in the documentation pull request; the RowMap text it needs is kept in
readme-rowmap.patch (untracked) and will be added to the new README after that PR merges.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…Map<T>); inline Map<T> overload

RowMap<T> wrapped a RowPlan<T> and kept a second copy of the pairs. RowPlan<T> is now the plan: it is created only
by an implicit conversion from ImmutableArray<KeyValuePair<string, string?>> and by a collection expression
([CollectionBuilder] on a non-generic RowPlan.Create<T>); there is no public constructor. It validates once, in one
pass, retains the caller's ImmutableArray (shared, not copied) plus the tables it needs (two small string arrays and
one dictionary instead of two dictionaries, a HashSet and a transient dictionary), and implements only
IEnumerable<KeyValuePair<string, string?>>, so every existing override parameter accepts it.
RowSource<T> is an internal interface so the public type can implement it; the per-row loops are unchanged.

Map<T> gains params ImmutableArray<KeyValuePair<string, string?>> (priority between Map<T>() and the existing
IEnumerable overload) for one-offs: it builds a new plan for the call and caches nothing. The existing IEnumerable and
tuple overloads and their OverridePlans cache are unchanged. ToChannel, AsSourceBlock and ToTargetBlock (24 overloads)
gain key/value-pair overloads that carry the implementation; their tuple overloads become lower-priority
convenience wrappers that convert once. Only public instance properties are mapped; validation covers what the plan
mentions; an empty column name is rejected. XML docs say which overload to use and point to RowPlan<T>.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… retained size before/after

Property names use nameof; the plan is built by the implicit conversion from the pairs. RESULTS.md records the
construction cost (3,392 B -> 1,088 B from a prepared array; 3,632 B -> 1,304 B from a literal), the retained size
(1,536 B -> 936 B unused; 7,905 B -> 7,305 B after the first read) and the allocation of the one-off inline path
(about 30 KB per one-row read). Timing tables were measured before the plan collapse and are marked to be re-run.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@electricessence electricessence changed the title RowMap<T>: declare a column mapping once, reuse it on every query RowPlan<T>: declare a column mapping once, reuse it on every query Nov 6, 2026
electricessence and others added 14 commits November 5, 2026 17:13
…ures; validation tidied; docs and tests

The constructor takes the ImmutableArray of pairs and builds the plan (the implicit conversion and RowPlan.Create call it).
The plan keeps the caller's array plus one string?[] aligned with the model's properties; the two name arrays and the
OrdinalIgnoreCase dictionary are gone (PropertyNames/ColumnNames enumerate the table; a layout compile finds a column's
property by scanning it, once per column per layout, never per row). Construction allocates 1,088 B -> 224 B from a
prepared array, retained size 936 B -> 192 B while unused (--map-memory, 12 fields); the first read still adds 6,369 B.
Validation is split into Declare / Override / RejectSharedColumns with the same rules and messages; ArgumentException
parameter names use nameof of the real parameter. Docs: null property name, exact-versus-ignore-case matching, the exact
rule for another model type, the exact shared-column rule, and the legacy ArgumentNullException. Tests pin the constructor
form and the lenient per-call rules (last column wins, first property reads a shared column).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The four conflicted files (ToChannel.cs, ToChannel.Target.cs, AsSourceBlock.cs, ToTargetBlock.cs) take master's documentation
for every existing member; the 24 key/value-pair overloads were regenerated on top with the same documentation plus a
paragraph saying why they take pairs, and the tuple overloads became lower-priority convenience wrappers.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… why the pair overloads take IEnumerable<KeyValuePair>

With all analyzers on, the branch introduced CA2225 (the implicit conversion; suppressed with a justification: the public
constructor and RowPlan.Create are the alternates) and two CA1062 on Results.cs (not resolved: they appear only with
AnalysisMode=All, and the cause was not identified). The pair overloads in Map, Transformer.Create, ToChannel,
ToTargetBlock and AsSourceBlock say in their remarks that a RowPlan flows through the IEnumerable<KeyValuePair> parameter
and that no public method takes a plan; the tuple overloads carry the convenience remark.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…rding

README: the renamed-columns section (collection syntax with nameof and field ??=, what a null column means, the inline
one-off and its cost) and the current version. docsite: a "Reusing a mapping" section in mapping.md, a "Preview 02" entry in
whats-new-11.md, and compiled and run samples (RowPlan declaration, use, ignore, inline, validation at the declaration).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…numbers updated

RESULTS.md: time and allocation per class on the final shape (CPU sampled before and after each class, none above 12%), the
verdict from same-run ratios, the interface lookup of the row reader measured against the earlier run (no measurable cost),
and the one-off inline path (about 0.43 ms and 29 KB for a one-row read). The documentation states those measured figures.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…embly, no BOM on new files beside BOM-less neighbours

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… overrides can never share a plan

("Name", "a|Note=b") and ("Name", "a"), ("Note", "b") both produced the key "Name=a|Note=b|", so the second call received the
first call's plan. Behavior is otherwise unchanged; a test pins the case.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ests, PublicAPI, docs, benchmarks

The public type is RowMap<T> (builder: RowMap.Create), and public wording says "map".

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…erloads strict and uncached

Every public method that took field-mapping overrides now takes one
RowMap<T>? map (default null, immediately before the CancellationToken):
Map, Results*, ResultsBuffered*, ResultsFromDataTable, DataTable.To,
DequeueAs, Transformer<T>.Create, ToChannel*, AsSourceBlock*,
ToTargetBlock*. Removed: the IEnumerable<KeyValuePair> overloads, the 24
pair overloads of the Channel/Dataflow methods, the inline params
ImmutableArray Map overload, the public For, the override-plan cache, the
lenient path and the rule that no public method takes a map.

The tuple overloads stay as thin wrappers: they convert the tuples once,
build a strict RowMap<T> on each call and call the map overload. They are
validated like a map and cache nothing.

RowMap<T> keeps an explicit IEnumerable<KeyValuePair<string, string?>>
only because the collection syntax needs an enumerable type; it enumerates
the effective mapping from its column table and retains no copy of the
pairs.

Tests: one test per family and receiver proves the renamed-column read
through the map overload and the tuple wrapper; tests of the lenient rules
and the cache are gone; the allocation test now covers a collection
expression and the tuple overload at the call site.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…off convenience

mapping.md leads with the collection syntax and field ??=, then static
readonly, LazyInitializer.EnsureInitialized and Lazy<T>, then the C# 14
note; a null column has its own labelled example; the one-off cost is
stated once; the tuple overloads are described as a strict convenience.
The README, the what's-new breaking-changes list (old shape to new shape,
strict uncached tuple overloads, the priority on Results<T>), the Dapper
migration step and the Dataflow package README follow. Samples: every
property is written with nameof, the other ways to hold a map are
compiled, and the checks cover them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ple list; NonQuery8 removed

The renamed-column benchmarks compare Map<T>() on matching names, a
declared RowMap<T>, the tuple overload and Dapper SetTypeMap; the 12
tuples live once in RenamedColumns. --map-memory prints the allocation
per call of each way of passing a map. SqlServerBenchmarks loses the
eight-parameter non-query pair. RESULTS.md no longer reports the removed
per-call scenarios as current results and says which timings were taken
at which commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…pping

# Conflicts:
#	docsite/articles/coming-from-dapper.md
@electricessence electricessence changed the title RowPlan<T>: declare a column mapping once, reuse it on every query RowMap<T>: declare a column mapping once, reuse it on every query (11.0.0-preview02) Nov 6, 2026
…mn name

Found by the review swarm (Engineer and Designer independently). The IExecuteReader overload threw ArgumentNullException for a null map,
so Results<T>() (empty tuple params, which yields no map) and Results<T>(null) threw, a regression, and contradicted the documented
"null reads by column name". The throw is removed (the sibling overloads already treat null as the default map) and a regression test
covers both calls. What's new: say the parameter rename needs a rename of named arguments, and describe the null binding accurately.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
electricessence and others added 3 commits November 5, 2026 21:58
…view swarm flagged

A bare null for the map was ambiguous (CS0121) between the RowMap<T>? and the tuple overloads of ResultsAsync(DbCommand, ..., CancellationToken):
the tuple twin now ranks below the map overload, and the tuple overload with optional parameters below that. RowMapBindingTests is a compile-only guard.
The layout limit counts hash keys, so the docs say "about 128"; what's new no longer claims every mapped overload defaults map to null.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…, no enumeration, no implicit conversion

Remove the pure type-shuffling that existed only for the `RowMap<Post> m = [ ... ]` syntax:
the [CollectionBuilder] attribute and its polyfill, the IEnumerable<KeyValuePair<string, string?>>
implementation, the implicit operator from ImmutableArray, and the public static class RowMap
with RowMap.Create<T>. A map is declared with `new RowMap<T>([ ... ])` (or `field ??= new([ ... ])`).

The ImmutableArray now goes straight through one internal constructor (IsDefault check, column
table, Declare, RejectSharedColumns); the ReadOnlySpan round trip (Declared, the private span
constructor) is gone. RowMap.cs shrinks from 139 to 31 lines: a short summary, remarks, one
example and a one-line constructor summary.

Tests: delete CollectionExpression_Factory_AndConversion_BuildTheSameMap and
EnumeratingAMap_YieldsTheEffectiveMappingInDeclarationOrder; rewrite the rest to
`new RowMap<T>([ ... ])`. Docs, samples, benchmarks and PublicAPI.Unshipped.txt updated.
Fix three false statements: the map keeps only its column table (not the declared array), a
held map adds no dictionary lookup only while queries meet the same layout, and the
unmeasured "one to two nanoseconds" claim is removed.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The repo's pattern: the root partial is Name._.cs so it sorts first among its partials. Pure rename, no content change.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant