Browse Source

Support auditing and history for complex properties

Resolve #24764
pull/24767/head
maliming 1 week ago
parent
commit
e487c0aaf2
No known key found for this signature in database GPG Key ID: A646B9CB645ECEA4
  1. 15
      framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs
  2. 19
      framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/EntityHistory/EntityHistoryHelper.cs
  3. 1
      framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/AbpAuditingTestModule.cs
  4. 26
      framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/App/Entities/AppEntityWithComplexProperty.cs
  5. 10
      framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/App/EntityFrameworkCore/AbpAuditingTestDbContext.cs
  6. 71
      framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/Auditing_Tests.cs
  7. 92
      framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Auditing/Auditing_Tests.cs
  8. 4
      framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/TestMigrationsDbContext.cs
  9. 4
      framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/TestApp/EntityFrameworkCore/TestAppDbContext.cs
  10. 16
      framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Domain/Person.cs
  11. 4
      framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/TestDataBuilder.cs

15
framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/AbpDbContext.cs

@ -310,7 +310,7 @@ public abstract class AbpDbContext<TDbContext> : DbContext, IAbpEfCoreDbContext,
EntityChangeEventHelper.PublishEntityUpdatedEvent(entityEntry.Entity);
}
}
else if (entityEntry.Properties.Any(x => x.IsModified && (x.Metadata.ValueGenerated == ValueGenerated.Never || x.Metadata.ValueGenerated == ValueGenerated.OnAdd)))
else if (GetAllPropertyEntries(entityEntry).Any(x => x.IsModified && (x.Metadata.ValueGenerated == ValueGenerated.Never || x.Metadata.ValueGenerated == ValueGenerated.OnAdd)))
{
if (IsOnlyForeignKeysModified(entityEntry))
{
@ -446,7 +446,7 @@ public abstract class AbpDbContext<TDbContext> : DbContext, IAbpEfCoreDbContext,
break;
case EntityState.Modified:
if (entry.Properties.Any(x => x.IsModified && (x.Metadata.ValueGenerated == ValueGenerated.Never || x.Metadata.ValueGenerated == ValueGenerated.OnAdd)))
if (GetAllPropertyEntries(entry).Any(x => x.IsModified && (x.Metadata.ValueGenerated == ValueGenerated.Never || x.Metadata.ValueGenerated == ValueGenerated.OnAdd)))
{
if (IsOnlyForeignKeysModified(entry))
{
@ -454,7 +454,7 @@ public abstract class AbpDbContext<TDbContext> : DbContext, IAbpEfCoreDbContext,
break;
}
var modifiedProperties = entry.Properties.Where(x => x.IsModified).ToList();
var modifiedProperties = GetAllPropertyEntries(entry).Where(x => x.IsModified).ToList();
var disableAuditingAttributes = modifiedProperties.Select(x => x.Metadata.PropertyInfo?.GetCustomAttribute<DisableAuditingAttribute>()).ToList();
if (disableAuditingAttributes.Any(x => x == null || x.UpdateModificationProps))
{
@ -501,9 +501,14 @@ public abstract class AbpDbContext<TDbContext> : DbContext, IAbpEfCoreDbContext,
}
}
protected virtual IEnumerable<PropertyEntry> GetAllPropertyEntries(EntityEntry entry)
{
return entry.Properties.Concat(entry.ComplexProperties.SelectMany(x => x.Properties));
}
protected virtual bool IsOnlyForeignKeysModified(EntityEntry entry)
{
return entry.Properties.Where(x => x.IsModified).All(x => x.Metadata.IsForeignKey() &&
return GetAllPropertyEntries(entry).Where(x => x.IsModified).All(x => x.Metadata.IsForeignKey() &&
(x.CurrentValue == null || x.OriginalValue?.ToString() == x.CurrentValue?.ToString()));
}
@ -662,7 +667,7 @@ public abstract class AbpDbContext<TDbContext> : DbContext, IAbpEfCoreDbContext,
protected virtual void ApplyAbpConceptsForModifiedEntity(EntityEntry entry, bool forceApply = false)
{
if (forceApply ||
entry.Properties.Any(x => x.IsModified && (x.Metadata.ValueGenerated == ValueGenerated.Never || x.Metadata.ValueGenerated == ValueGenerated.OnAdd)))
GetAllPropertyEntries(entry).Any(x => x.IsModified && (x.Metadata.ValueGenerated == ValueGenerated.Never || x.Metadata.ValueGenerated == ValueGenerated.OnAdd)))
{
IncrementEntityVersionProperty(entry);
SetModificationAuditProperties(entry);

19
framework/src/Volo.Abp.EntityFrameworkCore/Volo/Abp/EntityFrameworkCore/EntityHistory/EntityHistoryHelper.cs

@ -207,6 +207,23 @@ public class EntityHistoryHelper : IEntityHistoryHelper, ITransientDependency
}
}
foreach (var complexPropertyEntry in entityEntry.ComplexProperties)
{
foreach (var propertyEntry in complexPropertyEntry.Properties)
{
if (ShouldSavePropertyHistory(propertyEntry, isCreated || isDeleted) && !IsSoftDeleted(entityEntry))
{
propertyChanges.Add(new EntityPropertyChangeInfo
{
NewValue = isDeleted ? null : JsonSerializer.Serialize(propertyEntry.CurrentValue!).TruncateWithPostfix(EntityPropertyChangeInfo.MaxValueLength),
OriginalValue = isCreated ? null : JsonSerializer.Serialize(propertyEntry.OriginalValue!).TruncateWithPostfix(EntityPropertyChangeInfo.MaxValueLength),
PropertyName = $"{complexPropertyEntry.Metadata.Name}.{propertyEntry.Metadata.Name}",
PropertyTypeFullName = propertyEntry.Metadata.ClrType.GetFirstGenericArgumentIfNullable().FullName!
});
}
}
}
if (AbpEfCoreNavigationHelper == null)
{
return propertyChanges;
@ -262,7 +279,7 @@ public class EntityHistoryHelper : IEntityHistoryHelper, ITransientDependency
/// <see cref="PropertyEntry.OriginalValue"/>. If both values are <c>null</c>, the declared CLR type
/// (which may remain <see cref="object"/>) is returned.
/// </returns>
protected virtual Type DeterminePropertyTypeFromEntry(IProperty property, PropertyEntry propertyEntry)
protected virtual Type DeterminePropertyTypeFromEntry(IReadOnlyPropertyBase property, PropertyEntry propertyEntry)
{
var propertyType = property.ClrType.GetFirstGenericArgumentIfNullable();

1
framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/AbpAuditingTestModule.cs

@ -61,6 +61,7 @@ public class AbpAuditingTestModule : AbpModule
);
options.EntityHistorySelectors.Add(new NamedTypeSelector(nameof(AppEntityWithJsonProperty), type => type == typeof(AppEntityWithJsonProperty)));
options.EntityHistorySelectors.Add(new NamedTypeSelector(nameof(AppEntityWithComplexProperty), type => type == typeof(AppEntityWithComplexProperty)));
});
context.Services.AddType<Auditing_Tests.MyAuditedObject1>();

26
framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/App/Entities/AppEntityWithComplexProperty.cs

@ -0,0 +1,26 @@
using System;
using Volo.Abp.Domain.Entities.Auditing;
namespace Volo.Abp.Auditing.App.Entities;
public class AppEntityWithComplexProperty : FullAuditedAggregateRoot<Guid>
{
public string Name { get; set; }
public AppEntityContactInformation ContactInformation { get; set; }
public AppEntityWithComplexProperty()
{
}
public AppEntityWithComplexProperty(Guid id, string name)
: base(id)
{
Name = name;
}
}
public class AppEntityContactInformation
{
public string Street { get; set; } = string.Empty;
}

10
framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/App/EntityFrameworkCore/AbpAuditingTestDbContext.cs

@ -31,6 +31,7 @@ public class AbpAuditingTestDbContext : AbpDbContext<AbpAuditingTestDbContext>
public DbSet<AppEntityWithNavigationChildOneToMany> AppEntityWithNavigationChildOneToMany { get; set; }
public DbSet<AppEntityWithNavigationsAndDisableAuditing> AppEntityWithNavigationsAndDisableAuditing { get; set; }
public DbSet<AppEntityWithJsonProperty> EntitiesWithObjectProperty { get; set; }
public DbSet<AppEntityWithComplexProperty> AppEntitiesWithComplexProperty { get; set; }
public AbpAuditingTestDbContext(DbContextOptions<AbpAuditingTestDbContext> options)
: base(options)
@ -77,5 +78,14 @@ public class AbpAuditingTestDbContext : AbpDbContext<AbpAuditingTestDbContext>
);
});
});
modelBuilder.Entity<AppEntityWithComplexProperty>(b =>
{
b.ConfigureByConvention();
b.ComplexProperty(x => x.ContactInformation, cb =>
{
cb.Property(x => x.Street).IsRequired();
});
});
}
}

71
framework/test/Volo.Abp.Auditing.Tests/Volo/Abp/Auditing/Auditing_Tests.cs

@ -818,6 +818,77 @@ public class Auditing_Tests : AbpAuditingTestBase
x.EntityChanges[0].PropertyChanges[2].PropertyName == "Data.Value" &&
x.EntityChanges[0].PropertyChanges[2].PropertyTypeFullName == typeof(string).FullName));
AuditingStore.ClearReceivedCalls();
#pragma warning restore 4014
}
[Fact]
public async Task Should_Write_AuditLog_For_Complex_Property_Changes()
{
var entityId = Guid.NewGuid();
var repository = ServiceProvider.GetRequiredService<IBasicRepository<AppEntityWithComplexProperty, Guid>>();
using (var scope = _auditingManager.BeginScope())
{
using (var uow = _unitOfWorkManager.Begin())
{
var entity = new AppEntityWithComplexProperty(entityId, "Test Entity")
{
ContactInformation = new AppEntityContactInformation
{
Street = "First Street"
}
};
await repository.InsertAsync(entity);
await uow.CompleteAsync();
await scope.SaveAsync();
}
}
#pragma warning disable 4014
AuditingStore.Received().SaveAsync(Arg.Is<AuditLogInfo>(x => x.EntityChanges.Count == 1 &&
x.EntityChanges[0].ChangeType == EntityChangeType.Created &&
x.EntityChanges[0].EntityTypeFullName == typeof(AppEntityWithComplexProperty).FullName &&
x.EntityChanges[0].PropertyChanges.Count == 2 &&
x.EntityChanges[0].PropertyChanges.Any(pc =>
pc.PropertyName == nameof(AppEntityWithComplexProperty.Name) &&
pc.OriginalValue == null &&
pc.NewValue == "\"Test Entity\"" &&
pc.PropertyTypeFullName == typeof(string).FullName) &&
x.EntityChanges[0].PropertyChanges.Any(pc =>
pc.PropertyName == "ContactInformation.Street" &&
pc.OriginalValue == null &&
pc.NewValue == "\"First Street\"" &&
pc.PropertyTypeFullName == typeof(string).FullName)));
AuditingStore.ClearReceivedCalls();
#pragma warning restore 4014
using (var scope = _auditingManager.BeginScope())
{
using (var uow = _unitOfWorkManager.Begin())
{
var entity = await repository.GetAsync(entityId);
entity.ContactInformation.Street = "Updated Street";
await repository.UpdateAsync(entity);
await uow.CompleteAsync();
await scope.SaveAsync();
}
}
#pragma warning disable 4014
AuditingStore.Received().SaveAsync(Arg.Is<AuditLogInfo>(x => x.EntityChanges.Count == 1 &&
x.EntityChanges[0].ChangeType == EntityChangeType.Updated &&
x.EntityChanges[0].EntityTypeFullName == typeof(AppEntityWithComplexProperty).FullName &&
x.EntityChanges[0].PropertyChanges.Count == 1 &&
x.EntityChanges[0].PropertyChanges[0].PropertyName == "ContactInformation.Street" &&
x.EntityChanges[0].PropertyChanges[0].OriginalValue == "\"First Street\"" &&
x.EntityChanges[0].PropertyChanges[0].NewValue == "\"Updated Street\"" &&
x.EntityChanges[0].PropertyChanges[0].PropertyTypeFullName == typeof(string).FullName));
AuditingStore.ClearReceivedCalls();
#pragma warning restore 4014
}
}

92
framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/Auditing/Auditing_Tests.cs

@ -6,6 +6,7 @@ using NSubstitute;
using Shouldly;
using Volo.Abp.Domain.Entities.Events;
using Volo.Abp.TestApp;
using Volo.Abp.TestApp.Domain;
using Volo.Abp.TestApp.Testing;
using Xunit;
@ -85,6 +86,33 @@ public class Auditing_Tests : Auditing_Tests<AbpEntityFrameworkCoreTestModule>
}));
}
[Fact]
public async Task Should_Set_Modification_If_Complex_Properties_Changed()
{
var street = Guid.NewGuid().ToString();
await WithUnitOfWorkAsync((async () =>
{
var douglas = await PersonRepository.GetAsync(TestDataBuilder.UserDouglasId);
douglas.ContactInformation ??= new PersonContactInformation();
douglas.ContactInformation.Street = street;
}));
await WithUnitOfWorkAsync((async () =>
{
var douglas = await PersonRepository.FindAsync(TestDataBuilder.UserDouglasId);
douglas.ShouldNotBeNull();
douglas.ContactInformation.ShouldNotBeNull();
douglas.ContactInformation!.Street.ShouldBe(street);
douglas.LastModificationTime.ShouldNotBeNull();
douglas.LastModificationTime.Value.ShouldBeLessThanOrEqualTo(Clock.Now);
douglas.LastModifierId.ShouldBe(CurrentUserId);
}));
EntityChangeEventHelper.Received().PublishEntityUpdatedEvent(Arg.Any<object>());
}
[Fact]
public async Task Should_Not_Set_Modification_If_Properties_HasDisableAuditing_UpdateModificationProps()
{
@ -106,6 +134,28 @@ public class Auditing_Tests : Auditing_Tests<AbpEntityFrameworkCoreTestModule>
EntityChangeEventHelper.Received().PublishEntityUpdatedEvent(Arg.Any<object>());
}
[Fact]
public async Task Should_Not_Set_Modification_If_ComplexProperties_HasDisableAuditing_UpdateModificationProps()
{
await WithUnitOfWorkAsync((async () =>
{
var douglas = await PersonRepository.GetAsync(TestDataBuilder.UserDouglasId);
douglas.ContactInformation ??= new PersonContactInformation();
douglas.ContactInformation.DisableAuditingUpdateModificationPropsProperty = Guid.NewGuid().ToString();
}));
await WithUnitOfWorkAsync((async () =>
{
var douglas = await PersonRepository.FindAsync(TestDataBuilder.UserDouglasId);
douglas.ShouldNotBeNull();
douglas.LastModificationTime.ShouldBeNull();
douglas.LastModifierId.ShouldBeNull();
}));
EntityChangeEventHelper.Received().PublishEntityUpdatedEvent(Arg.Any<object>());
}
[Fact]
public async Task Should_Not_PublishEntityEvent_If_Properties_HasDisableAuditing_PublishEntityEventProperty()
{
@ -126,6 +176,27 @@ public class Auditing_Tests : Auditing_Tests<AbpEntityFrameworkCoreTestModule>
EntityChangeEventHelper.DidNotReceive().PublishEntityUpdatedEvent(Arg.Any<object>());
}
[Fact]
public async Task Should_Not_PublishEntityEvent_If_ComplexProperties_HasDisableAuditing_PublishEntityEventProperty()
{
await WithUnitOfWorkAsync((async () =>
{
var douglas = await PersonRepository.GetAsync(TestDataBuilder.UserDouglasId);
douglas.ContactInformation ??= new PersonContactInformation();
douglas.ContactInformation.DisableAuditingPublishEntityEventProperty = Guid.NewGuid().ToString();
}));
await WithUnitOfWorkAsync((async () =>
{
var douglas = await PersonRepository.FindAsync(TestDataBuilder.UserDouglasId);
douglas.ShouldNotBeNull();
douglas.LastModificationTime.ShouldNotBeNull();
}));
EntityChangeEventHelper.DidNotReceive().PublishEntityUpdatedEvent(Arg.Any<object>());
}
[Fact]
public async Task Should_Set_Modification_And_PublishEntityEvent_If_Properties_HasDisableAuditing()
@ -146,4 +217,25 @@ public class Auditing_Tests : Auditing_Tests<AbpEntityFrameworkCoreTestModule>
EntityChangeEventHelper.Received().PublishEntityUpdatedEvent(Arg.Any<object>());
}
[Fact]
public async Task Should_Set_Modification_And_PublishEntityEvent_If_ComplexProperties_HasDisableAuditing()
{
await WithUnitOfWorkAsync((async () =>
{
var douglas = await PersonRepository.GetAsync(TestDataBuilder.UserDouglasId);
douglas.ContactInformation ??= new PersonContactInformation();
douglas.ContactInformation.DisableAuditingProperty = Guid.NewGuid().ToString();
}));
await WithUnitOfWorkAsync((async () =>
{
var douglas = await PersonRepository.FindAsync(TestDataBuilder.UserDouglasId);
douglas.ShouldNotBeNull();
douglas.LastModificationTime.ShouldNotBeNull();
}));
EntityChangeEventHelper.Received().PublishEntityUpdatedEvent(Arg.Any<object>());
}
}

4
framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/EntityFrameworkCore/TestMigrationsDbContext.cs

@ -78,6 +78,10 @@ public class TestMigrationsDbContext : AbpDbContext<TestMigrationsDbContext>
b.Property(x => x.HasDefaultValue).HasDefaultValue(DateTime.Now);
b.Property(x => x.TenantId).HasColumnName("Tenant_Id");
b.Property(x => x.IsDeleted).HasColumnName("Is_Deleted");
b.ComplexProperty(x => x.ContactInformation, cb =>
{
cb.Property(x => x.Street).IsRequired();
});
});
modelBuilder.Entity<City>(b =>

4
framework/test/Volo.Abp.EntityFrameworkCore.Tests/Volo/Abp/TestApp/EntityFrameworkCore/TestAppDbContext.cs

@ -92,6 +92,10 @@ public class TestAppDbContext : AbpDbContext<TestAppDbContext>, IThirdDbContext,
b.Property(x => x.HasDefaultValue).HasDefaultValue(DateTime.Now);
b.Property(x => x.TenantId).HasColumnName("Tenant_Id");
b.Property(x => x.IsDeleted).HasColumnName("Is_Deleted");
b.ComplexProperty(x => x.ContactInformation, cb =>
{
cb.Property(x => x.Street).IsRequired();
});
});
modelBuilder

16
framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/Domain/Person.cs

@ -32,6 +32,8 @@ public class Person : FullAuditedAggregateRoot<Guid>, IMultiTenant, IHasEntityVe
public virtual DateTime HasDefaultValue { get; set; }
public virtual PersonContactInformation? ContactInformation { get; set; }
public int EntityVersion { get; set; }
[DisableAuditing(UpdateModificationProps = false)]
@ -84,3 +86,17 @@ public class Person : FullAuditedAggregateRoot<Guid>, IMultiTenant, IHasEntityVe
);
}
}
public class PersonContactInformation
{
public string Street { get; set; } = string.Empty;
[DisableAuditing(UpdateModificationProps = false)]
public string? DisableAuditingUpdateModificationPropsProperty { get; set; }
[DisableAuditing(PublishEntityEvent = false)]
public string? DisableAuditingPublishEntityEventProperty { get; set; }
[DisableAuditing]
public string? DisableAuditingProperty { get; set; }
}

4
framework/test/Volo.Abp.TestApp/Volo/Abp/TestApp/TestDataBuilder.cs

@ -76,6 +76,10 @@ public class TestDataBuilder : ITransientDependency
private async Task AddPeople()
{
var douglas = new Person(UserDouglasId, "Douglas", 42, cityId: LondonCityId);
douglas.ContactInformation = new PersonContactInformation
{
Street = "Test Street"
};
douglas.Phones.Add(new Phone(douglas.Id, "123456789"));
douglas.Phones.Add(new Phone(douglas.Id, "123456780", PhoneType.Home));

Loading…
Cancel
Save