From d0130a75376b0134c6c1d63a2ff8ee39cd49eb6d Mon Sep 17 00:00:00 2001 From: maliming Date: Wed, 11 Feb 2026 14:49:57 +0800 Subject: [PATCH 1/2] Reset navigation IsModified flags and clear on UoW Resolve #24806 --- .../Volo/Abp/EntityFrameworkCore/AbpDbContext.cs | 14 +++++++++++++- .../ChangeTrackers/AbpEfCoreNavigationHelper.cs | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs index 6a5b608c76..2575126fd5 100644 --- a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs +++ b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs @@ -277,7 +277,19 @@ public abstract class AbpDbContext : DbContext, IAbpEfCoreDbContext, finally { ChangeTracker.AutoDetectChangesEnabled = true; - AbpEfCoreNavigationHelper.RemoveChangedEntityEntries(); + AbpEfCoreNavigationHelper.ResetChangedFlags(); + if (UnitOfWorkManager.Current != null) + { + UnitOfWorkManager.Current.OnCompleted(() => + { + AbpEfCoreNavigationHelper.Clear(); + return Task.CompletedTask; + }); + } + else + { + AbpEfCoreNavigationHelper.Clear(); + } } } diff --git a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/ChangeTrackers/AbpEfCoreNavigationHelper.cs b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/ChangeTrackers/AbpEfCoreNavigationHelper.cs index 39835569cb..077ff21d2b 100644 --- a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/ChangeTrackers/AbpEfCoreNavigationHelper.cs +++ b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/ChangeTrackers/AbpEfCoreNavigationHelper.cs @@ -222,9 +222,19 @@ public class AbpEfCoreNavigationHelper : ITransientDependency return null; } - public virtual void RemoveChangedEntityEntries() + public virtual void ResetChangedFlags() { - EntityEntries.RemoveAll(x => x.Value.IsModified); + foreach (var entry in EntityEntries.Values) + { + if (entry.IsModified) + { + entry.IsModified = false; + foreach (var navigation in entry.NavigationEntries) + { + navigation.IsModified = false; + } + } + } } public virtual void Clear() From 37d3a692d0364b6583175660fc14704a89b6ab4e Mon Sep 17 00:00:00 2001 From: maliming Date: Fri, 13 Feb 2026 10:27:40 +0800 Subject: [PATCH 2/2] Add tests for navigation change detection --- .../Abp/TestApp/Testing/EntityChange_Tests.cs | 112 ++++++++++++++++++ 1 file changed, 112 insertions(+) diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityChange_Tests.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityChange_Tests.cs index adb80c89d7..538fa45778 100644 --- a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityChange_Tests.cs +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityChange_Tests.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using Volo.Abp.Domain.Repositories; using Volo.Abp.Modularity; using Volo.Abp.TestApp.Domain; +using Volo.Abp.Uow; using Xunit; namespace Volo.Abp.TestApp.Testing; @@ -100,6 +102,116 @@ public abstract class EntityChange_Tests : TestAppTestBase() + { + new AppEntityWithNavigationChildOneToMany(child1Id) + { + AppEntityWithNavigationId = entityId, + ChildName = "Child1" + } + } + }); + + var unitOfWorkManager = ServiceProvider.GetRequiredService(); + + string concurrencyStampAfterFirstSave = null; + + // Within a single UoW, remove Child1 (first SaveChanges), then add Child2 (second SaveChanges). + // Before the fix, the second SaveChanges would not detect the navigation change + // because the entity entries were removed after the first SaveChanges. + await WithUnitOfWorkAsync(async () => + { + var entity = await AppEntityWithNavigationsRepository.GetAsync(entityId); + var originalConcurrencyStamp = entity.ConcurrencyStamp; + + // Remove Child1 + entity.OneToMany.Clear(); + await AppEntityWithNavigationsRepository.UpdateAsync(entity); + await unitOfWorkManager.Current!.SaveChangesAsync(); + + // ConcurrencyStamp should have been updated after the first navigation change + entity.ConcurrencyStamp.ShouldNotBe(originalConcurrencyStamp); + concurrencyStampAfterFirstSave = entity.ConcurrencyStamp; + + // Add Child2 + entity.OneToMany.Add(new AppEntityWithNavigationChildOneToMany(Guid.NewGuid()) + { + AppEntityWithNavigationId = entityId, + ChildName = "Child2" + }); + await AppEntityWithNavigationsRepository.UpdateAsync(entity); + }); + + // After UoW completes, verify ConcurrencyStamp was updated again by the second SaveChanges + await WithUnitOfWorkAsync(async () => + { + var entity = await AppEntityWithNavigationsRepository.GetAsync(entityId); + entity.ConcurrencyStamp.ShouldNotBe(concurrencyStampAfterFirstSave); + entity.OneToMany.Count.ShouldBe(1); + entity.OneToMany[0].ChildName.ShouldBe("Child2"); + }); + } + + [Fact] + public async Task Should_Detect_Navigation_Changes_On_Second_SaveChanges_After_Remove_And_Add_ManyToMany() + { + var entityId = Guid.NewGuid(); + + await AppEntityWithNavigationsRepository.InsertAsync(new AppEntityWithNavigations(entityId, "TestEntity") + { + ManyToMany = new List() + { + new AppEntityWithNavigationChildManyToMany + { + ChildName = "ManyToManyChild1" + } + } + }); + + var unitOfWorkManager = ServiceProvider.GetRequiredService(); + + string concurrencyStampAfterFirstSave = null; + // Within a single UoW, remove ManyToManyChild1 (first SaveChanges), then add ManyToManyChild2 (second SaveChanges). + await WithUnitOfWorkAsync(async () => + { + var entity = await AppEntityWithNavigationsRepository.GetAsync(entityId); + var originalConcurrencyStamp = entity.ConcurrencyStamp; + + // Remove ManyToManyChild1 + entity.ManyToMany.Clear(); + await AppEntityWithNavigationsRepository.UpdateAsync(entity); + await unitOfWorkManager.Current!.SaveChangesAsync(); + + // ConcurrencyStamp should have been updated after the first navigation change + entity.ConcurrencyStamp.ShouldNotBe(originalConcurrencyStamp); + concurrencyStampAfterFirstSave = entity.ConcurrencyStamp; + + // Add ManyToManyChild2 + entity.ManyToMany.Add(new AppEntityWithNavigationChildManyToMany + { + ChildName = "ManyToManyChild2" + }); + await AppEntityWithNavigationsRepository.UpdateAsync(entity); + }); + + // After UoW completes, verify ConcurrencyStamp was updated again by the second SaveChanges + await WithUnitOfWorkAsync(async () => + { + var entity = await AppEntityWithNavigationsRepository.GetAsync(entityId); + entity.ConcurrencyStamp.ShouldNotBe(concurrencyStampAfterFirstSave); + entity.ManyToMany.Count.ShouldBe(1); + entity.ManyToMany[0].ChildName.ShouldBe("ManyToManyChild2"); + }); } }