From 2c4eeb81e4f0ef68ebbc32ab17934f6c276cf3e6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:40:03 +0000 Subject: [PATCH 1/6] Fix #38105: Set original values for AfterSaveBehavior.Throw properties during Update() to prevent DetectChanges from re-marking them as modified In SetEntityState with modifyProperties=true, after unflagging Throw properties, also ensure their original values match current values. This prevents DetectChanges from finding false mismatches due to snapshot factories storing default values for shadow properties on complex types. Agent-Logs-Url: https://github.com/dotnet/efcore/sessions/226e05b5-79be-4c42-9c60-b85c45bcf703 Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../Internal/InternalEntryBase.cs | 14 +++++ .../Query/AdHocComplexTypeQueryTestBase.cs | 53 +++++++++++++++++++ 2 files changed, 67 insertions(+) diff --git a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs index e0bf02dda27..19bfd15c8de 100644 --- a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs +++ b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs @@ -294,6 +294,20 @@ protected virtual void SetEntityState(EntityState oldState, EntityState newState SetPropertyModified(complexCollection, isModified: true, recurse: true); } } + + // Properties with AfterSaveBehavior.Throw were unflagged above, but DetectChanges could + // re-mark them if the original values snapshot doesn't match the current values (e.g. for + // shadow properties on complex types whose snapshot stores default values). + // Set the original values of Throw properties to match current values so that + // DetectChanges won't find a false mismatch and re-mark them as modified. + foreach (var property in structuralType.GetFlattenedProperties()) + { + if (property.GetAfterSaveBehavior() == PropertySaveBehavior.Throw + && property.GetOriginalValueIndex() >= 0) + { + SetOriginalValue(property, this[property], skipChangeDetection: true); + } + } } if (oldState == newState) diff --git a/test/EFCore.Specification.Tests/Query/AdHocComplexTypeQueryTestBase.cs b/test/EFCore.Specification.Tests/Query/AdHocComplexTypeQueryTestBase.cs index b5d5739c8e3..41ab4f94fa5 100644 --- a/test/EFCore.Specification.Tests/Query/AdHocComplexTypeQueryTestBase.cs +++ b/test/EFCore.Specification.Tests/Query/AdHocComplexTypeQueryTestBase.cs @@ -379,6 +379,59 @@ public class OptionalComplexProperty #endregion Issue37337 + #region Issue38105 + + [ConditionalFact] + public virtual async Task Update_entity_with_nullable_complex_type_and_discriminator_does_not_throw() + { + var contextFactory = await InitializeNonSharedTest( + seed: context => + { + var entity = new Context37337.EntityType + { + Id = Guid.NewGuid(), + Prop = new Context37337.OptionalComplexProperty + { + OptionalValue = true + } + }; + context.Add(entity); + context.Entry(entity).Property(Issue37337CreatedByShadowPropertyName).CurrentValue = "Seeder"; + return context.SaveChangesAsync(); + }); + + await using var context = contextFactory.CreateDbContext(); + + var entity = await context.Set().SingleAsync(); + var id = entity.Id; + context.ChangeTracker.Clear(); + + // Create a new disconnected instance with the same key and Update it. + // The complex type discriminator (shadow property with AfterSaveBehavior.Throw) should not + // be marked as modified by Update(), and SaveChanges should succeed without throwing. + var updatedEntity = new Context37337.EntityType + { + Id = id, + Prop = new Context37337.OptionalComplexProperty + { + OptionalValue = false + } + }; + + context.Update(updatedEntity); + + await context.SaveChangesAsync(); + + context.ChangeTracker.Clear(); + + var reloaded = await context.Set().SingleAsync(); + Assert.Equal(id, reloaded.Id); + Assert.NotNull(reloaded.Prop); + Assert.False(reloaded.Prop.OptionalValue); + } + + #endregion Issue38105 + protected override string NonSharedStoreName => "AdHocComplexTypeQueryTest"; } From 1315bc51041e7fbf349a42f3d3f247b173e2a8be Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 20:47:07 +0000 Subject: [PATCH 2/6] Optimize: use flag to skip second loop when no Throw properties exist Agent-Logs-Url: https://github.com/dotnet/efcore/sessions/226e05b5-79be-4c42-9c60-b85c45bcf703 Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../Internal/InternalEntryBase.cs | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs index 19bfd15c8de..2a641580da1 100644 --- a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs +++ b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs @@ -272,11 +272,18 @@ protected virtual void SetEntityState(EntityState oldState, EntityState newState _stateData.FlagAllProperties(StructuralType.PropertyCount, PropertyFlag.Modified, flagged: true); // Hot path; do not use LINQ + var hasThrowProperties = false; foreach (var property in structuralType.GetFlattenedProperties()) { if (property.GetAfterSaveBehavior() != PropertySaveBehavior.Save) { _stateData.FlagProperty(property.GetIndex(), PropertyFlag.Modified, isFlagged: false); + + if (property.GetAfterSaveBehavior() == PropertySaveBehavior.Throw + && property.GetOriginalValueIndex() >= 0) + { + hasThrowProperties = true; + } } // Properties that are not loaded (IsAutoLoaded = false and not yet loaded) should @@ -300,12 +307,15 @@ protected virtual void SetEntityState(EntityState oldState, EntityState newState // shadow properties on complex types whose snapshot stores default values). // Set the original values of Throw properties to match current values so that // DetectChanges won't find a false mismatch and re-mark them as modified. - foreach (var property in structuralType.GetFlattenedProperties()) + if (hasThrowProperties) { - if (property.GetAfterSaveBehavior() == PropertySaveBehavior.Throw - && property.GetOriginalValueIndex() >= 0) + foreach (var property in structuralType.GetFlattenedProperties()) { - SetOriginalValue(property, this[property], skipChangeDetection: true); + if (property.GetAfterSaveBehavior() == PropertySaveBehavior.Throw + && property.GetOriginalValueIndex() >= 0) + { + SetOriginalValue(property, this[property], skipChangeDetection: true); + } } } } From 448fea5a6ea7f0f37f8a8ac48e43c0e8bfd3b166 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 21:09:44 +0000 Subject: [PATCH 3/6] Rename hasThrowProperties to hasAfterSaveThrowProperties Agent-Logs-Url: https://github.com/dotnet/efcore/sessions/25d6f7dd-9b21-494f-af74-0354cf40a642 Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs index 2a641580da1..123577c1a97 100644 --- a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs +++ b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs @@ -272,7 +272,7 @@ protected virtual void SetEntityState(EntityState oldState, EntityState newState _stateData.FlagAllProperties(StructuralType.PropertyCount, PropertyFlag.Modified, flagged: true); // Hot path; do not use LINQ - var hasThrowProperties = false; + var hasAfterSaveThrowProperties = false; foreach (var property in structuralType.GetFlattenedProperties()) { if (property.GetAfterSaveBehavior() != PropertySaveBehavior.Save) @@ -282,7 +282,7 @@ protected virtual void SetEntityState(EntityState oldState, EntityState newState if (property.GetAfterSaveBehavior() == PropertySaveBehavior.Throw && property.GetOriginalValueIndex() >= 0) { - hasThrowProperties = true; + hasAfterSaveThrowProperties = true; } } @@ -307,7 +307,7 @@ protected virtual void SetEntityState(EntityState oldState, EntityState newState // shadow properties on complex types whose snapshot stores default values). // Set the original values of Throw properties to match current values so that // DetectChanges won't find a false mismatch and re-mark them as modified. - if (hasThrowProperties) + if (hasAfterSaveThrowProperties) { foreach (var property in structuralType.GetFlattenedProperties()) { From 2824c7f8091cc1f0de68ede4bdb8fbd826cfb27d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 22:31:14 +0000 Subject: [PATCH 4/6] Move SetOriginalValue into first loop with IsConceptualNull guard, remove second pass Agent-Logs-Url: https://github.com/dotnet/efcore/sessions/920653c7-9690-47d4-8d9d-10857f722725 Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../Internal/InternalEntryBase.cs | 28 ++++++------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs index 123577c1a97..46d66123eb5 100644 --- a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs +++ b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs @@ -272,17 +272,22 @@ protected virtual void SetEntityState(EntityState oldState, EntityState newState _stateData.FlagAllProperties(StructuralType.PropertyCount, PropertyFlag.Modified, flagged: true); // Hot path; do not use LINQ - var hasAfterSaveThrowProperties = false; foreach (var property in structuralType.GetFlattenedProperties()) { if (property.GetAfterSaveBehavior() != PropertySaveBehavior.Save) { _stateData.FlagProperty(property.GetIndex(), PropertyFlag.Modified, isFlagged: false); + // Properties with AfterSaveBehavior.Throw were unflagged above, but DetectChanges could + // re-mark them if the original values snapshot doesn't match the current values (e.g. for + // shadow properties on complex types whose snapshot stores default values). + // Set the original values of Throw properties to match current values so that + // DetectChanges won't find a false mismatch and re-mark them as modified. if (property.GetAfterSaveBehavior() == PropertySaveBehavior.Throw - && property.GetOriginalValueIndex() >= 0) + && property.GetOriginalValueIndex() >= 0 + && !IsConceptualNull(property)) { - hasAfterSaveThrowProperties = true; + SetOriginalValue(property, this[property], skipChangeDetection: true); } } @@ -301,23 +306,6 @@ protected virtual void SetEntityState(EntityState oldState, EntityState newState SetPropertyModified(complexCollection, isModified: true, recurse: true); } } - - // Properties with AfterSaveBehavior.Throw were unflagged above, but DetectChanges could - // re-mark them if the original values snapshot doesn't match the current values (e.g. for - // shadow properties on complex types whose snapshot stores default values). - // Set the original values of Throw properties to match current values so that - // DetectChanges won't find a false mismatch and re-mark them as modified. - if (hasAfterSaveThrowProperties) - { - foreach (var property in structuralType.GetFlattenedProperties()) - { - if (property.GetAfterSaveBehavior() == PropertySaveBehavior.Throw - && property.GetOriginalValueIndex() >= 0) - { - SetOriginalValue(property, this[property], skipChangeDetection: true); - } - } - } } if (oldState == newState) From 17ad1d1905d5e8949c12631f0c231a0c490d6b90 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 23:10:01 +0000 Subject: [PATCH 5/6] Add missing Cosmos test override for Update_entity_with_nullable_complex_type_and_discriminator_does_not_throw Agent-Logs-Url: https://github.com/dotnet/efcore/sessions/c4bbf4d0-14d6-4657-8eff-040197cda3bb Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../Query/AdHocComplexTypeQueryCosmosTest.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/EFCore.Cosmos.FunctionalTests/Query/AdHocComplexTypeQueryCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/Query/AdHocComplexTypeQueryCosmosTest.cs index 82d17ce9cee..d2b11ff0dc6 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Query/AdHocComplexTypeQueryCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Query/AdHocComplexTypeQueryCosmosTest.cs @@ -85,6 +85,13 @@ FROM root c """); } + public override async Task Update_entity_with_nullable_complex_type_and_discriminator_does_not_throw() + { + await base.Update_entity_with_nullable_complex_type_and_discriminator_does_not_throw(); + + AssertSql(); + } + protected override DbContextOptionsBuilder AddNonSharedOptions(DbContextOptionsBuilder builder) => base.AddNonSharedOptions(builder) .ConfigureWarnings(w => w.Ignore(CosmosEventId.NoPartitionKeyDefined)); From 009f41901ee1a943ce85425652bc454fe85d2278 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Apr 2026 23:26:32 +0000 Subject: [PATCH 6/6] Cache property.GetAfterSaveBehavior() and populate Cosmos test AssertSql with actual SQL Agent-Logs-Url: https://github.com/dotnet/efcore/sessions/35584ae8-0472-4f4c-9a93-cdd770e3d1a3 Co-authored-by: AndriySvyryd <6539701+AndriySvyryd@users.noreply.github.com> --- .../ChangeTracking/Internal/InternalEntryBase.cs | 5 +++-- .../Query/AdHocComplexTypeQueryCosmosTest.cs | 13 ++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs index 46d66123eb5..472b9394662 100644 --- a/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs +++ b/src/EFCore/ChangeTracking/Internal/InternalEntryBase.cs @@ -274,7 +274,8 @@ protected virtual void SetEntityState(EntityState oldState, EntityState newState // Hot path; do not use LINQ foreach (var property in structuralType.GetFlattenedProperties()) { - if (property.GetAfterSaveBehavior() != PropertySaveBehavior.Save) + var afterSaveBehavior = property.GetAfterSaveBehavior(); + if (afterSaveBehavior != PropertySaveBehavior.Save) { _stateData.FlagProperty(property.GetIndex(), PropertyFlag.Modified, isFlagged: false); @@ -283,7 +284,7 @@ protected virtual void SetEntityState(EntityState oldState, EntityState newState // shadow properties on complex types whose snapshot stores default values). // Set the original values of Throw properties to match current values so that // DetectChanges won't find a false mismatch and re-mark them as modified. - if (property.GetAfterSaveBehavior() == PropertySaveBehavior.Throw + if (afterSaveBehavior == PropertySaveBehavior.Throw && property.GetOriginalValueIndex() >= 0 && !IsConceptualNull(property)) { diff --git a/test/EFCore.Cosmos.FunctionalTests/Query/AdHocComplexTypeQueryCosmosTest.cs b/test/EFCore.Cosmos.FunctionalTests/Query/AdHocComplexTypeQueryCosmosTest.cs index d2b11ff0dc6..c27c7efa56b 100644 --- a/test/EFCore.Cosmos.FunctionalTests/Query/AdHocComplexTypeQueryCosmosTest.cs +++ b/test/EFCore.Cosmos.FunctionalTests/Query/AdHocComplexTypeQueryCosmosTest.cs @@ -89,7 +89,18 @@ public override async Task Update_entity_with_nullable_complex_type_and_discrimi { await base.Update_entity_with_nullable_complex_type_and_discriminator_does_not_throw(); - AssertSql(); + AssertSql( + """ +SELECT VALUE c +FROM root c +OFFSET 0 LIMIT 2 +""", + // + """ +SELECT VALUE c +FROM root c +OFFSET 0 LIMIT 2 +"""); } protected override DbContextOptionsBuilder AddNonSharedOptions(DbContextOptionsBuilder builder)