Skip to content

Add a storage agnostic [FirstOrDefault] attribute - #3933

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-first-or-default
Aug 13, 2026
Merged

Add a storage agnostic [FirstOrDefault] attribute#3933
jeremydmiller merged 1 commit into
mainfrom
gh-first-or-default

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

[Entity] needs an identity to load by, so it cannot express the singleton document — a type a system stores exactly one of, looked up by nothing at all. Today that forces handler code to name a session type:

// Correct, but pinned to Marten
[WolverineGet("/api/critterwatch/alerts/config/metrics/defaults")]
public static async Task<MetricsAlertDefaults> GetMetricsDefaults(IDocumentSession session)
{
    var defaults = await session.Query<MetricsAlertDefaults>().FirstOrDefaultAsync();
    return defaults ?? new MetricsAlertDefaults();
}

becomes

[WolverineGet("/api/critterwatch/alerts/config/metrics/defaults")]
public static MetricsAlertDefaults GetMetricsDefaults([FirstOrDefault] MetricsAlertDefaults? defaults)
    => defaults ?? new MetricsAlertDefaults();

The seam

A new optional IPersistenceFrameProvider.TryBuildFirstOrDefaultFrame, a default interface method returning false in exactly the shape of the existing TryBuildFetchSpecificationFrame — so no external implementer breaks, and a provider that cannot support it says so rather than silently doing nothing.

Each provider supplies its own frame, because every one of them spells the async terminal operator differently:

Provider Generated call
Marten Marten.QueryableExtensions.FirstOrDefaultAsync(session.Query<T>(), token)
Polecat Polecat.Linq.PolecatQueryableExtensions.FirstOrDefaultAsync(...)
Fisher Fisher.Linq.QueryableExtensions.FirstOrDefaultAsync(...)
RavenDb Raven.Client.Documents.LinqExtensions.FirstOrDefaultAsync(...)
EF Core Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions.FirstOrDefaultAsync(dbContext.Set<T>(), token)

Those extension classes and methods are referenced through typeof(...) / nameof(...) rather than as literal strings in the generated source. A rename in any client library then breaks this build rather than shipping a codegen failure that only surfaces the first time a handler using the attribute is compiled at runtime. That mattered enough to be worth the ceremony given RavenDb's suite only runs on CI.

Deliberately not an IDataRequirement

No Required, no OnMissing, no MissingMessage. The parameter is null when nothing matches and the handler runs anyway, because a miss here is an ordinary answer to "is there one of these yet?" rather than an error worth a 404. Callers write their own ?? new T().

The query is unfiltered on purpose — a predicate is what Before methods, compiled queries and [FromQuerySpecification] are for, and promising consistent ordering across five different LINQ providers is not something this should try to do.

CosmosDb is not supported, and that is a storage model limit

Worth review attention, because it is the one place this does not deliver all six stores.

Wolverine's CosmosDb integration upserts every user document into a single shared wolverine container — the same one holding its own incoming/outgoing envelopes, node records and locks — via a plain container.UpsertItemAsync(document) with no per-type discriminator written onto user documents. Wolverine's own system documents are separated by a partitionKey property; user documents have none.

So "the first document of type T" is not expressible there. SELECT * FROM c OFFSET 0 LIMIT 1 would return an arbitrary item, quite possibly one of Wolverine's own envelopes, and deserialize it as T. Filtering to NOT IS_DEFINED(c.partitionKey) would exclude Wolverine's records but still conflate every user document type with every other.

Rather than ship something that returns the wrong object, the CosmosDb provider simply does not implement the new method. The default false becomes a bootstrapping time InvalidOperationException naming the provider and pointing at a Before method instead. Documented with a warning admonition.

If we want Cosmos parity later, it needs a type discriminator stamped on user documents at write time — a storage format change, and its own PR.

Testing

A character-for-character identical handler in five suites, proving the storage agnostic claim rather than asserting it:

  • MartenTests — 2/2 pass locally
  • PolecatTests — 2/2 pass locally
  • FisherTests — 2/2 pass locally
  • EfCoreTests — 2/2 pass locally
  • RavenDbTests — compiles; runs first on CI per instruction

Each covers both the null case (nothing stored, handler still runs, writes its fallback) and the hit case.

One real behavior difference surfaced while writing these: on Fisher, a document table is created lazily on first write, and querying a type never written throws no such table rather than returning nothing. That is a general Fisher characteristic — storage_attribute_routes_to_fisher_store already leans on it to assert a negative — not something this attribute introduces, so the Fisher test establishes the table then empties it, and the docs carry a tip about it.

dotnet build wolverine.slnx -c Release -f net9.0 clean.

Docs

docs/guide/handlers/persistence.md — a new "Reading the First of a Type" section with the before/after, the always-optional semantics, the CosmosDb warning, and the Fisher tip.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC

[Entity] needs an identity to load by, so it cannot express the singleton
document -- a type a system stores exactly one of, looked up by nothing at all.
Today that forces handler code to name a session type and hand-write the query,
which pins the code to one store.

[FirstOrDefault] resolves the equivalent of session.Query<T>().FirstOrDefaultAsync()
through whichever persistence provider owns the type, so the same handler is valid
on Marten, Polecat, Fisher, RavenDb or EF Core.

The seam is a new optional IPersistenceFrameProvider.TryBuildFirstOrDefaultFrame,
a default interface method returning false in the same shape as the existing
TryBuildFetchSpecificationFrame. Each provider supplies its own frame, because
every one of them spells the async terminal operator differently. Those extension
classes and methods are referenced through typeof/nameof rather than as literal
strings in the generated source, so a rename in any client library breaks the
build instead of shipping a codegen failure that only surfaces at runtime -- which
matters most for RavenDb, whose suite only runs on CI.

Deliberately standalone rather than an IDataRequirement: no Required, no OnMissing.
The parameter is null when nothing matches and the handler runs anyway, because a
miss here is an ordinary answer rather than an error worth a 404. The query is
unfiltered; a predicate is what Before methods, compiled queries and
[FromQuerySpecification] are for.

CosmosDb is NOT supported, and this is a storage model limitation rather than an
omission: Wolverine's CosmosDb integration upserts every user document into one
shared `wolverine` container alongside its own envelopes and node records, with no
per-type discriminator on user documents, so "the first document of type T" cannot
be asked for without risking a different type entirely. It therefore does not
implement the new method, and the default false turns into a bootstrapping time
error naming the provider rather than a wrong answer at runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JG8Un6iNeyXECKJk3jo5uC
@jeremydmiller
jeremydmiller merged commit 62f2ca0 into main Aug 13, 2026
38 checks passed
This was referenced Aug 14, 2026
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