Repository navigation
Fix #5461 — an absent key must not project as an explicit JSON null - #5462
Merged
Merged
Conversation
A Select() projection is translated to jsonb_build_object(), and a stored document that simply does not have the key -- a property added to the document type after those documents were written -- locates as SQL NULL. jsonb_build_object() has no way to leave a pair out, so the projection gained a key the stored document never had, holding an explicit null. Deserializing that null into a non-nullable value type (bool, int, Guid, an enum) throws, while the very same document loads fine as a whole document and projects fine as a bare scalar. This is not a #5011 regression: the jsonb_build_object translation for simple flat member-access projections dates to 14068e0 (GH-1926) and is present unchanged on 8.0. #5011 only added the client-side fallback for projections that are NOT simple, and the reported shape took the jsonb_build_object path before and after it. jsonb_strip_nulls() puts the absence back, but it also recurses into object and array values, which would quietly drop meaningful nulls out of a projected Dictionary<string, T?> or nested document -- so NewObject now partitions its pairs and sends only the ones locating a non-nullable value-type scalar through the strip: (jsonb_build_object('CareTypes', CAST(d.data ->> 'CareTypes' as jsonb)) || jsonb_strip_nulls(jsonb_build_object('ExecuteWlzCheck', CAST(d.data ->> 'ExecuteWlzCheck' as boolean)))) When no pair qualifies -- the common case -- the emitted SQL is byte-identical to before. Collections, child documents and nested NewObjects never enter the strip, so their values cannot be reached into. Omitting the pair rather than coalescing to a CLR default also defers to the target type's own initializer and needs no per-type default literal. ChildCollectionJsonPathCount carries the same latent bug -- Count(predicate) over an absent collection is jsonb_array_length(NULL) -> null -> throws for int -- and opts in through the new IOmittedWhenNull marker. Synthetic members (an array index) carry no MemberType at all and keep the behavior they already had rather than guessing. Verified: LinqTests 1492/1492 green under both Newtonsoft and System.Text.Json, DocumentDbTests 1113/1113. The new tests cover the repro, the three control rows from the issue, the emitted SQL shape, raw JSON streaming, and a stored dictionary null that a blanket jsonb_strip_nulls() would have dropped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01481eiEZg1Bv6pu4DrgPRg3
This was referenced Sep 22, 2026
This was referenced Sep 24, 2026
This was referenced Sep 24, 2026
Closed
This was referenced Oct 1, 2026
Closed
This was referenced Oct 9, 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.
Closes #5461
The bug
A
Select()projection is translated tojsonb_build_object(), and a stored document that simply does not have the key — a property added to the document type after those documents were written — locates as SQL NULL.jsonb_build_object()has no way to leave a pair out, so the projection gains a key the stored document never had, holding an explicitnull. Deserializing that null into a non-nullable value type (bool,int,Guid, an enum) throws, while the very same document loads fine as a whole document and projects fine as a bare scalar.Reproduced with the default Newtonsoft serializer as well as System.Text.Json, so it is not an STJ quirk.
Not a #5011 regression
The issue attributes this to #5011. It predates it: the
jsonb_build_objecttranslation for simple flat member-access projections dates to14068e075(Dec 2023, GH-1926) and is present unchanged on8.0. #5011 only added the client-side fallback for projections that are not simple (method calls, arithmetic, ternaries). The reported shape —new { x.CareTypes, x.ExecuteWlzCheck }— took thejsonb_build_objectpath both before and after it. 7.x/8.x/9.x are all affected, so8.0is a valid backport target.The fix
jsonb_strip_nulls()puts the absence back, but it also recurses into object and array values, which would quietly drop meaningful nulls out of a projectedDictionary<string, T?>or nested document. SoNewObjectnow partitions its pairs and sends only the ones locating a non-nullable value-type scalar through the strip:When no pair qualifies — the common case — the emitted SQL is byte-identical to before. Collections, child documents and nested
NewObjects never enter the strip, so their values cannot be reached into.Omitting the pair rather than
coalesce-ing to a CLR default is the better of the issue's directions: it defers to the target type's own initializer, needs no per-type default literal (Guid, DateTime, enum-as-string, decimal…), and does not write a value into streamed JSON that the document does not contain. For anonymous types — the overwhelming case — the two are equivalent anyway.ChildCollectionJsonPathCountcarries the same latent bug (Count(predicate)over an absent collection isjsonb_array_length(NULL)→ null → throws forint) and opts in through the newIOmittedWhenNullmarker. Synthetic members (an array index) carry noMemberTypeat all and keep the behavior they already had rather than guessing.Verification
LinqTests1492/1492 green under both Newtonsoft and System.Text.JsonDocumentDbTests1113/1113 greenBug_5461_absent_key_in_select_projection.cs: the repro, the three control rows from the issue's table, the emitted SQL shape, raw JSON streaming, and a stored dictionary null that a blanketjsonb_strip_nulls()would have droppedDeliberately out of scope
null. Omitting them would not help: an anonymous type's constructor parameter defaults tonulleither way.Sumover all-absent keys) can still emit null into a non-nullable target — same class, separate fix.coalescecan reproduce a source type's non-default initializer (= true); only the client-side path knows it. Omission at least defers to the target type.🤖 Generated with Claude Code
https://claude.ai/code/session_01481eiEZg1Bv6pu4DrgPRg3