Skip to content

Throwing First/Single terminals, and ToDictionary with a value selector - #126

Merged
electricessence merged 8 commits into
masterfrom
feat/first-single-dictionary-values
Nov 12, 2026
Merged

electricessence merged 8 commits into
masterfrom
feat/first-single-dictionary-values

Conversation

@electricessence

Copy link
Copy Markdown
Collaborator

Throwing First / Single terminals, and ToDictionary with a value selector, on every mapped query.

// An insert must return exactly one new id: a terminal that throws replaces the handler.
long id = await connection
    .Command("insert into posts (title) values (@title) returning id")
    .AddParam(title)
    .Map<long>()
    .SingleAsync(ct);

// A lookup built in one pass from a key and a value selector:
Dictionary<int, string> names = await connection
    .Command("select id, name from authors")
    .Map<Author>()
    .ToDictionaryAsync(a => a.Id, a => a.Name ?? "", cancellationToken: ct);

What

  • First(), Single(), FirstAsync(ct) and SingleAsync(ct) are on MappedQuery<T>, its two staging structs, MappedReader<T>, MappedCommand<T> and MappedTable<T>, and as extensions on IMappedQuery<T>. First throws InvalidOperationException on no rows; Single also throws on more than one row. The message names the terminal. FirstAsync<T> and SingleAsync<T> are on commands too, next to the existing FirstOrDefaultAsync<T>.
  • They read exactly like FirstOrDefault and SingleOrDefault: First asks the provider for one row and Single does not; the later result sets are read through unless .SingleResult(); the connection is closed only if the call opened it. The throw happens after that cleanup, so a failure in a later result set is thrown first, and Single's more-than-one error is never replaced.
  • ToDictionary(keySelector, valueSelector, comparer) and ToDictionaryAsync(...) build the dictionary in one pass, key selector before value selector, once per row, with the same duplicate-key behavior as the key-only form (ArgumentException).
  • Through the IMappedQuery<T> interface the same terminals now do what the members do for the library's six types: the library's message and the read-through of later result sets. A third-party implementation of the interface still works through a LINQ fallback, which cannot read through. (Found in review: before this, IMappedQuery<Post> q = ...; q.FirstOrDefault() stopped after the row and missed a later failure.) The interface extensions also guard a null query now.

Notes for review

  • ToDictionaryAsync keeps the comparer before the token, as the key-only form does, so a caller passes the token by name.
  • No public signature besides the new members; the internal interface that routes the extensions to the members does not show in the public API.

Docs and tests

  • mapping.md, expressive-commands.md and the Dapper comparison (its "no throwing First/Single" text) are updated; whats-new-11.md has an entry; two compiled samples (an insert read with SingleAsync, a lookup with a value selector).
  • New tests for First/Single at all six levels and through the interface (no rows, one, two, with Skip/Take, a single value, a NULL row, read-through, SingleResult, a third-party implementation, null query), and for the dictionary overloads (one pass, comparer, duplicate key, a throwing selector leaves no leak).

Checks

  • Release and Debug builds: 0 warnings, 0 errors. Tests: Release 9814 of 9814. Debug 9813 of 9814 in both runs: the one failure each time is TokenTerminalTests.Allocation_Channel, a known flaky test (a different shape each run) that this change does not touch and that is being fixed separately. Samples: 109 of 109 checks pass. Style and analyzers: 0 findings. DocFX: 0 warnings.

🤖 Generated with Claude Code

electricessence and others added 8 commits November 12, 2026 04:19
…ary takes a value selector

The throwing terminals are on every mapped query (and FirstAsync<T>/SingleAsync<T> on commands); they read like
the OrDefault forms and throw after cleanup. ToDictionary and ToDictionaryAsync take a key and a value selector
and build the dictionary in one pass.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…interface extensions guard a null query

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…t interface terminals, no un-awaited ValueTask

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Conflicts: the SingleResult wording in mapping.md and the MappedQuery remarks keep master's text with this branch's
First and Single additions; whats-new keeps both sets of bullets.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Conflicts: First/Single and Select are both added at the end of MappedQueryExtensions, so both are kept; the
public-API list, the notes and the samples keep both sides.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@electricessence
electricessence merged commit 40b461e into master Nov 12, 2026
@electricessence
electricessence deleted the feat/first-single-dictionary-values branch November 12, 2026 15:35
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