diff --git a/framework/src/Volo.Abp.Core/Volo/Abp/Reflection/TypeHelper.cs b/framework/src/Volo.Abp.Core/Volo/Abp/Reflection/TypeHelper.cs index 83182fc965..83c2c4c38e 100644 --- a/framework/src/Volo.Abp.Core/Volo/Abp/Reflection/TypeHelper.cs +++ b/framework/src/Volo.Abp.Core/Volo/Abp/Reflection/TypeHelper.cs @@ -359,5 +359,15 @@ namespace Volo.Abp.Reflection ? type.GenericTypeArguments[0] : type; } + + public static bool IsDefaultValue([CanBeNull] object obj) + { + if (obj == null) + { + return true; + } + + return obj.Equals(GetDefaultValue(obj.GetType())); + } } } diff --git a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Entity.cs b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Entity.cs index b78a5aa400..cf1b643305 100644 --- a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Entity.cs +++ b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Entity.cs @@ -1,7 +1,5 @@ using System; using System.Collections.Generic; -using System.Reflection; -using Volo.Abp.MultiTenancy; namespace Volo.Abp.Domain.Entities { @@ -16,6 +14,11 @@ namespace Volo.Abp.Domain.Entities } public abstract object[] GetKeys(); + + public bool EntityEquals(IEntity other) + { + return EntityHelper.EntityEquals(this, other); + } } /// @@ -35,44 +38,6 @@ namespace Volo.Abp.Domain.Entities Id = id; } - public bool EntityEquals(object obj) - { - if (obj == null || !(obj is Entity)) - { - return false; - } - - //Same instances must be considered as equal - if (ReferenceEquals(this, obj)) - { - return true; - } - - //Transient objects are not considered as equal - var other = (Entity)obj; - if (EntityHelper.HasDefaultId(this) && EntityHelper.HasDefaultId(other)) - { - return false; - } - - //Must have a IS-A relation of types or must be same type - var typeOfThis = GetType().GetTypeInfo(); - var typeOfOther = other.GetType().GetTypeInfo(); - if (!typeOfThis.IsAssignableFrom(typeOfOther) && !typeOfOther.IsAssignableFrom(typeOfThis)) - { - return false; - } - - //Different tenants may have an entity with same Id. - if (this is IMultiTenant && other is IMultiTenant && - this.As().TenantId != other.As().TenantId) - { - return false; - } - - return Id.Equals(other.Id); - } - public override object[] GetKeys() { return new object[] {Id}; diff --git a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/EntityHelper.cs b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/EntityHelper.cs index ad082c3fb0..65f2992257 100644 --- a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/EntityHelper.cs +++ b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/EntityHelper.cs @@ -5,6 +5,8 @@ using System.Linq; using System.Linq.Expressions; using System.Reflection; using JetBrains.Annotations; +using Volo.Abp.MultiTenancy; +using Volo.Abp.Reflection; namespace Volo.Abp.Domain.Entities { @@ -16,6 +18,98 @@ namespace Volo.Abp.Domain.Entities private static readonly ConcurrentDictionary CachedIdProperties = new ConcurrentDictionary(); + public static bool EntityEquals(IEntity entity1, IEntity entity2) + { + if (entity1 == null || entity2 == null) + { + return false; + } + + //Same instances must be considered as equal + if (ReferenceEquals(entity1, entity2)) + { + return true; + } + + //Must have a IS-A relation of types or must be same type + var typeOfEntity1 = entity1.GetType(); + var typeOfEntity2 = entity2.GetType(); + if (!typeOfEntity1.IsAssignableFrom(typeOfEntity2) && !typeOfEntity2.IsAssignableFrom(typeOfEntity1)) + { + return false; + } + + //Different tenants may have an entity with same Id. + if (entity1 is IMultiTenant && entity2 is IMultiTenant) + { + var tenant1Id = ((IMultiTenant) entity1).TenantId; + var tenant2Id = ((IMultiTenant) entity2).TenantId; + + if (tenant1Id != tenant2Id) + { + if (tenant1Id == null || tenant2Id == null) + { + return false; + } + + if (!tenant1Id.Equals(tenant2Id)) + { + return false; + } + } + } + + //Transient objects are not considered as equal + if (HasDefaultKeys(entity1) && HasDefaultKeys(entity2)) + { + return false; + } + + var entity1Keys = entity1.GetKeys(); + var entity2Keys = entity2.GetKeys(); + + if (entity1Keys.Length != entity2Keys.Length) + { + return false; + } + + for (var i = 0; i < entity1Keys.Length; i++) + { + var entity1Key = entity1Keys[i]; + var entity2Key = entity2Keys[i]; + + if (entity1Key == null) + { + if (entity2Key == null) + { + //Both null, so considered as equals + continue; + } + + //entity2Key is not null! + return false; + } + + if (entity2Key == null) + { + //entity1Key was not null! + return false; + } + + if (TypeHelper.IsDefaultValue(entity1Key) && TypeHelper.IsDefaultValue(entity2Key)) + { + return false; + } + + if (!entity1Key.Equals(entity2Key)) + { + return false; + } + } + + return true; + } + public static bool IsEntity([NotNull] Type type) { return typeof(IEntity).IsAssignableFrom(type); @@ -25,7 +119,8 @@ namespace Volo.Abp.Domain.Entities { foreach (var interfaceType in type.GetInterfaces()) { - if (interfaceType.GetTypeInfo().IsGenericType && interfaceType.GetGenericTypeDefinition() == typeof(IEntity<>)) + if (interfaceType.GetTypeInfo().IsGenericType && + interfaceType.GetGenericTypeDefinition() == typeof(IEntity<>)) { return true; } @@ -55,6 +150,44 @@ namespace Volo.Abp.Domain.Entities return false; } + private static bool IsDefaultKeyValue(object value) + { + if (value == null) + { + return true; + } + + var type = value.GetType(); + + //Workaround for EF Core since it sets int/long to min value when attaching to DbContext + if (type == typeof(int)) + { + return Convert.ToInt32(value) <= 0; + } + + if (type == typeof(long)) + { + return Convert.ToInt64(value) <= 0; + } + + return TypeHelper.IsDefaultValue(value); + } + + public static bool HasDefaultKeys([NotNull] IEntity entity) + { + Check.NotNull(entity, nameof(entity)); + + foreach (var key in entity.GetKeys()) + { + if (!IsDefaultKeyValue(key)) + { + return false; + } + } + + return true; + } + /// /// Tries to find the primary key type of the given entity type. /// May return null if given type does not implement @@ -75,12 +208,14 @@ namespace Volo.Abp.Domain.Entities { if (!typeof(IEntity).IsAssignableFrom(entityType)) { - throw new AbpException($"Given {nameof(entityType)} is not an entity. It should implement {typeof(IEntity).AssemblyQualifiedName}!"); + throw new AbpException( + $"Given {nameof(entityType)} is not an entity. It should implement {typeof(IEntity).AssemblyQualifiedName}!"); } foreach (var interfaceType in entityType.GetTypeInfo().GetInterfaces()) { - if (interfaceType.GetTypeInfo().IsGenericType && interfaceType.GetGenericTypeDefinition() == typeof(IEntity<>)) + if (interfaceType.GetTypeInfo().IsGenericType && + interfaceType.GetGenericTypeDefinition() == typeof(IEntity<>)) { return interfaceType.GenericTypeArguments[0]; } @@ -132,4 +267,4 @@ namespace Volo.Abp.Domain.Entities property?.SetValue(entity, idFactory()); } } -} +} \ No newline at end of file diff --git a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Events/EntityChangeEventHelper.cs b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Events/EntityChangeEventHelper.cs index cad12b1450..5de3b22c77 100644 --- a/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Events/EntityChangeEventHelper.cs +++ b/framework/src/Volo.Abp.Ddd.Domain/Volo/Abp/Domain/Entities/Events/EntityChangeEventHelper.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Volo.Abp.Auditing; using Volo.Abp.DependencyInjection; @@ -18,6 +21,7 @@ namespace Volo.Abp.Domain.Entities.Events /// public class EntityChangeEventHelper : IEntityChangeEventHelper, ITransientDependency { + public ILogger Logger { get; set; } public ILocalEventBus LocalEventBus { get; set; } public IDistributedEventBus DistributedEventBus { get; set; } @@ -36,6 +40,7 @@ namespace Volo.Abp.Domain.Entities.Events LocalEventBus = NullLocalEventBus.Instance; DistributedEventBus = NullDistributedEventBus.Instance; + Logger = NullLogger.Instance; } public async Task TriggerEventsAsync(EntityChangeReport changeReport) @@ -56,6 +61,7 @@ namespace Volo.Abp.Domain.Entities.Events LocalEventBus, typeof(EntityCreatingEventData<>), entity, + entity, true ); } @@ -66,6 +72,7 @@ namespace Volo.Abp.Domain.Entities.Events LocalEventBus, typeof(EntityCreatedEventData<>), entity, + entity, false ); @@ -78,6 +85,7 @@ namespace Volo.Abp.Domain.Entities.Events DistributedEventBus, typeof(EntityCreatedEto<>), eto, + entity, false ); } @@ -101,6 +109,7 @@ namespace Volo.Abp.Domain.Entities.Events LocalEventBus, typeof(EntityUpdatingEventData<>), entity, + entity, true ); } @@ -111,6 +120,7 @@ namespace Volo.Abp.Domain.Entities.Events LocalEventBus, typeof(EntityUpdatedEventData<>), entity, + entity, false ); @@ -123,6 +133,7 @@ namespace Volo.Abp.Domain.Entities.Events DistributedEventBus, typeof(EntityUpdatedEto<>), eto, + entity, false ); } @@ -135,6 +146,7 @@ namespace Volo.Abp.Domain.Entities.Events LocalEventBus, typeof(EntityDeletingEventData<>), entity, + entity, true ); } @@ -145,6 +157,7 @@ namespace Volo.Abp.Domain.Entities.Events LocalEventBus, typeof(EntityDeletedEventData<>), entity, + entity, false ); @@ -157,6 +170,7 @@ namespace Volo.Abp.Domain.Entities.Events DistributedEventBus, typeof(EntityDeletedEto<>), eto, + entity, false ); } @@ -206,22 +220,129 @@ namespace Volo.Abp.Domain.Entities.Events { foreach (var distributedEvent in distributedEvents) { - await DistributedEventBus.PublishAsync(distributedEvent.EventData.GetType(), distributedEvent.EventData); + await DistributedEventBus.PublishAsync(distributedEvent.EventData.GetType(), + distributedEvent.EventData); } } - protected virtual async Task TriggerEventWithEntity(IEventBus eventPublisher, Type genericEventType, object entity, bool triggerInCurrentUnitOfWork) + protected virtual async Task TriggerEventWithEntity( + IEventBus eventPublisher, + Type genericEventType, + object entityOrEto, + object originalEntity, + bool triggerInCurrentUnitOfWork) { - var entityType = ProxyHelper.UnProxy(entity).GetType(); + var entityType = ProxyHelper.UnProxy(entityOrEto).GetType(); var eventType = genericEventType.MakeGenericType(entityType); + var currentUow = UnitOfWorkManager.Current; - if (triggerInCurrentUnitOfWork || UnitOfWorkManager.Current == null) + if (triggerInCurrentUnitOfWork || currentUow == null) { - await eventPublisher.PublishAsync(eventType, Activator.CreateInstance(eventType, entity)); + await eventPublisher.PublishAsync( + eventType, + Activator.CreateInstance(eventType, entityOrEto) + ); + return; } - UnitOfWorkManager.Current.OnCompleted(() => eventPublisher.PublishAsync(eventType, Activator.CreateInstance(eventType, entity))); + var eventList = GetEventList(currentUow); + var isFirstEvent = !eventList.Any(); + + eventList.AddUniqueEvent(eventPublisher, eventType, entityOrEto, originalEntity); + + /* Register to OnCompleted if this is the first item. + * Other items will already be in the list once the UOW completes. + */ + if (isFirstEvent) + { + currentUow.OnCompleted( + async () => + { + foreach (var eventEntry in eventList) + { + try + { + await eventPublisher.PublishAsync( + eventEntry.EventType, + Activator.CreateInstance(eventEntry.EventType, eventEntry.EntityOrEto) + ); + } + catch (Exception ex) + { + Logger.LogError( + $"Caught an exception while publishing the event '{eventType.FullName}' for the entity '{entityOrEto}'"); + Logger.LogException(ex); + } + } + } + ); + } + } + + private EntityChangeEventList GetEventList(IUnitOfWork currentUow) + { + return (EntityChangeEventList) currentUow.Items.GetOrAdd( + "AbpEntityChangeEventList", + () => new EntityChangeEventList() + ); + } + + private class EntityChangeEventList : List + { + public void AddUniqueEvent(IEventBus eventBus, Type eventType, object entityOrEto, object originalEntity) + { + var newEntry = new EntityChangeEventEntry(eventBus, eventType, entityOrEto, originalEntity); + + //Latest "same" event overrides the previous events. + for (var i = 0; i < Count; i++) + { + if (this[i].IsSameEvent(newEntry)) + { + this[i] = newEntry; + return; + } + } + + //If this is a "new" event, add to the end + Add(newEntry); + } + } + + private class EntityChangeEventEntry + { + public IEventBus EventBus { get; } + + public Type EventType { get; } + + public object EntityOrEto { get; } + + public object OriginalEntity { get; } + + public EntityChangeEventEntry(IEventBus eventBus, Type eventType, object entityOrEto, object originalEntity) + { + EventType = eventType; + EntityOrEto = entityOrEto; + OriginalEntity = originalEntity; + EventBus = eventBus; + } + + public bool IsSameEvent(EntityChangeEventEntry otherEntry) + { + if (EventBus != otherEntry.EventBus || EventType != otherEntry.EventType) + { + return false; + } + + var originalEntityRef = OriginalEntity as IEntity; + var otherOriginalEntityRef = otherEntry.OriginalEntity as IEntity; + if (originalEntityRef == null || otherOriginalEntityRef == null) + { + return false; + } + + return EntityHelper.EntityEquals(originalEntityRef, otherOriginalEntityRef); + } } } } \ No newline at end of file diff --git a/framework/test/Volo.Abp.Ddd.Tests/Volo/Abp/Domain/Entities/Entity_Tests.cs b/framework/test/Volo.Abp.Ddd.Tests/Volo/Abp/Domain/Entities/Entity_Tests.cs new file mode 100644 index 0000000000..779ac3fe49 --- /dev/null +++ b/framework/test/Volo.Abp.Ddd.Tests/Volo/Abp/Domain/Entities/Entity_Tests.cs @@ -0,0 +1,139 @@ +using System; +using Shouldly; +using Volo.Abp.MultiTenancy; +using Xunit; + +namespace Volo.Abp.Domain.Entities +{ + public class Entity_Tests + { + [Fact] + public void EntityEquals_Should_Return_True_For_Same_Keys() + { + var idValue1 = Guid.NewGuid(); + var idValue2 = Guid.NewGuid(); + + new Person(idValue1).EntityEquals(new Person(idValue1)).ShouldBeTrue(); + + new Car(42).EntityEquals(new Car(42)).ShouldBeTrue(); + + new Product("a").EntityEquals(new Product("a")).ShouldBeTrue(); + + new Phone(idValue1, "123").EntityEquals(new Phone(idValue1, "123")).ShouldBeTrue(); + } + + [Fact] + public void EntityEquals_Should_Return_False_For_Different_Keys() + { + var idValue1 = Guid.NewGuid(); + var idValue2 = Guid.NewGuid(); + + new Person(idValue1).EntityEquals(new Person()).ShouldBeFalse(); + new Person(idValue1).EntityEquals(new Person(idValue2)).ShouldBeFalse(); + + new Car(42).EntityEquals(new Car()).ShouldBeFalse(); + new Car(42).EntityEquals(new Car(43)).ShouldBeFalse(); + + new Product("a").EntityEquals(new Product()).ShouldBeFalse(); + new Product("a").EntityEquals(new Product("b")).ShouldBeFalse(); + + new Phone(idValue1, "123").EntityEquals(new Phone()).ShouldBeFalse(); + new Phone(idValue1, "123").EntityEquals(new Phone(idValue1, null)).ShouldBeFalse(); + new Phone(idValue1, "123").EntityEquals(new Phone(idValue1, "321")).ShouldBeFalse(); + } + + [Fact] + public void EntityEquals_Should_Return_False_For_Both_Default_Keys() + { + new Person().EntityEquals(new Person()).ShouldBeFalse(); + + new Car().EntityEquals(new Car()).ShouldBeFalse(); + + new Product().EntityEquals(new Product()).ShouldBeFalse(); + + new Phone().EntityEquals(new Phone()).ShouldBeFalse(); + } + + [Fact] + public void Different_Tenants_With_Same_Keys_Considered_As_Different_Objects() + { + var tenantId1 = Guid.NewGuid(); + var tenantId2 = Guid.NewGuid(); + + new Car(42, tenantId1).EntityEquals(new Car(42)).ShouldBeFalse(); + new Car(42).EntityEquals(new Car(42, tenantId2)).ShouldBeFalse(); + new Car(42, tenantId1).EntityEquals(new Car(42, tenantId2)).ShouldBeFalse(); + } + + [Fact] + public void Same_Tenants_With_Same_Keys_Considered_As_Same_Objects() + { + var tenantId1 = Guid.NewGuid(); + + new Car(42).EntityEquals(new Car(42)).ShouldBeTrue(); + new Car(42, tenantId1).EntityEquals(new Car(42, tenantId1)).ShouldBeTrue(); + } + + public class Person : Entity + { + public Person() + { + } + + public Person(Guid id) + : base(id) + { + } + } + + public class Car : Entity, IMultiTenant + { + public Guid? TenantId { get; } + + public Car() + { + } + + public Car(int id, Guid? tenantId = null) + : base(id) + { + TenantId = tenantId; + } + } + + public class Product : Entity + { + public Product() + { + } + + public Product(string id) + : base(id) + { + } + } + + public class Phone : Entity + { + public Guid PersonId { get; set; } + + public string Number { get; set; } + + public Phone() + { + + } + + public Phone(Guid personId, string number) + { + PersonId = personId; + Number = number; + } + + public override object[] GetKeys() + { + return new Object[] {PersonId, Number}; + } + } + } +} \ No newline at end of file diff --git a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DomainEvents/EntityChangeEvents_Tests.cs b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DomainEvents/EntityChangeEvents_Tests.cs index 2b94ee1ed9..9f4b67d089 100644 --- a/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DomainEvents/EntityChangeEvents_Tests.cs +++ b/framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/DomainEvents/EntityChangeEvents_Tests.cs @@ -4,6 +4,6 @@ namespace Volo.Abp.EntityFrameworkCore.DomainEvents { public class EntityChangeEvents_Tests : EntityChangeEvents_Tests { - + } } diff --git a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityChangeEvents_Tests.cs b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityChangeEvents_Tests.cs index fe4e0862f3..cb16a46439 100644 --- a/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityChangeEvents_Tests.cs +++ b/framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Testing/EntityChangeEvents_Tests.cs @@ -84,5 +84,44 @@ namespace Volo.Abp.TestApp.Testing createdEventTriggered.ShouldBeTrue(); createdEtoTriggered.ShouldBeTrue(); } + + [Fact] + public async Task Multiple_Update_Should_Result_With_Single_Updated_Event_In_The_Same_Uow() + { + var personId = Guid.NewGuid(); + await PersonRepository.InsertAsync(new Person(personId, Guid.NewGuid().ToString("D"), 42)); + + var updateEventCount = 0; + var updatedAge = 0; + + DistributedEventBus.Subscribe>(eto => + { + updateEventCount++; + updatedAge = eto.Entity.Age; + return Task.CompletedTask; + }); + + using (var uow = GetRequiredService().Begin()) + { + var person = await PersonRepository.GetAsync(personId); + + person.Age = 43; + await PersonRepository.UpdateAsync(person, autoSave: true); + updateEventCount.ShouldBe(0); + + person.Age = 44; + await PersonRepository.UpdateAsync(person, autoSave: true); + updateEventCount.ShouldBe(0); + + person.Age = 45; + await PersonRepository.UpdateAsync(person, autoSave: true); + updateEventCount.ShouldBe(0); + + await uow.CompleteAsync(); + } + + updateEventCount.ShouldBe(1); + updatedAge.ShouldBe(45); + } } }