Repository navigation
Materialize required collections absent from a JSON document as empty - #39014
AndriySvyryd merged 1 commit into
Conversation
6853d6b to
fce76bb
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Add coverage for explicit nested JSON nulls and no-tracking materialization.
Get a fresh assessment by requesting another Copilot review.
Pull request overview
This PR fixes JSON materialization of absent required complex collections and corrects null validation for complex collection elements.
Changes:
- Materializes absent required collections as empty.
- Corrects validation for complex collection elements.
- Adds regression tests and SQL baselines.
File summaries
| File | Summary |
|---|---|
test/EFCore.SqlServer.FunctionalTests/Update/ComplexCollectionJsonUpdateSqlServerTest.cs |
Adds SQL Server baselines. |
test/EFCore.Sqlite.FunctionalTests/Update/ComplexCollectionJsonUpdateSqliteTest.cs |
Adds SQLite baselines. |
test/EFCore.Relational.Specification.Tests/Update/ComplexCollectionJsonUpdateTestBase.cs |
Adds regression tests. |
src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs |
Fixes null validation for collection elements. |
src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs |
Tracks JSON property presence and applies empty-collection fixups. |
Review details
Suppressed comments (1)
src/EFCore.Relational/Query/RelationalShapedQueryCompilingExpressionVisitor.ShaperProcessingExpressionVisitor.cs:2256
- The new read flag controls the only distinction between an absent key and an explicit JSON
null, but the added tests exercise only the absent case. The existingSet_complex_collection_to_null_mapped_to_jsontest covers a top-level JSON collection and does not enter this nestedinnerAbsentPropertyFixupMappath, so a regression could incorrectly coalesce an explicit nested null to an empty collection while all current tests pass. Please add a base test that stores"Others":nulland asserts that the nested property remains null.
if (innerAbsentPropertyFixupMap.ContainsKey(innerShaperMapElementKey))
{
var propertyReadVariable = Variable(typeof(bool), innerShaperMapElementKey + "Read");
finalBlockVariables.Add(propertyReadVariable);
_navigationReadVariableMap[innerShaperMapElementKey] = propertyReadVariable;
- Files reviewed: 5/5 changed files
- Comments generated: 1
- Review effort level: Lite
fce76bb to
5f3594c
Compare
There was a problem hiding this comment.
🟡 Changes recommended
Critical issues remain with precompiled queries and the regression-test JSON setup.
Get a fresh assessment by requesting another Copilot review.
Review details
- Files reviewed: 5/5 changed files
- Comments generated: 2
- Review effort level: Lite
5f3594c to
f331e67
Compare
|
I had opened #39017 as a |
f331e67 to
c813993
Compare
c813993 to
d2a8926
Compare
996013f to
3e05e51
Compare
3e05e51 to
d353db9
Compare
d353db9 to
e73c09a
Compare
We should make this consistent with Cosmos
Query changes carry an inherent risk and currently that haven't been many reports of this issue, so we aren't going to port it to 10.x or 11.x. |
e73c09a to
b3e2705
Compare
- A complex type mapped to JSON with ComplexProperty().ToJson() produced null for a required collection whose key was absent from the stored document, typically a row persisted before the collection was added to the type. Reading it throws NullReferenceException and saving the entity after any change throws from PrepareToSave, so such rows are poison-on-touch. Both collection kinds in the issue are affected: the nested complex collection and the primitive collection. - After the JSON read loop, assign a required complex collection that is still null a new empty collection created through the property's IClrCollectionAccessor, via a liftable constant so that precompiled queries keep working; only an absent key leaves the variable null, since an explicit null already produces the empty collection. An explicit JSON null for a required complex collection materializes as empty as well, consistent with the Cosmos provider; a nullable one keeps the null. - Do the same for required primitive collections, which are read as scalar values in the same loop and were left at their default. The empty collection is of the type the JSON reader/writer itself would have created; that rule moves out of TypeMappingSourceBase into a shared helper so that the two cannot drift apart. A declared type that cannot be instantiated, or a property configured with its own JsonValueReaderWriter, keeps materializing as null, since only that reader/writer knows what it would have created. An explicit JSON null for a required primitive collection takes the same empty-collection path instead of the NullValueInRequiredJsonProperty error, which now remains only for a property with its own reader/writer; the column-mapped case is unchanged. - Skip the "is the containing complex object null?" guard in both null-collection checks of InternalEntryBase when the entry is a complex collection element: the containing property lives on the parent's entry, so the guard threw the internal PropertyDoesNotBelong message instead of NullRequiredComplexProperty or NullRequiredPrimitiveCollection. - Add tests for both collection kinds covering materialization with and without tracking, saving a row loaded from such a document, a value-type complex type, the error for a null required collection, and an explicit JSON null for both collection kinds, including the reader/writer boundary that keeps the error; the SQL Server test that pinned the error for a null primitive collection in a document now expects an empty collection. Fixes dotnet#38625
b3e2705 to
3e6be2a
Compare
AndriySvyryd
left a comment
There was a problem hiding this comment.
Thanks for your contribution!



Fixes #38625
When a complex type is mapped to JSON via
ComplexProperty().ToJson()and the stored document is missing the key for a required collection inside it (typically a row persisted before that collection was added to the type), the materializer producednullfor the collection. Reading it then throwsNullReferenceException, and saving the tracked entity after any change throws fromPrepareToSave, so such rows are poison-on-touch. This covers both kinds of collection the issue shows: the nested complex collection (Parcels) and the primitive collection (InspectionTagIds) in the repro.Per the discussion on the issue, an absent required collection now materializes as an empty collection (option (b), per @AndriySvyryd's note that the materializer does not preserve default property values). Only the absent case changes: an explicit JSON
nullkeeps whatever behavior it has today, which is not the same for the two collection kinds. There is a question about that below.Changes
ShaperProcessingExpressionVisitor/JsonEntityMaterializerRewriter(complex collections). The nested-property fixups run unconditionally after the JSON read loop, so a property that never appeared in the document had its fixup invoked withnull, overwriting whatever the CLR initializer produced. The rewriter now tracks whether each nested property was actually read; for a required complex collection that was not read, an absent-property fixup assigns a new empty collection created through the property'sIClrCollectionAccessor(the same accessor used for[]), via a liftable constant so precompiled queries keep working.ShaperProcessingExpressionVisitor(primitive collections). Scalar properties are read in the same loop and left at their default when the key is absent, which is how theList<Guid>in the issue's repro comes backnull. A required primitive collection now gets the same treatment: a read flag, and after the loop an empty collection of the type the JSON reader/writer itself would have created. That rule (array, concrete type with a public parameterless constructor, elseList<T>) lived as a local function inTypeMappingSourceBase.TryFindJsonCollectionMapping; it moves to a shared helper so the shaper and the type mapping cannot drift apart.ReadOnlyCollection<T>is built the way the reader/writer builds it, from a list, which is the one read-only shape it supports besides arrays. A property configured with its ownJsonValueReaderWriteris skipped entirely, since only that reader/writer knows what it would have created, and so is a declared type that nothing can instantiate; in both cases an absent key still materializes asnull, as before, and such a row therefore stays unsaveable. Handling those properly would mean asking the reader/writer for an empty value, which looks like an API addition rather than something to slip into this fix.InternalEntryBase, both null-collection checks.CheckForNullComplexPropertiesandCheckForNullCollectionguard with "is the containing complex object null?", callingGetCurrentValue(complexType.ComplexProperty)on the current entry even when that entry is a complex collection element. The containing property lives on the parent's entry, so both threw the internalPropertyDoesNotBelongmessage, which is exactly the confusing exception pasted in the issue, instead ofNullRequiredComplexProperty/NullRequiredPrimitiveCollection. Both now skip the guard for collection element types. This is a message fix, not a new check: saving in this state already threw, just unintelligibly.Behavioral change
For a required collection inside a JSON-mapped complex type, a document lacking that collection's key now materializes an empty collection where it previously produced
nulland an unsaveable entity. Documents written by EF always contain the key, so this affects rows written before the collection existed on the type, or by another writer. Nullable collections, explicit JSONnull, and owned JSON navigations are unchanged.This matches what the Cosmos provider already does for an absent key: it creates a nested collection before its property read loop (source).
One question for the team: explicit JSON
nullThis change is about an absent key and deliberately leaves an explicit
nullalone, but that exposes a divergence that already exists onmainand is worth settling:null)null)"X": nullNullValueInRequiredJsonPropertynull, then throws atSaveChangesSo for an explicit
nullthe primitive side already fails fast with a named error, while the complex side hands back an entity that cannot be saved. Cosmos, for comparison, flattens an explicitnullto empty for both. I have not touched any of that here, andComplex_collection_explicitly_null_in_json_is_materialized_as_nullrecords today's complex behavior rather than proposing it. Happy to make the complex side throw the same error as the primitive one, or flatten both to empty like Cosmos, whichever the team prefers.The primitive-collection read loop is shared with owned JSON entities and with the root of a JSON document, so required primitive collections there get the same treatment, covered by
Primitive_collection_absent_from_json_in_owned_entity_is_materialized_as_empty. That is deliberate: it is the same bug and the same fix, and carving owned types out would be arbitrary. It does mean an application that readsnullfrom such a key today, for instance to detect rows predating the property, sees an empty collection instead, and the next save writes"X":[]into the document. That seems worth a breaking-change note; tell me where you would like it and I will write it.Still out of scope, and reported separately where they are bugs: owned JSON navigation collections have the same absent-key behavior (#39060), and a required non-collection complex property that is absent from the document still materializes as
null.Tests
Added to
ComplexCollectionJsonUpdateTestBase, rewriting the stored document with raw SQL to simulate an old-shape row, with SQL baselines in the SQL Server and SQLite classes for the save cases:Complex_collection_absent_from_json_is_materialized_as_empty(tracking and no-tracking)Complex_collection_absent_from_json_in_value_type_complex_type_is_materialized_as_empty(a struct complex type, which takes the inlined fixup path rather than the invoked one)Save_changes_after_loading_row_with_complex_collection_absent_from_jsonSaving_null_required_complex_collection_in_complex_collection_element_throwsPrimitive_collection_absent_from_json_is_materialized_as_empty(tracking and no-tracking; the document has three elements, the middle one carrying the key, so a read flag cannot bleed across elements). The element type carries one property per way the collection to create is decided, so each branch of the rule is exercised:List<int>for a concrete type with a parameterless constructor,int[]for an array,IList<int>for theList<T>fallback,List<string>for reference elements,ReadOnlyCollection<int>for the shape the reader/writer builds from a list, plus the two deliberate non-cases, an optional collection and one with its ownJsonValueReaderWriter, which stay nullPrimitive_collection_absent_from_json_in_owned_entity_is_materialized_as_emptySave_changes_after_loading_row_with_primitive_collection_absent_from_jsonSaving_null_required_primitive_collection_in_complex_collection_element_throwsComplex_collection_explicitly_null_in_json_is_materialized_as_nullAll of them fail without the corresponding change except the last, which passes before and after: it is there to pin the absent-versus-explicit-null distinction, not as a regression test. The two "saving null" tests fail on
mainwith thePropertyDoesNotBelongmessage rather than the intended one.Invalid_identifier_json_property_name(precompiled queries) now also has a required complex collection and a required primitive collection whose JSON names are not valid C# identifiers, so the read flags and the empty-collection creation added here are exercised through the precompiled-query translator as well. That is also how the identifier bug fixed separately in #39062 surfaced.Servicing
Any application that adds a collection to an existing JSON-mapped complex type hits this on every pre-existing row, with no model-level workaround; the issue has details. Since
mainis now 12.0, I would like to ask for a backport torelease/11.0so this makes 11.0 GA, and for servicing consideration for 10.0.x, in the same vein as the complex-collection fixes that shipped in 10.0.3 (#37373), 10.0.6 (#37725) and 10.0.10 (#38299). External PRs cannot target release branches, so those backports would need to come from the team once this is approved. A ready cherry-pick forrelease/10.0, with a quirk switch (Microsoft.EntityFrameworkCore.Issue38625) and the tests adapted to that branch's conventions, is on my fork atbackport/38625-release-10.0; it predates the primitive-collection part and I will refresh it if the team wants it.