Expose a document's mapped version member, and let the enum in-fragments be told the stored name - #592
Merged
Conversation
… enum in-fragments be told the stored name Two additive seams a document store can opt into, both defaulting to today's behaviour. Neither changes what a migration does to an existing database. IDocumentStorage<T> gains MappedVersionFor / MappedRevisionFor as default interface members returning null. A session needs the expected version for a write's concurrency guard. It can test a document for a versioned marker interface directly, but a document that instead maps a plain member as its version carries the same information somewhere the session cannot reach, since it holds the storage only as IDocumentStorage<T>. In Marten (marten#5372) the upsert therefore bound DBNull into its WHERE ... mt_version = ? guard and reported every cross-session write of such a document as a ConcurrencyException -- a documented feature that could not do the one thing it is named for. The alternatives all needed a runtime type test on the storage plus explicit forwarding through each decorator, which fails silently when a decorator is missed; that is the wrong failure mode for a concurrency guard. EnumIsOneOfWhereFragment and EnumIsNotOneOfWhereFragment gain a constructor overload taking an optional Func<object, string>. Under EnumStorage.AsString both rendered every value with ToString(), which is the member's declared name -- and that stops being the stored name as soon as the member is renamed with [JsonStringEnumMemberName] or [EnumMember]. The filter then compares against a name that is not in the data, matches nothing, and reports it as no rows rather than as an error. Only the serializer knows the name it wrote, so the caller supplies it; passing nothing keeps the previous behaviour byte for byte, which is why the three-argument constructors are unchanged. A delegate rather than an ISerializer keeps SqlGeneration free of a serialization dependency, and Weasel.Core.ISerializer carries no enum rendering to reuse anyway. Reflecting over the enum's fields for the rename attribute is not a substitute, which is worth recording because it is the tempting fix: it agrees with the serializer under a JIT and finds no attribute at all in a trimmed Native AOT binary, so it would quietly fall back to the declared name in exactly the deployment where the symptom is hardest to diagnose. The version bump is deliberately not here -- it belongs to the release commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01481eiEZg1Bv6pu4DrgPRg3
7 tasks
jeremydmiller
added a commit
that referenced
this pull request
Sep 12, 2026
Two additive seams a document store can opt into (#592), closing #590 and #591: IDocumentStorage<T>.MappedVersionFor / MappedRevisionFor, and an optional stored-name renderer on EnumIsOneOfWhereFragment / EnumIsNotOneOfWhereFragment. Both default to the previous behaviour. See docs/release-9-32.md. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01481eiEZg1Bv6pu4DrgPRg3
This was referenced Sep 12, 2026
Merged
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.
Closes #590. Closes #591.
Two additive seams a document store can opt into. Both default to today's behaviour, neither changes
what a migration does to an existing database, and nothing in Weasel, Marten, Polecat or Fisher
changes until a store adopts them.
A storage can expose a document's mapped version / revision member
IDocumentStorage<T>gains two default interface members:A session needs the expected version for a write's concurrency guard. It can test a document for a
versioned marker interface directly, but a document that instead maps a plain member as its version
carries the same information somewhere the session cannot reach — it holds the storage only as
IDocumentStorage<T>.In Marten (JasperFx/marten#5372, reported by @JurJean) the consequence was that a document configured
with
Metadata(m => m.Version.MapTo(x => x.Etag))boundDBNullinto itsON CONFLICT … WHERE table.mt_version = ?guard on every cross-sessionStore(), so no RETURNING rowcame back and a write that should have succeeded was reported as a
ConcurrencyException. The memberis populated on save and on load, so the mapping looks like it works — only the write guard never
sees it.
Every alternative needed a runtime type test on the storage plus explicit forwarding in each
decorator, and a decorator that forgets to forward fails silently, which is the wrong failure
mode for a concurrency guard.
MappedRevisionForreturns alongwhatever the member's own width is, matchingDocumentRevisionBinder, which already accepts anintor alongrevision member.The enum
infragments can be told the name the serializer storedEnumIsOneOfWhereFragmentandEnumIsNotOneOfWhereFragmenteach gain a constructor overload takingan optional
Func<object, string>? nameForValue.Under
EnumStorage.AsStringboth rendered every value withToString()— the member's declaredname, which stops being the stored name the moment somebody renames the member with
[JsonStringEnumMemberName](System.Text.Json) or[EnumMember](Newtonsoft). The filter thencompares against a name that is not in the data, matches nothing, and reports it as no rows rather
than as an error.
This is the collection-shaped half of JasperFx/marten#5376. Marten's own
EnumAsStringMembercovers==/!=; everyin/Containsshape funnels into these two fragments instead —IsOneOf,IsNotOneOf, bothEnumerableContainsbranches, andMemoryExtensionsContains— and kept the bug.Passing nothing keeps the previous behaviour byte for byte, which is why the existing three-argument
constructors are unchanged and delegate. A delegate rather than an
ISerializerkeepsWeasel.Postgresql.SqlGenerationfree of a serialization dependency — andWeasel.Core.ISerializercould not have served anyway, since it carries no enum rendering.
Worth recording because it is the tempting fix: reflecting over the enum's fields for the rename
attribute is not a substitute. It agrees with the serializer under a JIT and finds no attribute at
all in a trimmed Native AOT binary, so it would quietly fall back to the declared name in exactly the
deployment where the symptom is hardest to diagnose.
Verification
dotnet build: 0 errors. The only in-repo implementer ofIDocumentStorage<T>(
NoopDocumentStorage<T>inWeasel.Benchmarks) compiles untouched, which is the default-interface-member guarantee doing its job.
EnumIsOneOfWhereFragmentTests— 7 new tests, green: both fragments, with and without the renderer,AsStringandAsInteger, plus theAsIntegerpath asserting the renderer is never consulted andthe
IsOneOfnull entry still taking theis nullbranch without being rendered.Not here
The version bump — that belongs to the release commit, following the convention in this repo's
history. Suggested release is 9.32.0 (additive minor);
docs/release-9-32.mdis written andregistered in the sidebar.
🤖 Generated with Claude Code
https://claude.ai/code/session_01481eiEZg1Bv6pu4DrgPRg3