Alias the StreamOne payload column so Select() projections keep their ETag - #5160
Merged
Merged
Conversation
… 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>
This was referenced Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #5158. Also closes out the last untested claim behind #5120.
The defect
WriteSingle/StreamOne<T>threw at runtime whenever the queryable carried aSelect()projection andemitETagwas 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:
Mode 1 — the lost
dataalias.VersionSelectClause.Applyrebuilds the select list fromInner.SelectFields()instead of delegating to the inner clause's ownApply, so any aliasingApplywould have emitted is dropped.DataSelectClause's field is the literald.data, so Postgres names the columndataanyway and the plain path worked by luck.SelectDataSelectClause's field is ajsonb_build_object(...)expression, so rebuilt without the alias the column came back namedjsonb_build_objectand the reader'sGetOrdinal("data")threwIndexOutOfRangeException.Mode 2 — scalar projections.
Select(x => x.Description)makesTa primitive, andFindMapping(typeof(string))throwsArgumentOutOfRangeException: This type cannot be used as a Marten documentbefore any SQL is built.The fix
VersionSelectClausealiases the payload explicitly todata, making the name intentional for every inner clause rather than incidental for one.DocumentTable.SelectColumnsguarantees 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 selectsd.data, d.mt_version, and aliasing more than the payload would have broken it.MartenLinqQueryProvider.StreamOneWithVersion<T>resolves the mapping from the source document type (parser.DocumentTypes().First()) rather than fromT. Under a projectionTis the projected type, and askingStorageFeaturesfor 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}/summaryendpoint: 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 againstWriteSingledirectly.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:
That is the entire justification for the feature.
stream_one_and_stream_aggregate_serve_the_same_etag_for_the_same_streamnow 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 forIRevisioned/ILongVersioneddocuments, whose storage selectsdataandmt_version. Two existing tests caught it, and it was replaced with the narrower first-field aliasing above.🤖 Generated with Claude Code