Skip to content

Alias the StreamOne payload column so Select() projections keep their ETag - #5160

Merged
jeremydmiller merged 1 commit into
masterfrom
fix/5158-streamone-select-projection
Aug 3, 2026
Merged

Alias the StreamOne payload column so Select() projections keep their ETag#5160
jeremydmiller merged 1 commit into
masterfrom
fix/5158-streamone-select-projection

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Fixes #5158. Also closes out the last untested claim behind #5120.

The defect

WriteSingle / StreamOne<T> threw at runtime whenever the queryable carried a Select() projection and emitETag was on (the default). Regression from #5027 / PR #5030 (44f9b44a2), shipped in 9.18.0.

Dumping the generated SQL for three shapes of the same query showed two distinct failure modes, not the one reported:

plain   OK      select d.data, d.mt_version as mt_etag_version from ... mt_doc_issue as d where d.id = :p0 LIMIT :p1;
anon    THREW   select  jsonb_build_object('Description', d.data ->> 'Description') , d.mt_version as mt_etag_version from ...
scalar  THREW   (no SQL — threw before the command was built)

Mode 1 — the lost data alias. VersionSelectClause.Apply rebuilds the select list from Inner.SelectFields() instead of delegating to the inner clause's own Apply, so any aliasing Apply would have emitted is dropped. DataSelectClause's field is the literal d.data, so Postgres names the column data anyway and the plain path worked by luck. SelectDataSelectClause's field is a jsonb_build_object(...) expression, so rebuilt without the alias the column came back named jsonb_build_object and the reader's GetOrdinal("data") threw IndexOutOfRangeException.

Mode 2 — scalar projections. Select(x => x.Description) makes T a primitive, and FindMapping(typeof(string)) throws ArgumentOutOfRangeException: This type cannot be used as a Marten document before any SQL is built.

The fix

  1. VersionSelectClause aliases the payload explicitly to data, making the name intentional for every inner clause rather than incidental for one. DocumentTable.SelectColumns guarantees the payload is the first selected field — "the order of the selection is data, id, everything else" — so only field 0 is aliased and the remaining fields pass through untouched, keeping their own names. That matters: a revisioned document's storage already selects d.data, d.mt_version, and aliasing more than the payload would have broken it.
  2. MartenLinqQueryProvider.StreamOneWithVersion<T> resolves the mapping from the source document type (parser.DocumentTypes().First()) rather than from T. Under a projection T is the projected type, and asking StorageFeatures for a mapping of it either invents one whose version metadata defaults to enabled or throws for a primitive. The version being read is the source document's either way.

Net effect: projections keep their ETag rather than merely not crashing. The projection is a pure function of the document, so it validates against the same version — a client can cache either representation off the same tag.

Tests

  • stream_one_over_a_select_projection_serves_the_projection_and_the_document_etag — Alba e2e over a new /minimal/issue/{id}/summary endpoint: the projected body is served, its ETag equals the full document's, and that tag drives a 304.
  • stream_one_over_an_anonymous_type_projection_emits_the_document_etag — the exact shape from the report. Cannot go through an endpoint (the result type must be nameable), so it runs against WriteSingle directly.
  • stream_one_over_a_scalar_projection_emits_the_document_etag — mode 2.
  • stream_one_over_a_select_projection_of_a_revisioned_document_emits_the_revision — the projection path on the numeric-revision flavor.
  • stream_one_over_a_select_projection_of_a_versionless_document_emits_no_etag — the source type still decides, so a projection over a versionless document must not pick up a default from the projected type.

Closing out #5120

#5120's proposal shipped in full in #5121, but one load-bearing claim in it was asserted in a code comment and never tested:

For a SingleStreamProjection target the resulting ETag is semantically identical to StreamAggregate's (both are the stream version), so the two read styles stay cache-coherent.

That is the entire justification for the feature. stream_one_and_stream_aggregate_serve_the_same_etag_for_the_same_stream now hits both endpoints for the same stream, compares their ETags at version 1 and version 2 (so a coincidental match at "1" cannot pass), and confirms a tag minted by either style is honored by the other with a 304. With that pinned, #5120 can be closed.

Verified locally (net10.0)

Marten.AspNetCore.Testing · LinqTests 1421/1421 · DocumentDbTests 1089 passed (1 pre-existing skip) · CompiledQueryTests 9/9 · EventSourcingTests.

Worth noting one course-correction during development: a first attempt guarded with CanCarryVersion (skip the version column for multi-field select clauses) and silently dropped the ETag for IRevisioned/ILongVersioned documents, whose storage selects data and mt_version. Two existing tests caught it, and it was replaced with the narrower first-field aliasing above.

🤖 Generated with Claude Code

… ETag

Fixes #5158. WriteSingle/StreamOne<T> threw whenever the queryable carried a
Select() projection and emitETag was on (the default) -- a regression from
#5027/#5030 (44f9b44), shipped in 9.18.0. Dumping the SQL showed two failure
modes rather than the one reported.

Mode 1, the lost alias: VersionSelectClause.Apply rebuilds the select list from
Inner.SelectFields() instead of delegating to the inner clause's own Apply, so
any aliasing Apply would have emitted is dropped. DataSelectClause's field is
the literal d.data, so Postgres names the column `data` anyway and the plain
path worked by luck. SelectDataSelectClause's field is a jsonb_build_object(...)
expression, so the column came back named jsonb_build_object and the reader's
GetOrdinal("data") threw IndexOutOfRangeException.

Mode 2: for a scalar projection T is a primitive, and FindMapping(typeof(string))
threw ArgumentOutOfRangeException before any SQL was built.

The fix:

- VersionSelectClause aliases the payload column to `data` explicitly, making
  the name intentional for every inner clause instead of incidental for one.
  DocumentTable.SelectColumns guarantees the payload is the first selected field
  ("the order of the selection is data, id, everything else"), so only field 0
  is aliased -- a revisioned document's storage already selects d.data AND
  d.mt_version, and aliasing more than the payload would break it.
- StreamOneWithVersion<T> resolves the mapping from the source document type
  rather than from T, which is the projected type under a Select().

Projections now keep their ETag rather than merely not crashing: the projection
is a pure function of the document, so it validates against the same version.

Also pins the one load-bearing claim behind #5120 that shipped untested -- that
StreamOne and StreamAggregate serve the same ETag for the same stream, which is
what makes serving a read model through either style cache-coherent. Compared at
two stream versions so a coincidental match at "1" cannot pass, with a
cross-honored 304 in both directions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

StreamOne/WriteSingle with a Select() projection throws "Field not found in row: data" when emitETag is on (regression in 9.18.0)

1 participant