diff --git a/docs/en/release-info/migration-guides/abp-10-7.md b/docs/en/release-info/migration-guides/abp-10-7.md index 5a6dc033c3..7337697f4d 100644 --- a/docs/en/release-info/migration-guides/abp-10-7.md +++ b/docs/en/release-info/migration-guides/abp-10-7.md @@ -116,6 +116,24 @@ If you change transforming pipeline contributors or an encryption passphrase, re > See the [BLOB Encryption](../../framework/infrastructure/blob-storing/encryption.md) and [BLOB Content Pipeline](../../framework/infrastructure/blob-storing/pipeline.md) documents and [#25836](https://github.com/abpframework/abp/pull/25836) for details. +### Aggregate Root Update on Foreign Key Only Relations + +**Who is affected** + +- Applications with a relation that has no navigation property on the principal side, such as `HasOne().WithMany()`, where the principal entity is tracked while one of its dependents is added, modified or deleted. + +**What changed** + +- ABP treated any dependent change as a navigation change of the principal, so the principal was updated (`ConcurrencyStamp`, audit properties) and its entity updated event was published, even if the principal had no navigation for that relation. Concurrent changes on such dependents conflicted on the principal row and failed with `AbpDbConcurrencyException`. +- The principal is now updated only when it really has a navigation property for the changed relation. Relations with navigation properties keep their current behavior. +- `IUnitOfWork.DisableUpdateAggregateRootWhenNavigationChanges()` is added to disable updating the aggregate root for a unit of work, without disabling its entity updated event. It overrides the `AbpEntityChangeOptions.UpdateAggregateRootWhenNavigationChanges` option. + +**What to do** + +Add a navigation property to the principal entity if you rely on the previous behavior, or update the principal in your own code. + +> See [#25937](https://github.com/abpframework/abp/pull/25937) for details. + ### Dependency Updates **Who is affected** diff --git a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Events/EntityChangeUnitOfWorkExtensions.cs b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Events/EntityChangeUnitOfWorkExtensions.cs new file mode 100644 index 0000000000..c23f8de2ce --- /dev/null +++ b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Events/EntityChangeUnitOfWorkExtensions.cs @@ -0,0 +1,74 @@ +using System; +using JetBrains.Annotations; +using Volo.Abp.Uow; + +namespace Volo.Abp.Domain.Entities.Events; + +public static class EntityChangeUnitOfWorkExtensions +{ + private const string UpdateAggregateRootWhenNavigationChangesItemKey = "Abp.UpdateAggregateRootWhenNavigationChanges"; + + /// + /// Disables updating the aggregate root when one of its navigation properties changes, + /// for the given unit of work. It overrides the option. + /// It doesn't disable publishing the entity updated event, which is still controlled by the + /// option + /// and the . + /// + /// A unit of work object + /// + /// A disposable object. Dispose it to restore the setting back to its previous state. + /// + public static IDisposable DisableUpdateAggregateRootWhenNavigationChanges([NotNull] this IUnitOfWork unitOfWork) + { + return SetUpdateAggregateRootWhenNavigationChanges(unitOfWork, false); + } + + /// + /// Enables updating the aggregate root when one of its navigation properties changes, + /// for the given unit of work. It overrides the option. + /// + /// A unit of work object + /// + /// A disposable object. Dispose it to restore the setting back to its previous state. + /// + public static IDisposable EnableUpdateAggregateRootWhenNavigationChanges([NotNull] this IUnitOfWork unitOfWork) + { + return SetUpdateAggregateRootWhenNavigationChanges(unitOfWork, true); + } + + /// + /// Returns the value set for the given unit of work, or null when it was not set. + /// The option is used when it is null. + /// + public static bool? GetUpdateAggregateRootWhenNavigationChangesOrNull([NotNull] this IUnitOfWork unitOfWork) + { + Check.NotNull(unitOfWork, nameof(unitOfWork)); + + return unitOfWork.Items.TryGetValue(UpdateAggregateRootWhenNavigationChangesItemKey, out var value) + ? value as bool? + : null; + } + + private static IDisposable SetUpdateAggregateRootWhenNavigationChanges(IUnitOfWork unitOfWork, bool value) + { + Check.NotNull(unitOfWork, nameof(unitOfWork)); + + // Items of a child unit of work is the same instance with its parent, + // so the previous value is restored on dispose to not leak into the outer unit of work. + var previousValue = unitOfWork.GetUpdateAggregateRootWhenNavigationChangesOrNull(); + unitOfWork.Items[UpdateAggregateRootWhenNavigationChangesItemKey] = value; + + return new DisposeAction(() => + { + if (previousValue == null) + { + unitOfWork.Items.Remove(UpdateAggregateRootWhenNavigationChangesItemKey); + } + else + { + unitOfWork.Items[UpdateAggregateRootWhenNavigationChangesItemKey] = previousValue.Value; + } + }); + } +} 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 da1a47d779..a4aa3a510a 100644 --- a/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs +++ b/framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs @@ -284,7 +284,7 @@ public abstract class AbpDbContext : DbContext, IAbpEfCoreDbContext, continue; } - if (EntityChangeOptions.Value.UpdateAggregateRootWhenNavigationChanges && + if (IsUpdateAggregateRootWhenNavigationChangesEnabled() && EntityChangeOptions.Value.IgnoredUpdateAggregateRootSelectors.All(selector => !selector.Predicate(entityEntry.Entity.GetType())) && entityEntry.State == EntityState.Unchanged) { @@ -467,7 +467,7 @@ public abstract class AbpDbContext : DbContext, IAbpEfCoreDbContext, EntityChangeOptions.Value.IgnoredNavigationEntitySelectors.All(selector => !selector.Predicate(entry.Entity.GetType())) && AbpEfCoreNavigationHelper.IsNavigationEntryModified(entry)) { - if (EntityChangeOptions.Value.UpdateAggregateRootWhenNavigationChanges && + if (IsUpdateAggregateRootWhenNavigationChangesEnabled() && EntityChangeOptions.Value.IgnoredUpdateAggregateRootSelectors.All(selector => !selector.Predicate(entry.Entity.GetType()))) { ApplyAbpConceptsForModifiedEntity(entry, true); @@ -524,6 +524,12 @@ public abstract class AbpDbContext : DbContext, IAbpEfCoreDbContext, (x.CurrentValue == null || x.OriginalValue?.ToString() == x.CurrentValue?.ToString())); } + protected virtual bool IsUpdateAggregateRootWhenNavigationChangesEnabled() + { + return UnitOfWorkManager.Current?.GetUpdateAggregateRootWhenNavigationChangesOrNull() ?? + EntityChangeOptions.Value.UpdateAggregateRootWhenNavigationChanges; + } + protected virtual void HandlePropertiesBeforeSave() { var entries = ChangeTracker.Entries().ToList(); @@ -538,7 +544,7 @@ public abstract class AbpDbContext : DbContext, IAbpEfCoreDbContext, } if (EntityChangeOptions.Value.PublishEntityUpdatedEventWhenNavigationChanges && - EntityChangeOptions.Value.UpdateAggregateRootWhenNavigationChanges) + IsUpdateAggregateRootWhenNavigationChangesEnabled()) { foreach (var entry in AbpEfCoreNavigationHelper.GetChangedEntityEntries() .Where(x => x.State == EntityState.Unchanged) 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 077ff21d2b..9db4ffa810 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 @@ -83,7 +83,8 @@ public class AbpEfCoreNavigationHelper : ITransientDependency abpEntityEntry.UpdateNavigation(entityEntry, navigationEntry); } - if (!abpEntityEntry.IsModified && (!checkEntityEntryState || IsEntityEntryChanged(entityEntry))) + // A principal without a navigation to the dependent has no navigation change to report. + if (navigationEntry != null && !abpEntityEntry.IsModified && (!checkEntityEntryState || IsEntityEntryChanged(entityEntry))) { abpEntityEntry.IsModified = true; DetectChanges(abpEntityEntry.EntityEntry, false); @@ -128,7 +129,7 @@ public class AbpEfCoreNavigationHelper : ITransientDependency abpEntityEntry.UpdateNavigation(entityEntry, navigationEntry); } - if (!abpEntityEntry.IsModified && (!checkEntityEntryState || IsEntityEntryChanged(entityEntry))) + if (navigationEntry != null && !abpEntityEntry.IsModified && (!checkEntityEntryState || IsEntityEntryChanged(entityEntry))) { abpEntityEntry.IsModified = true; DetectChanges(abpEntityEntry.EntityEntry, false); diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DomainEvents/DomainEvents_Tests.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DomainEvents/DomainEvents_Tests.cs index 2967e15f16..39b914755e 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DomainEvents/DomainEvents_Tests.cs +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DomainEvents/DomainEvents_Tests.cs @@ -569,11 +569,13 @@ public class AbpEfCoreDomainEvents_IgnoredUpdateAggregateRootSelectors_Test : A { protected override void AfterAddApplication(IServiceCollection services) { + base.AfterAddApplication(services); + services.Configure(options => { - options.IgnoredUpdateAggregateRootSelectors.Add("AppEntityWithValueObjectAddress", x => x == typeof(AppEntityWithNavigations)); + // The base class disables it for all entities, the selector has to be the only reason of the expected behavior. + options.UpdateAggregateRootWhenNavigationChanges = true; + options.IgnoredUpdateAggregateRootSelectors.Add("AppEntityWithNavigations", x => x == typeof(AppEntityWithNavigations)); }); - - base.AfterAddApplication(services); } } diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DomainEvents/UpdateAggregateRootWhenNavigationChanges_Tests.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DomainEvents/UpdateAggregateRootWhenNavigationChanges_Tests.cs new file mode 100644 index 0000000000..8114288d71 --- /dev/null +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DomainEvents/UpdateAggregateRootWhenNavigationChanges_Tests.cs @@ -0,0 +1,394 @@ +using System; +using Microsoft.Extensions.DependencyInjection; +using System.Threading.Tasks; +using Shouldly; +using Volo.Abp.Domain.Entities.Events; +using Volo.Abp.Domain.Repositories; +using Volo.Abp.EventBus.Local; +using Volo.Abp.TestApp.Domain; +using Volo.Abp.Uow; +using Xunit; + +namespace Volo.Abp.EntityFrameworkCore.DomainEvents; + +public class UpdateAggregateRootWhenNavigationChanges_Tests : EntityFrameworkCoreTestBase +{ + private readonly IRepository _entityWithForeignKeyOnlyRepository; + private readonly IRepository _childRepository; + private readonly IRepository _ownerRepository; + private readonly IRepository _entityChildRepository; + private readonly IRepository _entityWithNavigationsRepository; + private readonly IRepository _entityWithNavigationsForeignRepository; + private readonly IUnitOfWorkManager _unitOfWorkManager; + private readonly ILocalEventBus _localEventBus; + + public UpdateAggregateRootWhenNavigationChanges_Tests() + { + _entityWithForeignKeyOnlyRepository = GetRequiredService>(); + _childRepository = GetRequiredService>(); + _ownerRepository = GetRequiredService>(); + _entityChildRepository = GetRequiredService>(); + _entityWithNavigationsRepository = GetRequiredService>(); + _entityWithNavigationsForeignRepository = GetRequiredService>(); + _unitOfWorkManager = GetRequiredService(); + _localEventBus = GetRequiredService(); + } + + [Fact] + public async Task Should_Not_Update_Principal_Entity_Without_Navigation_Property() + { + var principalId = Guid.NewGuid(); + + await WithUnitOfWorkAsync(async () => + { + await _entityWithForeignKeyOnlyRepository.InsertAsync( + new AppEntityWithForeignKeyOnly(principalId, "Principal")); + }); + + var concurrencyStamp = (await _entityWithForeignKeyOnlyRepository.GetAsync(principalId)).ConcurrencyStamp; + + var principalUpdatedEventTriggered = false; + _localEventBus.Subscribe>(_ => + { + principalUpdatedEventTriggered = true; + return Task.CompletedTask; + }); + + await WithUnitOfWorkAsync(async () => + { + // The principal has to be tracked to be a candidate for the aggregate root update. + await _entityWithForeignKeyOnlyRepository.GetAsync(principalId); + + await _childRepository.InsertAsync( + new AppEntityWithForeignKeyOnlyChild(Guid.NewGuid(), principalId, "Child")); + }); + + principalUpdatedEventTriggered.ShouldBeFalse(); + (await _entityWithForeignKeyOnlyRepository.GetAsync(principalId)).ConcurrencyStamp.ShouldBe(concurrencyStamp); + } + + [Fact] + public async Task Should_Not_Update_Principal_Entity_Without_Navigation_Property_On_Update_And_Delete() + { + var principalId = Guid.NewGuid(); + var childId = Guid.NewGuid(); + + await WithUnitOfWorkAsync(async () => + { + await _entityWithForeignKeyOnlyRepository.InsertAsync( + new AppEntityWithForeignKeyOnly(principalId, "Principal")); + await _childRepository.InsertAsync( + new AppEntityWithForeignKeyOnlyChild(childId, principalId, "Child")); + }); + + var concurrencyStamp = (await _entityWithForeignKeyOnlyRepository.GetAsync(principalId)).ConcurrencyStamp; + + await WithUnitOfWorkAsync(async () => + { + await _entityWithForeignKeyOnlyRepository.GetAsync(principalId); + + var child = await _childRepository.GetAsync(childId); + child.Name = "Child-Updated"; + await _childRepository.UpdateAsync(child); + }); + + (await _entityWithForeignKeyOnlyRepository.GetAsync(principalId)).ConcurrencyStamp.ShouldBe(concurrencyStamp); + + await WithUnitOfWorkAsync(async () => + { + await _entityWithForeignKeyOnlyRepository.GetAsync(principalId); + + await _childRepository.DeleteAsync(childId); + }); + + (await _entityWithForeignKeyOnlyRepository.GetAsync(principalId)).ConcurrencyStamp.ShouldBe(concurrencyStamp); + } + + [Fact] + public async Task Should_Update_The_Owner_But_Not_The_Referenced_Aggregate_Root_Of_A_Child_Entity() + { + var ownerId = Guid.NewGuid(); + var referencedId = Guid.NewGuid(); + + await WithUnitOfWorkAsync(async () => + { + await _ownerRepository.InsertAsync(new AppEntityWithForeignKeyOnlyOwner(ownerId, "Owner")); + await _entityWithForeignKeyOnlyRepository.InsertAsync( + new AppEntityWithForeignKeyOnly(referencedId, "Referenced")); + }); + + var ownerStamp = (await _ownerRepository.GetAsync(ownerId)).ConcurrencyStamp; + var referencedStamp = (await _entityWithForeignKeyOnlyRepository.GetAsync(referencedId)).ConcurrencyStamp; + + await WithUnitOfWorkAsync(async () => + { + await _ownerRepository.GetAsync(ownerId); + await _entityWithForeignKeyOnlyRepository.GetAsync(referencedId); + + await _entityChildRepository.InsertAsync( + new AppEntityWithForeignKeyOnlyEntityChild(Guid.NewGuid(), ownerId, referencedId, "Child")); + }); + + (await _ownerRepository.GetAsync(ownerId)).ConcurrencyStamp.ShouldNotBe(ownerStamp); + (await _entityWithForeignKeyOnlyRepository.GetAsync(referencedId)).ConcurrencyStamp.ShouldBe(referencedStamp); + } + + [Fact] + public async Task Should_Update_Aggregate_Root_When_Owned_Entity_Changes() + { + var entityId = Guid.NewGuid(); + + await WithUnitOfWorkAsync(async () => + { + await _entityWithNavigationsRepository.InsertAsync( + new AppEntityWithNavigations(entityId, "Entity")); + }); + + var concurrencyStamp = (await _entityWithNavigationsRepository.GetAsync(entityId)).ConcurrencyStamp; + + await WithUnitOfWorkAsync(async () => + { + var entity = await _entityWithNavigationsRepository.GetAsync(entityId); + entity.AppEntityWithValueObjectAddress = new AppEntityWithValueObjectAddress("Turkey"); + await _entityWithNavigationsRepository.UpdateAsync(entity); + }); + + (await _entityWithNavigationsRepository.GetAsync(entityId)).ConcurrencyStamp.ShouldNotBe(concurrencyStamp); + } + + [Fact] + public async Task Should_Update_Aggregate_Root_When_Navigation_Changes_By_Default() + { + var entityId = Guid.NewGuid(); + var foreignId = Guid.NewGuid(); + + await WithUnitOfWorkAsync(async () => + { + await _entityWithNavigationsForeignRepository.InsertAsync( + new AppEntityWithNavigationsForeign(foreignId, "Foreign")); + await _entityWithNavigationsRepository.InsertAsync( + new AppEntityWithNavigations(entityId, "Entity")); + }); + + var concurrencyStamp = (await _entityWithNavigationsForeignRepository.GetAsync(foreignId)).ConcurrencyStamp; + + var foreignUpdatedEventTriggered = false; + _localEventBus.Subscribe>(_ => + { + foreignUpdatedEventTriggered = true; + return Task.CompletedTask; + }); + + await WithUnitOfWorkAsync(async () => + { + await _entityWithNavigationsForeignRepository.GetAsync(foreignId); + + var entity = await _entityWithNavigationsRepository.GetAsync(entityId); + entity.AppEntityWithNavigationForeignId = foreignId; + await _entityWithNavigationsRepository.UpdateAsync(entity); + }); + + foreignUpdatedEventTriggered.ShouldBeTrue(); + (await _entityWithNavigationsForeignRepository.GetAsync(foreignId)).ConcurrencyStamp.ShouldNotBe(concurrencyStamp); + } + + [Fact] + public async Task Should_Not_Update_Aggregate_Root_But_Still_Publish_Event_When_Disabled_For_The_Unit_Of_Work() + { + var entityId = Guid.NewGuid(); + var foreignId = Guid.NewGuid(); + + await WithUnitOfWorkAsync(async () => + { + await _entityWithNavigationsForeignRepository.InsertAsync( + new AppEntityWithNavigationsForeign(foreignId, "Foreign")); + await _entityWithNavigationsRepository.InsertAsync( + new AppEntityWithNavigations(entityId, "Entity")); + }); + + var concurrencyStamp = (await _entityWithNavigationsForeignRepository.GetAsync(foreignId)).ConcurrencyStamp; + + var foreignUpdatedEventTriggered = false; + _localEventBus.Subscribe>(_ => + { + foreignUpdatedEventTriggered = true; + return Task.CompletedTask; + }); + + using (var uow = _unitOfWorkManager.Begin(requiresNew: true)) + { + using (uow.DisableUpdateAggregateRootWhenNavigationChanges()) + { + await _entityWithNavigationsForeignRepository.GetAsync(foreignId); + + var entity = await _entityWithNavigationsRepository.GetAsync(entityId); + entity.AppEntityWithNavigationForeignId = foreignId; + await _entityWithNavigationsRepository.UpdateAsync(entity); + + await uow.CompleteAsync(); + } + } + + foreignUpdatedEventTriggered.ShouldBeTrue(); + (await _entityWithNavigationsForeignRepository.GetAsync(foreignId)).ConcurrencyStamp.ShouldBe(concurrencyStamp); + } + + [Fact] + public async Task Should_Update_Aggregate_Root_Of_Its_Own_Changes_While_Disabled() + { + var entityId = Guid.NewGuid(); + var foreignId = Guid.NewGuid(); + + await WithUnitOfWorkAsync(async () => + { + await _entityWithNavigationsForeignRepository.InsertAsync( + new AppEntityWithNavigationsForeign(foreignId, "Foreign")); + await _entityWithNavigationsRepository.InsertAsync( + new AppEntityWithNavigations(entityId, "Entity")); + }); + + var concurrencyStamp = (await _entityWithNavigationsForeignRepository.GetAsync(foreignId)).ConcurrencyStamp; + + using (var uow = _unitOfWorkManager.Begin(requiresNew: true)) + { + using (uow.DisableUpdateAggregateRootWhenNavigationChanges()) + { + // The foreign entity is changed by itself, not by a navigation change. + var foreign = await _entityWithNavigationsForeignRepository.GetAsync(foreignId); + foreign.Name = "Foreign-Updated"; + await _entityWithNavigationsForeignRepository.UpdateAsync(foreign); + + await uow.CompleteAsync(); + } + } + + var updatedForeign = await _entityWithNavigationsForeignRepository.GetAsync(foreignId); + updatedForeign.Name.ShouldBe("Foreign-Updated"); + updatedForeign.ConcurrencyStamp.ShouldNotBe(concurrencyStamp); + } + + [Fact] + public async Task Should_Use_The_Current_Value_On_Each_Save_Changes_Of_The_Same_Unit_Of_Work() + { + var firstEntityId = Guid.NewGuid(); + var secondEntityId = Guid.NewGuid(); + var foreignId = Guid.NewGuid(); + + await WithUnitOfWorkAsync(async () => + { + await _entityWithNavigationsForeignRepository.InsertAsync( + new AppEntityWithNavigationsForeign(foreignId, "Foreign")); + await _entityWithNavigationsRepository.InsertAsync( + new AppEntityWithNavigations(firstEntityId, "Entity1")); + await _entityWithNavigationsRepository.InsertAsync( + new AppEntityWithNavigations(secondEntityId, "Entity2")); + }); + + var concurrencyStamp = (await _entityWithNavigationsForeignRepository.GetAsync(foreignId)).ConcurrencyStamp; + + using (var uow = _unitOfWorkManager.Begin(requiresNew: true)) + { + using (uow.DisableUpdateAggregateRootWhenNavigationChanges()) + { + await _entityWithNavigationsForeignRepository.GetAsync(foreignId); + + var firstEntity = await _entityWithNavigationsRepository.GetAsync(firstEntityId); + firstEntity.AppEntityWithNavigationForeignId = foreignId; + await _entityWithNavigationsRepository.UpdateAsync(firstEntity, autoSave: true); + } + + (await _entityWithNavigationsForeignRepository.GetAsync(foreignId)).ConcurrencyStamp.ShouldBe(concurrencyStamp); + + // The setting is restored, the second save has to update the aggregate root. + var secondEntity = await _entityWithNavigationsRepository.GetAsync(secondEntityId); + secondEntity.AppEntityWithNavigationForeignId = foreignId; + await _entityWithNavigationsRepository.UpdateAsync(secondEntity, autoSave: true); + + await uow.CompleteAsync(); + } + + (await _entityWithNavigationsForeignRepository.GetAsync(foreignId)).ConcurrencyStamp.ShouldNotBe(concurrencyStamp); + } + + [Fact] + public async Task Should_Restore_The_Previous_Value_On_Dispose() + { + using (var uow = _unitOfWorkManager.Begin(requiresNew: true)) + { + uow.GetUpdateAggregateRootWhenNavigationChangesOrNull().ShouldBeNull(); + + using (uow.DisableUpdateAggregateRootWhenNavigationChanges()) + { + uow.GetUpdateAggregateRootWhenNavigationChangesOrNull().ShouldBe(false); + + using (uow.EnableUpdateAggregateRootWhenNavigationChanges()) + { + uow.GetUpdateAggregateRootWhenNavigationChangesOrNull().ShouldBe(true); + } + + uow.GetUpdateAggregateRootWhenNavigationChangesOrNull().ShouldBe(false); + } + + uow.GetUpdateAggregateRootWhenNavigationChangesOrNull().ShouldBeNull(); + + await uow.CompleteAsync(); + } + } +} + +public class UpdateAggregateRootWhenNavigationChanges_Globally_Disabled_Tests : EntityFrameworkCoreTestBase +{ + private readonly IRepository _entityWithNavigationsRepository; + private readonly IRepository _entityWithNavigationsForeignRepository; + private readonly IUnitOfWorkManager _unitOfWorkManager; + + public UpdateAggregateRootWhenNavigationChanges_Globally_Disabled_Tests() + { + _entityWithNavigationsRepository = GetRequiredService>(); + _entityWithNavigationsForeignRepository = GetRequiredService>(); + _unitOfWorkManager = GetRequiredService(); + } + + protected override void AfterAddApplication(IServiceCollection services) + { + services.Configure(options => + { + options.UpdateAggregateRootWhenNavigationChanges = false; + }); + + base.AfterAddApplication(services); + } + + [Fact] + public async Task Should_Update_Aggregate_Root_When_Enabled_For_The_Unit_Of_Work() + { + var entityId = Guid.NewGuid(); + var foreignId = Guid.NewGuid(); + + await WithUnitOfWorkAsync(async () => + { + await _entityWithNavigationsForeignRepository.InsertAsync( + new AppEntityWithNavigationsForeign(foreignId, "Foreign")); + await _entityWithNavigationsRepository.InsertAsync( + new AppEntityWithNavigations(entityId, "Entity")); + }); + + var concurrencyStamp = (await _entityWithNavigationsForeignRepository.GetAsync(foreignId)).ConcurrencyStamp; + + using (var uow = _unitOfWorkManager.Begin(requiresNew: true)) + { + using (uow.EnableUpdateAggregateRootWhenNavigationChanges()) + { + await _entityWithNavigationsForeignRepository.GetAsync(foreignId); + + var entity = await _entityWithNavigationsRepository.GetAsync(entityId); + entity.AppEntityWithNavigationForeignId = foreignId; + await _entityWithNavigationsRepository.UpdateAsync(entity); + + await uow.CompleteAsync(); + } + } + + (await _entityWithNavigationsForeignRepository.GetAsync(foreignId)).ConcurrencyStamp.ShouldNotBe(concurrencyStamp); + } +} diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/TestMigrationsDbContext.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/TestMigrationsDbContext.cs index fd9831690e..b90a114195 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/TestMigrationsDbContext.cs +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/TestMigrationsDbContext.cs @@ -38,6 +38,14 @@ public class TestMigrationsDbContext : AbpDbContext public DbSet AppEntityWithNavigationsForeign { get; set; } + public DbSet AppEntityWithForeignKeyOnly { get; set; } + + public DbSet AppEntityWithForeignKeyOnlyChild { get; set; } + + public DbSet AppEntityWithForeignKeyOnlyOwner { get; set; } + + public DbSet AppEntityWithForeignKeyOnlyEntityChild { get; set; } + public DbSet Blogs { get; set; } public DbSet BlogPosts { get; set; } @@ -162,6 +170,31 @@ public class TestMigrationsDbContext : AbpDbContext b.ConfigureByConvention(); }); + modelBuilder.Entity(b => + { + b.ConfigureByConvention(); + }); + + modelBuilder.Entity(b => + { + b.ConfigureByConvention(); + // No navigation property on both sides, only a foreign key. + b.HasOne().WithMany().HasForeignKey(x => x.AppEntityWithForeignKeyOnlyId); + }); + + modelBuilder.Entity(b => + { + b.ConfigureByConvention(); + b.HasMany(x => x.Children).WithOne().HasForeignKey(x => x.OwnerId); + }); + + modelBuilder.Entity(b => + { + b.ConfigureByConvention(); + // The owner has a navigation, the referenced aggregate root has not. + b.HasOne().WithMany().HasForeignKey(x => x.AppEntityWithForeignKeyOnlyId); + }); + modelBuilder.Entity(b => { b.ConfigureByConvention(); diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/TestApp/EntityFrameworkCore/TestAppDbContext.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/TestApp/EntityFrameworkCore/TestAppDbContext.cs index 8f4e957e2a..18a99a8934 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/TestApp/EntityFrameworkCore/TestAppDbContext.cs +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/TestApp/EntityFrameworkCore/TestAppDbContext.cs @@ -46,6 +46,14 @@ public class TestAppDbContext : AbpDbContext, IThirdDbContext, public DbSet AppEntityWithNavigationsForeign { get; set; } + public DbSet AppEntityWithForeignKeyOnly { get; set; } + + public DbSet AppEntityWithForeignKeyOnlyChild { get; set; } + + public DbSet AppEntityWithForeignKeyOnlyOwner { get; set; } + + public DbSet AppEntityWithForeignKeyOnlyEntityChild { get; set; } + public DbSet Blogs { get; set; } public DbSet BlogPosts { get; set; } @@ -161,6 +169,31 @@ public class TestAppDbContext : AbpDbContext, IThirdDbContext, b.HasMany(x => x.OneToMany).WithOne().HasForeignKey(x => x.AppEntityWithNavigationForeignId); }); + modelBuilder.Entity(b => + { + b.ConfigureByConvention(); + }); + + modelBuilder.Entity(b => + { + b.ConfigureByConvention(); + // No navigation property on both sides, only a foreign key. + b.HasOne().WithMany().HasForeignKey(x => x.AppEntityWithForeignKeyOnlyId); + }); + + modelBuilder.Entity(b => + { + b.ConfigureByConvention(); + b.HasMany(x => x.Children).WithOne().HasForeignKey(x => x.OwnerId); + }); + + modelBuilder.Entity(b => + { + b.ConfigureByConvention(); + // The owner has a navigation, the referenced aggregate root has not. + b.HasOne().WithMany().HasForeignKey(x => x.AppEntityWithForeignKeyOnlyId); + }); + modelBuilder.Entity(b => { b.ConfigureByConvention(); diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Domain/AppEntityWithNavigations.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Domain/AppEntityWithNavigations.cs index c3ab4466a6..923bc4f96a 100644 --- a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Domain/AppEntityWithNavigations.cs +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Domain/AppEntityWithNavigations.cs @@ -121,3 +121,86 @@ public class AppEntityWithNavigationsForeign : AggregateRoot public virtual List OneToMany { get; set; } } + +/// +/// Has no navigation property to , +/// the relation is only a foreign key on the child. +/// +public class AppEntityWithForeignKeyOnly : AggregateRoot +{ + protected AppEntityWithForeignKeyOnly() + { + + } + + public AppEntityWithForeignKeyOnly(Guid id, string name) + : base(id) + { + Name = name; + } + + public string Name { get; set; } +} + +public class AppEntityWithForeignKeyOnlyChild : AggregateRoot +{ + protected AppEntityWithForeignKeyOnlyChild() + { + + } + + public AppEntityWithForeignKeyOnlyChild(Guid id, Guid appEntityWithForeignKeyOnlyId, string name) + : base(id) + { + AppEntityWithForeignKeyOnlyId = appEntityWithForeignKeyOnlyId; + Name = name; + } + + public Guid AppEntityWithForeignKeyOnlyId { get; set; } + + public string Name { get; set; } +} + +public class AppEntityWithForeignKeyOnlyOwner : AggregateRoot +{ + protected AppEntityWithForeignKeyOnlyOwner() + { + + } + + public AppEntityWithForeignKeyOnlyOwner(Guid id, string name) + : base(id) + { + Name = name; + } + + public string Name { get; set; } + + public virtual List Children { get; set; } +} + +/// +/// Belongs to the aggregate, +/// but references the aggregate root with a foreign key only. +/// +public class AppEntityWithForeignKeyOnlyEntityChild : Entity +{ + protected AppEntityWithForeignKeyOnlyEntityChild() + { + + } + + public AppEntityWithForeignKeyOnlyEntityChild(Guid id, Guid ownerId, Guid appEntityWithForeignKeyOnlyId, string name) + : base(id) + { + OwnerId = ownerId; + AppEntityWithForeignKeyOnlyId = appEntityWithForeignKeyOnlyId; + Name = name; + } + + public Guid OwnerId { get; set; } + + public Guid AppEntityWithForeignKeyOnlyId { get; set; } + + public string Name { get; set; } +}