Repository navigation
Support null versus empty for owned JSON collections - #39630
ajcvickers wants to merge 12 commits into
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Critical tracking and materialization defects can lose or overwrite nullable-collection changes, while the new abstract IUpdateEntry member breaks existing provider and extension implementations.
6 open issues
Prevent relationship fixup from populating protected null navigations · New Capture original values for nullable navigations before collection changes · New Keep null navigations intact during notification subscription · New Avoid reloading nullable collections for duplicate streamed entries · New Keep the new IUpdateEntry member backward compatible · New Preserve virtual dispatch for existing overload calls · New
4 files not reviewed
src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs: Generated filesrc/EFCore.InMemory/Properties/InMemoryStrings.Designer.cs: Generated filesrc/EFCore.Relational/Properties/RelationalStrings.Designer.cs: Generated filesrc/EFCore/Properties/CoreStrings.Designer.cs: Generated file
Review effort: Balanced
Adds an opt-in INavigation.IsNullable facet for collection navigations. Relational conventions set it for JSON-mapped collections whose CLR property is annotated as nullable. For such navigations, change tracking now distinguishes a null collection from an empty one, and null round-trips to and from the database as a JSON NULL. Fixes dotnet#33452 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Providers no longer need internal APIs to support nullable collection navigations. - Add QueryContext.CanLoadCollectionNullness and SetCollectionNullnessLoaded, which apply and record the stored null-ness of a collection navigation. These replace the internal QueryContext.StartedTracking and the state manager calls that the relational JSON include shaper made. - Stop using IRuntimeEntityType.NullableCollectionCount in ModificationCommand. - Make IUpdateEntry.IsModified(INavigation) a default interface member returning false, so it is not a source-breaking change for implementers. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Keep the existing RuntimeEntityType.AddNavigation signature and add an overload with the nullable parameter, so compiled models built against earlier versions keep working. - Don't run change detection when recording the loaded null-ness, which moved the entry to Modified and back during materialization. - Reset the set of entities that started being tracked when a query is executed again, so unsaved changes made after an earlier execution aren't overwritten. - Look up the state entry once per entity, and merge duplicate branches in the JSON include. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
- Setting CollectionEntry.IsModified to false for a nullable collection navigation now restores the original value, like it does for properties. The query code, which wants the loaded value to become the baseline, passes acceptChanges instead. - Make IUpdateEntry.IsModified(INavigation) abstract again instead of a default interface member, so implementers can't silently report no change. - The in-memory provider now rejects nullable collection navigations in model validation. - Rename the isNull parameter of QueryContext.SetCollectionNullnessLoaded to storedAsNull, which satisfies the API consistency rule on bool parameter names. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
87c4fdb to
d45b5ae
Compare
…erload The overload taking `nullable` had a higher overload resolution priority, so source calls omitting it, and RuntimeModelConvention for non-nullable navigations, bypassed subclasses that override the shipped nine-parameter virtual method. The new overload now has a required `nullable` parameter, so calls that omit it bind to the original overload. When `nullable` is false the new overload delegates to the original virtual method. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…ions InternalEntrySubscriber obtained the collection with GetOrCreateCollection when subscribing to, or unsubscribing from, a collection navigation. For a nullable collection navigation that is currently null, this created an empty collection, so Add wrote [] instead of NULL, Attach changed the supplied value, and stored nulls were replaced after a query materialized a tracked notifying entity. A null nullable collection navigation now has nothing to subscribe to, and is subscribed to when a collection is assigned, as for any other change. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
With ChangingAndChangedNotificationsWithOriginalValues the original values are captured lazily from PropertyChanging, but only for properties. Changing a nullable collection navigation before any property meant there was no snapshot to compare with, so the null-ness change was missed and lost by SaveChanges. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…a query An entity that started being tracked by a query stayed eligible for loading the stored null-ness of its nullable collections for the whole query. If a streaming query returned the same owner again, a change made to the collection in the meantime was overwritten and its baseline reset. The completion is now recorded for each (entry, navigation). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…n querying When a query returned an entity that was already tracked with a null nullable JSON collection, the stored elements were still materialized. Tracking them added them back to the owner's collection through fixup, so the null that was about to be saved was lost. The stored collection is now skipped without being materialized in that case. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…entities An entity that uses change notifications and has a null nullable collection no longer has an empty collection created when it starts being tracked. When a query then created the collection while including the stored JSON, nothing subscribed to it, so elements added to or removed from it in place were not detected and not saved. The collection is now subscribed to after the query has loaded it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… entry CollectionEntry.Load, LoadAsync and Query created an empty collection for the navigation before doing anything else, even when the load was not needed. For a nullable collection navigation that is null, this replaced the null, which was then detected as a change and saved as an empty collection. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Unmodifying a nullable collection navigation restored the original collection reference even when the current collection had the same null-ness, so replacing a collection with another one and then unmodifying the navigation silently undid the replacement. Replacing a collection is not a tracked change, as for collection navigations that are not nullable, so it is no longer reverted. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
|
After addressing the Copilot comments, I had a second, independent review of the whole branch (a separate Opus-model agent, briefed with Copilot's categories and asked to look for other integration gaps and missing coverage). Each finding was checked against the code and fixed test-first. Significant changes from it:
One suspected problem, with TPT/TPC and a derived-type collection, can't occur: model validation already rejects non-TPH inheritance for JSON-mapped entities. One claim, that old compiled models break because the Still open: the Sqlite, SQL Server and the remaining test suites pass. |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved collection-navigation defects can leave assigned elements untracked and cause SaveChanges failures, break NULL materialization for getter-only collections, and lose data-annotation nullability opt-outs during navigation replacement.
3 open issues
6 resolved since last review
Keep the new IUpdateEntry member backward compatible Avoid reloading nullable collections for duplicate streamed entries Keep null navigations intact during notification subscription Capture original values for nullable navigations before collection changes Prevent relationship fixup from populating protected null navigations Preserve virtual dispatch for existing overload calls
4 files not reviewed
src/EFCore.Cosmos/Properties/CosmosStrings.Designer.cs: Generated filesrc/EFCore.InMemory/Properties/InMemoryStrings.Designer.cs: Generated filesrc/EFCore.Relational/Properties/RelationalStrings.Designer.cs: Generated filesrc/EFCore/Properties/CoreStrings.Designer.cs: Generated file
Review effort: Balanced
| case INavigation { IsCollection: true, IsNullable: true } nullableCollection: | ||
| // Collection navigations of notifying entities have no relationship snapshot, so the null-ness | ||
| // of nullable collections is tracked directly from the notification. |
| bool? SetIsNullable(bool? nullable, bool fromDataAnnotation = false) | ||
| => (bool?)SetOrRemoveAnnotation(CoreAnnotationNames.NullableCollection, nullable, fromDataAnnotation)?.Value; |
| entry!.SetOriginalValue(navigation, null, skipChangeDetection: true); | ||
| } | ||
|
|
||
| ((IRuntimePropertyBase)navigation).MaterializationSetter.SetClrValue(entity, null); |
|
|
||
| return null; | ||
| } | ||
|
|
There was a problem hiding this comment.
Could we extend the existing IsRequired/CanSetIsRequired path for principal-side collection navigations and remove these new builder methods? The nullable facet can follow the existing property pattern (IsRequired(required) sets IsNullable = !required), letting the convention use navigation.Builder.IsRequired(false) and callers opt out through the fluent API.
|
|
||
| namespace Microsoft.EntityFrameworkCore.ChangeTracking; | ||
|
|
||
| public class NullableCollectionNavigationTrackingTest |
There was a problem hiding this comment.
Could we move these tests into the existing ChangeDetectorTest, CollectionEntryTest, and InternalEntrySubscriberTest classes? They already cover the corresponding tracking behaviors and have suitable test infrastructure. The loading/querying cases also overlap with the relational update tests, and several inverse scenarios could be combined as [Theory] cases to reduce duplication.
|
|
||
| namespace Microsoft.EntityFrameworkCore.Metadata.Conventions; | ||
|
|
||
| public class NullableJsonCollectionConventionTest |
There was a problem hiding this comment.
Could we put these model-building cases in RelationalOwnedTypesTestBase instead? It already covers owned JSON mapping, and tests there run across the generic/non-generic builders and SQL Server/SQLite. The nullable, non-nullable, JSON/non-JSON, and explicit-override cases could be combined as theory rows.
| Assert.Equal(1, ((Microsoft.EntityFrameworkCore.Metadata.Internal.IRuntimeEntityType)derived).NullableCollectionCount); | ||
| Assert.Equal(0, ((Microsoft.EntityFrameworkCore.Metadata.Internal.IRuntimeEntityType)baseType).NullableCollectionCount); | ||
| #pragma warning restore EF1001 // Internal EF Core API usage. | ||
| }, |
There was a problem hiding this comment.
Could we extend BigModel_with_JSON_columns instead of adding these two compiled-model tests? It already has a JSON owned collection on a derived type and an owned type using changing/changed notifications, so it can cover most of these facets with a nullable navigation and a few assertions. That would avoid roughly 34 generated baseline files across SQL Server and SQLite.
| } | ||
|
|
||
| [Theory, InlineData(true), InlineData(false)] // Issue #33452 | ||
| public virtual async Task Nullable_json_collection_changed_from_empty_to_null_is_saved(bool alsoModifyName) |
There was a problem hiding this comment.
Could we move the common save/load scenarios into JsonUpdateTestBase or reuse one model across theory cases? Empty/null transitions, nested and derived collections, concurrency, batching, and query projections use nearly identical model shapes; keeping a separate model per test also duplicates entity types and SQL baselines. Reserve separate models for cases that specifically test a different shape, such as coercing setters or notification behavior.
| if (memberInfo != null | ||
| && TryGetNullabilityInfo(modelBuilder, memberInfo, out var nullabilityInfo) | ||
| && nullabilityInfo.ReadState == NullabilityState.Nullable) | ||
| { |
There was a problem hiding this comment.
This convention can enable null materialization for a getter-only navigation with no writable backing field, but SetCollectionNullnessLoaded later unconditionally assigns null through the materialization setter when the stored JSON value is null. Could we skip these navigations here and reject explicit nullable configurations during model validation? Please add a getter-only regression test for the stored-null case.


Fixes #33452
Related: #35916 (Cosmos opt-in, not part of this PR), #31831
Summary
Owned JSON collections could not distinguish a null collection from an empty one.
ChangeDetector.DetectNavigationChangeignored null-ness, andModificationCommand.HandleJsonforced a write only for empty collections, so changing between null and empty was lost.This adds an opt-in
INavigation.IsNullablefacet (collection navigations only):IReadOnlyNavigation.IsNullable(default interface member),SetIsNullableon the mutable and convention interfaces, and aNullableCollectionannotation.RuntimeEntityType.AddNavigationhas a new overload with anullableargument, andPropertyCountstracksNullableCollectionCount.NullableJsonCollectionConventionsets the facet from nullable annotations for JSON-mapped collections.PropertyFlag.NavigationModified.CollectionEntry.IsModifiedis now settable (setting it tofalserestores the original collection, like for properties) andIUpdateEntry.IsModified(INavigation)is added.QueryContextmethods,CanLoadCollectionNullnessandSetCollectionNullnessLoaded, so providers (relational and Cosmos) don't need internal APIs for this.ModificationCommandwrites JSON NULL, and writes when replacing an entry with the same key and a null value.Breaking changes
SetIsNullable(false).IUpdateEntry.IsModified(INavigation)is a new abstract member, which is a source break for implementers.RuntimeEntityType.AddNavigationkeeps its old signature.Testing
Unit tests for the metadata, convention, tracking and validators, about 20 end-to-end tests in
NonSharedModelUpdatesTestBasewith Sqlite and SQL Server baselines, and two compiled-model tests. Fulltest.cmdrun passes with 0 failures (Cosmos emulator tests were likely skipped).Open items
Naming of
IsNullable, a convention builder API and fluent opt-out, copying the facet when a relationship is rebuilt inInternalForeignKeyBuilder, and an untested standalone projection of the collection.🤖 Generated with Claude Code