diff --git a/src/EFCore/ChangeTracking/Internal/EntityGraphAttacher.cs b/src/EFCore/ChangeTracking/Internal/EntityGraphAttacher.cs index ba7048a022e..711ab05a93a 100644 --- a/src/EFCore/ChangeTracking/Internal/EntityGraphAttacher.cs +++ b/src/EFCore/ChangeTracking/Internal/EntityGraphAttacher.cs @@ -102,7 +102,11 @@ private bool PaintAction( SetReferenceLoaded(node); var internalEntityEntry = node.GetInfrastructure(); - if (internalEntityEntry.EntityState != EntityState.Detached + var sourceEntry = node.SourceEntry?.GetInfrastructure(); + + if ((internalEntityEntry.EntityState != EntityState.Detached + && (internalEntityEntry.EntityState != EntityState.Deleted + || sourceEntry?.SharedIdentityEntry == null)) || (_visited != null && _visited.Contains(internalEntityEntry.Entity))) { return false; @@ -114,7 +118,7 @@ private bool PaintAction( if (internalEntityEntry.StateManager.ResolveToExistingEntry( internalEntityEntry, - node.InboundNavigation, node.SourceEntry?.GetInfrastructure())) + node.InboundNavigation, sourceEntry)) { (_visited ??= new HashSet(ReferenceEqualityComparer.Instance)).Add(internalEntityEntry.Entity); } @@ -139,7 +143,10 @@ private async Task PaintActionAsync( SetReferenceLoaded(node); var internalEntityEntry = node.GetInfrastructure(); - if (internalEntityEntry.EntityState != EntityState.Detached + var sourceEntry = node.SourceEntry?.GetInfrastructure(); + if ((internalEntityEntry.EntityState != EntityState.Detached + && (internalEntityEntry.EntityState != EntityState.Deleted + || sourceEntry?.SharedIdentityEntry == null)) || (_visited != null && _visited.Contains(internalEntityEntry.Entity))) { return false; @@ -151,7 +158,7 @@ private async Task PaintActionAsync( if (internalEntityEntry.StateManager.ResolveToExistingEntry( internalEntityEntry, - node.InboundNavigation, node.SourceEntry?.GetInfrastructure())) + node.InboundNavigation, sourceEntry)) { (_visited ??= []).Add(internalEntityEntry.Entity); } diff --git a/src/EFCore/ChangeTracking/Internal/StateManager.cs b/src/EFCore/ChangeTracking/Internal/StateManager.cs index 44b2ab405e9..9a5a169ef1b 100644 --- a/src/EFCore/ChangeTracking/Internal/StateManager.cs +++ b/src/EFCore/ChangeTracking/Internal/StateManager.cs @@ -1208,6 +1208,14 @@ public virtual void CascadeChanges(bool force) /// public virtual void CascadeDelete(InternalEntityEntry entry, bool force, IEnumerable? foreignKeys = null) { + // When an owned entity is replaced (e.g., via record 'with' expression), the old entry is + // marked Deleted and a new entry with the same key is linked via SharedIdentityEntry. + // Skip cascade from the old entry since the replacement handles its own dependents. + if (entry.SharedIdentityEntry != null) + { + return; + } + var doCascadeDelete = force || CascadeDeleteTiming != CascadeTiming.Never; var principalIsDetached = entry.EntityState == EntityState.Detached; diff --git a/test/EFCore.Relational.Specification.Tests/Update/JsonUpdateTestBase.cs b/test/EFCore.Relational.Specification.Tests/Update/JsonUpdateTestBase.cs index 54c37c517d0..2dd1a172555 100644 --- a/test/EFCore.Relational.Specification.Tests/Update/JsonUpdateTestBase.cs +++ b/test/EFCore.Relational.Specification.Tests/Update/JsonUpdateTestBase.cs @@ -3671,6 +3671,67 @@ public virtual Task Edit_single_property_with_non_ascii_characters() Assert.Equal("测试1", result.OwnedReferenceRoot.OwnedReferenceBranch.OwnedReferenceLeaf.SomethingSomething); }); + [ConditionalFact] + public virtual Task Replace_json_reference_root_preserves_nested_owned_entities_in_memory() + => TestHelpers.ExecuteWithStrategyInTransactionAsync( + CreateContext, + UseTransaction, + async context => + { + var query = await context.JsonEntitiesBasic.ToListAsync(); + var entity = query.Single(); + + // Save original leaf value + var originalLeaf = entity.OwnedReferenceRoot.OwnedReferenceBranch.OwnedReferenceLeaf; + var originalLeafValue = originalLeaf.SomethingSomething; + + // Replace the owned reference with a new instance that shares nested reference navigations + var oldRoot = entity.OwnedReferenceRoot; + entity.OwnedReferenceRoot = new JsonOwnedRoot + { + Name = "Modified", + Number = oldRoot.Number, + Names = oldRoot.Names, + Numbers = oldRoot.Numbers, + OwnedReferenceBranch = new JsonOwnedBranch + { + Id = oldRoot.OwnedReferenceBranch.Id, + Date = oldRoot.OwnedReferenceBranch.Date, + Enum = oldRoot.OwnedReferenceBranch.Enum, + Fraction = oldRoot.OwnedReferenceBranch.Fraction, + NullableEnum = oldRoot.OwnedReferenceBranch.NullableEnum, + Enums = oldRoot.OwnedReferenceBranch.Enums, + NullableEnums = oldRoot.OwnedReferenceBranch.NullableEnums, + OwnedReferenceLeaf = originalLeaf, + OwnedCollectionLeaf = [], + }, + OwnedCollectionBranch = [], + }; + + // Before DetectChanges, leaf should be accessible + Assert.Same(originalLeaf, entity.OwnedReferenceRoot.OwnedReferenceBranch.OwnedReferenceLeaf); + + context.ChangeTracker.DetectChanges(); + + // After DetectChanges, leaf should still be accessible + Assert.NotNull(entity.OwnedReferenceRoot.OwnedReferenceBranch.OwnedReferenceLeaf); + + ClearLog(); + await context.SaveChangesAsync(); + + // After SaveChanges, nested owned entities should still be accessible in memory + Assert.NotNull(entity.OwnedReferenceRoot.OwnedReferenceBranch); + Assert.NotNull(entity.OwnedReferenceRoot.OwnedReferenceBranch.OwnedReferenceLeaf); + Assert.Equal(originalLeafValue, entity.OwnedReferenceRoot.OwnedReferenceBranch.OwnedReferenceLeaf.SomethingSomething); + }, + async context => + { + var result = await context.Set().SingleAsync(); + Assert.Equal("Modified", result.OwnedReferenceRoot.Name); + Assert.NotNull(result.OwnedReferenceRoot.OwnedReferenceBranch); + Assert.NotNull(result.OwnedReferenceRoot.OwnedReferenceBranch.OwnedReferenceLeaf); + }); + public void UseTransaction(DatabaseFacade facade, IDbContextTransaction transaction) => facade.UseTransaction(transaction.GetDbTransaction()); diff --git a/test/EFCore.SqlServer.FunctionalTests/Update/JsonUpdateJsonTypeSqlServerTest.cs b/test/EFCore.SqlServer.FunctionalTests/Update/JsonUpdateJsonTypeSqlServerTest.cs index 879095bb839..eeec57d6bea 100644 --- a/test/EFCore.SqlServer.FunctionalTests/Update/JsonUpdateJsonTypeSqlServerTest.cs +++ b/test/EFCore.SqlServer.FunctionalTests/Update/JsonUpdateJsonTypeSqlServerTest.cs @@ -3274,6 +3274,28 @@ FROM [JsonEntitiesAllTypes] AS [j] """); } + public override async Task Replace_json_reference_root_preserves_nested_owned_entities_in_memory() + { + await base.Replace_json_reference_root_preserves_nested_owned_entities_in_memory(); + + AssertSql( + """ +@p0='{"Id":0,"Name":"Modified","Names":["e1_r1","e1_r2"],"Number":10,"Numbers":[-2147483648,-1,0,1,2147483647],"OwnedCollectionBranch":[],"OwnedReferenceBranch":{"Date":"2100-01-01T00:00:00","Enum":-1,"Enums":[-1,-1,2],"Fraction":10.0,"Id":88,"NullableEnum":null,"NullableEnums":[null,-1,2],"OwnedCollectionLeaf":[],"OwnedReferenceLeaf":{"SomethingSomething":"e1_r_r_r"}}}' (Nullable = false) (Size = 367) +@p1='1' + +SET IMPLICIT_TRANSACTIONS OFF; +SET NOCOUNT ON; +UPDATE [JsonEntitiesBasic] SET [OwnedReferenceRoot] = @p0 +OUTPUT 1 +WHERE [Id] = @p1; +""", + // + """ +SELECT TOP(2) [j].[Id], [j].[EntityBasicId], [j].[Name], [j].[OwnedCollectionRoot], [j].[OwnedReferenceRoot] +FROM [JsonEntitiesBasic] AS [j] +"""); + } + protected override void ClearLog() => Fixture.TestSqlLoggerFactory.Clear(); diff --git a/test/EFCore.Sqlite.FunctionalTests/Update/JsonUpdateSqliteTest.cs b/test/EFCore.Sqlite.FunctionalTests/Update/JsonUpdateSqliteTest.cs index b1905c54a5e..3fb88eaccc1 100644 --- a/test/EFCore.Sqlite.FunctionalTests/Update/JsonUpdateSqliteTest.cs +++ b/test/EFCore.Sqlite.FunctionalTests/Update/JsonUpdateSqliteTest.cs @@ -2408,6 +2408,27 @@ public override Task Edit_single_property_collection_of_collection_of_nullable_i public override Task Edit_single_property_collection_of_collection_of_single() => Assert.ThrowsAsync(base.Edit_single_property_collection_of_collection_of_single); + public override async Task Replace_json_reference_root_preserves_nested_owned_entities_in_memory() + { + await base.Replace_json_reference_root_preserves_nested_owned_entities_in_memory(); + + AssertSql( + """ +@p0='{"Id":0,"Name":"Modified","Names":["e1_r1","e1_r2"],"Number":10,"Numbers":[-2147483648,-1,0,1,2147483647],"OwnedCollectionBranch":[],"OwnedReferenceBranch":{"Date":"2100-01-01 00:00:00","Enum":-1,"Enums":[-1,-1,2],"Fraction":"10.0","Id":88,"NullableEnum":null,"NullableEnums":[null,-1,2],"OwnedCollectionLeaf":[],"OwnedReferenceLeaf":{"SomethingSomething":"e1_r_r_r"}}}' (Nullable = false) (Size = 369) +@p1='1' + +UPDATE "JsonEntitiesBasic" SET "OwnedReferenceRoot" = @p0 +WHERE "Id" = @p1 +RETURNING 1; +""", + // + """ +SELECT "j"."Id", "j"."EntityBasicId", "j"."Name", "j"."OwnedCollectionRoot", "j"."OwnedReferenceRoot" +FROM "JsonEntitiesBasic" AS "j" +LIMIT 2 +"""); + } + protected override void ClearLog() => Fixture.TestSqlLoggerFactory.Clear();