thepirat000 / Audit.NET

An extensible framework to audit executing operations in .NET and .NET Core.
MIT License
2.29k stars 324 forks source link

Owned entites not tracked. #696

Closed hassanrazakhalid closed 6 days ago

hassanrazakhalid commented 1 month ago

Describe the bug Owned entities when mapped to Json not showing in EventEntry.Changes callback. Is there a way to override changes implementation or wo could a json in oldValue and newValue for complex types.

To Reproduce

  1. Use the below setup
  2. Now whenever i try to update or add ShipperPricing
  3. EventEntry.Changes contain other props but not owned prop.

Expected behavior OwnedEntities should be visible in Changes.

Libraries (specify the Audit.NET extensions being used including version): For example:

Target .NET framework: For example:

Additional context

public enum FulfillmentOfferedService
{
    Pick_And_Pack,
    Storage_Per_CBM_Pallet_Shelves,
}
public sealed class Shipper {

}
public sealed class ShipperPricing
{
    public long Id { get; init; }
    public FulfillmentOfferedService OfferedService { get; set; }
    public List<SkuRangePricing> PriceRanges { get; private set; } = new ();
    public decimal? ExtraPerSku { get; set; }

    public Shipper Shipper { get; set; } = null!;
    public long ShipperId { get; set; }
}

/// <summary>
/// Will be used as JSON column to avoid extra JOIN.
/// </summary>
public record SkuRangePricing
{
    public string Range { get; set; } = string.Empty;
    /// <summary>
    /// Fee will in AED.
    /// </summary>
    public decimal Fee { get; set; }
}

public sealed class ShipperPricingConfigurationConfiguration : IAuditableEntityConfiguration<ShipperPricing>
{
    public override void ConfigureEntity(EntityTypeBuilder<ShipperPricing> builder)
    {
        builder.Property(x => x.Id)
            .ValueGeneratedNever();
        builder.Property(x => x.OfferedService)
            .HasConversion<string>()
            .HasMaxLength(StringLength.StandardMax);

        builder.OwnsMany(x => x.PriceRanges, skuRange =>
        {
            skuRange.ToJson();
            skuRange.Property(x => x.Fee)
                .HasColumnType("decimal(9, 2)");
        });

        builder.Property(x => x.ExtraPerSku)
            .HasColumnType("decimal(9, 2)");
    }
}

Audit.Core.Configuration.Setup()
            .UseEntityFramework(ef => ef
                .AuditTypeExplicitMapper(m => m
                    .Map<Shipper, AuditLog>((shipper, entry, auditLog) =>
                    {
                        auditLog.TableName = nameof(AppDbContext.Shippers);
                        return true;
                    })
                    .Map<ShipperCarrier, AuditLog>((shipper, entry, auditLog) =>
                    {
                        auditLog.TableName = nameof(AppDbContext.ShipperCarriers);
                        auditLog.Reference.Add((entry.GetEntry().Entity as ShipperCarrier)!.ShipperId.ToString());
                        return true;
                    })
                    .Map<ShipperPricing, AuditLog>((shipper, entry, auditLog) =>
                    {
                        auditLog.TableName = nameof(AppDbContext.ShipperPricings);
                        auditLog.Reference.Add((entry.GetEntry().Entity as ShipperPricing)!.ShipperId.ToString());
                        return true;
                    })
                    .AuditEntityAction<AuditLog>((ev, entry, entity) =>
                    {
                        // To fix audit infinity cycle in case of DbUpdateException
                        // if (ev.Environment.Exception != null)
                        // {
                        //     return Task.FromResult(false);
                        // }

                        // If only created at or updated at is modified and not prop is change ignoring that event
                        if (entry.GetEntry().Entity is IAuditableEntity auditableEntity)
                        {
                            if (entry.Changes != null && entry.Changes.Count == 1)
                            {
                                if (entry.Changes.First().ColumnName == nameof(IAuditableEntity.CreatedAt) ||
                                    entry.Changes.First().ColumnName == nameof(IAuditableEntity.UpdatedAt))
                                {
                                    return false;
                                }
                            }
                        }

                        entity.Action = entry.Action;
                        entity.Exception = ev.GetEntityFrameworkEvent().ErrorMessage;
                        entity.Data = entry.ToJson();
                        entity.Changes = JsonSerializer.Serialize(entry.Changes ?? []);
                        entity.EntityType = entry.EntityType.Name;
                        entity.AuditDate = DateTime.UtcNow;

                        entity.TablePk = entry.PrimaryKey.First().Value.ToString()!;
                        var userId = ev.CustomFields[AuditKeys.UserId];
                        var email = ev.CustomFields[AuditKeys.Email];
                        if (userId is not null)
                        {
                            entity.ByUserId = userId.ToString()!;
                            entity.ByUserName = email.ToString()!;
                            // ev.CustomFields["User"] = userId;
                        }

                        return true;
                    }))
                .IgnoreMatchedProperties(true));

LogicTosave() {
foreach (ShipperPricingDto pricingDto in request.PricingDtos)
        {
            ShipperPricing? shipperPricing;
            if (string.IsNullOrEmpty(pricingDto.Id))
            {
                shipperPricing = new ShipperPricing
                {
                    Id = _idGenerator.LongId,
                    OfferedService = pricingDto.OfferedService,
                    ShipperId = request.ShipperId
                };
                _dbContext.ShipperPricings.Add(shipperPricing);
            }
            else
            {
                shipperPricing = dbShipperPricing.Single(x => x.Id.ToString() == pricingDto.Id);
                _dbContext.ShipperPricings.Update(shipperPricing);
            }

            shipperPricing.PriceRanges.Clear();
            List<SkuRangePricing> ranges = _mapper.Map<List<SkuRangePricing>>(pricingDto.PriceRanges) ?? [];
            shipperPricing.PriceRanges.AddRange(ranges);
            shipperPricing.ExtraPerSku = pricingDto.ExtraPerSku;
        }
}

See the below screenshot. Are owned entries should be treated as separate entries. Also the state is Added during execution, which is why i guess its not added to value changes

Screenshot from 2024-09-25 08-23-54

thepirat000 commented 1 month ago

Could you clarify which property you don't see in EventEntry.Changes for your example? I couldn't understand the screenshot. Please provide what you are getting and what your expectation is

hassanrazakhalid commented 1 month ago

yes. What i think should be like this Changes structure similar to

{
    "Changes": [
               // ommiting other properities
        {
            "PropertyName": "PriceRanges"
            "OldValue": "some-json-string",
            "NewValue": "some-json-string"
        }
    ]
}

since "PriceRanges" is defined as Owned and cannot exist independently, When i modify entity "ShipperPricing" and modify PriceRanges, then i expect to see is value changes along with non-complex properties. Right now

.AuditEntityAction<AuditLog> is missing "PriceRanges" in changes when i save parent entity "ShipperPricing"

thepirat000 commented 1 month ago

I was unable to reproduce. You should see the changes to the owned property by the owner's entity.

Could you provide a minimal project that replicates the issue, or at the very least, share all relevant code and configuration details?

Note you probably want to set the ReloadDatabaseValues configuration to true, since you use DbContext.Update (see this)

For example:

Audit.EntityFramework.Configuration.Setup()
    .ForAnyContext(cfg => cfg
        .ReloadDatabaseValues());
hassanrazakhalid commented 1 month ago

Adding ReloadDatabaseValues() gives exception

An unhandled exception has occurred while executing the request.
System.Collections.Generic.KeyNotFoundException: The given key 'Property: SkuRangePricing.Id (no field, int) Shadow Required PK AfterSave:Throw ValueGenerated.OnAdd' was not present in the dictionary.
   at System.Collections.Generic.Dictionary`2.get_Item(TKey key)
   at Microsoft.EntityFrameworkCore.Query.StructuralTypeProjectionExpression.BindProperty(IProperty property)
   at Microsoft.EntityFrameworkCore.Query.RelationalSqlTranslatingExpressionVisitor.BindProperty(StructuralTypeReferenceExpression typeReference, IProperty property)
   at Microsoft.EntityFrameworkCore.Query.RelationalSqlTranslatingExpressionVisitor.TryBindMember(Expression source, MemberIdentity member, Expression& expression, IPropertyBase& property)
   at Microsoft.EntityFrameworkCore.Query.RelationalSqlTranslatingExpressionVisitor.TryBindMember(Expression source, MemberIdentity member, Expression& expression)
   at Microsoft.EntityFrameworkCore.Query.RelationalSqlTranslatingExpressionVisitor.VisitMethodCall(MethodCallExpression methodCallExpression)
   at Microsoft.EntityFrameworkCore.SqlServer.Query.Internal.SqlServerSqlTranslatingExpressionVisitor.VisitMethodCall(MethodCallExpression methodCallExpression)
   at Microsoft.EntityFrameworkCore.Query.RelationalSqlTranslatingExpressionVisitor.VisitBinary(BinaryExpression binaryExpression)
   at Microsoft.EntityFrameworkCore.SqlServer.Query.Internal.SqlServerSqlTranslatingExpressionVisitor.VisitBinary(BinaryExpression binaryExpression)
   at Microsoft.EntityFrameworkCore.Query.RelationalSqlTranslatingExpressionVisitor.VisitBinary(BinaryExpression binaryExpression)
   at Microsoft.EntityFrameworkCore.SqlServer.Query.Internal.SqlServerSqlTranslatingExpressionVisitor.VisitBinary(BinaryExpression binaryExpression)
   at Microsoft.EntityFrameworkCore.Query.RelationalSqlTranslatingExpressionVisitor.TranslateInternal(Expression expression, Boolean applyDefaultTypeMapping)
   at Microsoft.EntityFrameworkCore.Query.RelationalSqlTranslatingExpressionVisitor.Translate(Expression expression, Boolean applyDefaultTypeMapping)
   at Microsoft.EntityFrameworkCore.Query.RelationalQueryableMethodTranslatingExpressionVisitor.TranslateExpression(Expression expression, Boolean applyDefaultTypeMapping)
   at Microsoft.EntityFrameworkCore.Query.RelationalQueryableMethodTranslatingExpressionVisitor.TranslateLambdaExpression(ShapedQueryExpression shapedQueryExpression, LambdaExpression lambdaExpression)
   at Microsoft.EntityFrameworkCore.Query.RelationalQueryableMethodTranslatingExpressionVisitor.TranslateWhere(ShapedQueryExpression source, LambdaExpression predicate)
   at Microsoft.EntityFrameworkCore.Query.QueryableMethodTranslatingExpressionVisitor.VisitMethodCall(MethodCallExpression methodCallExpression)
   at Microsoft.EntityFrameworkCore.Query.RelationalQueryableMethodTranslatingExpressionVisitor.VisitMethodCall(MethodCallExpression methodCallExpression)
   at Microsoft.EntityFrameworkCore.Query.QueryableMethodTranslatingExpressionVisitor.VisitMethodCall(MethodCallExpression methodCallExpression)
   at Microsoft.EntityFrameworkCore.Query.RelationalQueryableMethodTranslatingExpressionVisitor.VisitMethodCall(MethodCallExpression methodCallExpression)
   at Microsoft.EntityFrameworkCore.Query.QueryableMethodTranslatingExpressionVisitor.VisitMethodCall(MethodCallExpression methodCallExpression)
   at Microsoft.EntityFrameworkCore.Query.RelationalQueryableMethodTranslatingExpressionVisitor.VisitMethodCall(MethodCallExpression methodCallExpression)
   at Microsoft.EntityFrameworkCore.Query.QueryableMethodTranslatingExpressionVisitor.Translate(Expression expression)
   at Microsoft.EntityFrameworkCore.Query.RelationalQueryableMethodTranslatingExpressionVisitor.Translate(Expression expression)
   at Microsoft.EntityFrameworkCore.Query.QueryCompilationContext.CreateQueryExecutor[TResult](Expression query)
   at Microsoft.EntityFrameworkCore.Storage.Database.CompileQuery[TResult](Expression query, Boolean async)
   at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.CompileQueryCore[TResult](IDatabase database, Expression query, IModel model, Boolean async)
   at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.<>c__DisplayClass9_0`1.<Execute>b__0()
   at Microsoft.EntityFrameworkCore.Query.Internal.CompiledQueryCache.GetOrAddQuery[TResult](Object cacheKey, Func`1 compiler)
   at Microsoft.EntityFrameworkCore.Query.Internal.QueryCompiler.Execute[TResult](Expression query)
   at Microsoft.EntityFrameworkCore.Query.Internal.EntityQueryProvider.Execute[TResult](Expression expression)
   at Microsoft.EntityFrameworkCore.Internal.EntityFinder`1.GetDatabaseValues(InternalEntityEntry entry)
   at Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry.GetDatabaseValues()
   at Audit.EntityFramework.DbContextHelper.CreateAuditEvent(IAuditDbContext context)
   at Audit.EntityFramework.DbContextHelper.BeginSaveChangesAsync(IAuditDbContext context, CancellationToken cancellationToken)
   at Audit.EntityFramework.AuditSaveChangesInterceptor.SavingChangesAsync(DbContextEventData eventData, InterceptionResult`1 result, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.Diagnostics.Internal.SaveChangesInterceptorAggregator.CompositeSaveChangesInterceptor.SavingChangesAsync(DbContextEventData eventData, InterceptionResult`1 result, CancellationToken cancellationToken)
   at Microsoft.EntityFrameworkCore.DbContext.SaveChangesAsync(Boolean acceptAllChangesOnSuccess, CancellationToken cancellationToken)
   at Application.Features.Shippers.Commands.SaveShipperPricing.SaveShipperPricingCommandHandler.Handle(SaveShipperPricingCommand request, CancellationToken cancellationToken) in /home/hassan/projects/uruk/fulfillment-web/src/Fulfilment.Application/Features/Shippers/Commands/SaveShipperPricing/SaveShipperPricingCommandHandler.cs:line 62
   at Application.Commons.Behaviors.ValidationBehavior`2.Handle(TRequest request, RequestHandlerDelegate`1 next, CancellationToken cancellationToken) in /home/hassan/projects/uruk/fulfillment-web/src/Fulfilment.Application/Commons/Behaviors/ValidationBehavior.cs:line 23
   at Application.Commons.Behaviors.LoggingBehavior`2.Handle(TRequest request, RequestHandlerDelegate`1 next, CancellationToken cancellationToken) in /home/hassan/projects/uruk/fulfillment-web/src/Fulfilment.Application/Commons/Behaviors/LoggingBehavior.cs:line 25
   at Fulfillment.Sys.Controllers.Shippers.ShipperController.SavePricingDetails(SaveShipperPricingCommand command) in /home/hassan/projects/uruk/fulfillment-web/src/Fulfillment.Sys/Controllers/Shippers/ShipperController.cs:line 261
   at lambda_method487(Closure, Object)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ActionMethodExecutor.AwaitableObjectResultExecutor.Execute(ActionContext actionContext, IActionResultTypeMapper mapper, ObjectMethodExecutor executor, Object controller, Object[] arguments)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeActionMethodAsync>g__Logged|12_1(ControllerActionInvoker invoker)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeNextActionFilterAsync>g__Awaited|10_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Rethrow(ActionExecutedContextSealed context)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ControllerActionInvoker.<InvokeInnerFilterAsync>g__Awaited|13_0(ControllerActionInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeNextResourceFilter>g__Awaited|25_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Rethrow(ResourceExecutedContextSealed context)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.Next(State& next, Scope& scope, Object& state, Boolean& isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeFilterPipelineAsync>g__Awaited|20_0(ResourceInvoker invoker, Task lastTask, State next, Scope scope, Object state, Boolean isCompleted)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
   at Microsoft.AspNetCore.Mvc.Infrastructure.ResourceInvoker.<InvokeAsync>g__Logged|17_1(ResourceInvoker invoker)
   at Microsoft.AspNetCore.Authorization.AuthorizationMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Authentication.AuthenticationMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Localization.RequestLocalizationMiddleware.Invoke(HttpContext context)
   at Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddlewareImpl.Invoke(HttpContext context)
hassanrazakhalid commented 1 month ago

ShipperPrice Configuration

public sealed class ShipperPricingConfigurationConfiguration : IAuditableEntityConfiguration<ShipperPricing>
{
    public override void ConfigureEntity(EntityTypeBuilder<ShipperPricing> builder)
    {
        builder.Property(x => x.Id)
            .ValueGeneratedNever();
        builder.Property(x => x.OfferedService)
            .HasConversion<string>()
            .HasMaxLength(StringLength.StandardMax);

        // builder.Property(x => x.SkuRanges)
        //     
        //     .HasMaxLength(StringLength.StandardMax);
        builder.OwnsMany(x => x.PriceRanges, skuRange =>
        {
            skuRange.ToJson();
            skuRange.Property(x => x.Fee)
                .HasColumnType("decimal(9, 2)");
        });

        builder.Property(x => x.ExtraPerSku)
            .HasColumnType("decimal(9, 2)");
    }
}

The configuration of Audit.Net i'm using

Audit.EntityFramework.Configuration.Setup()
            .ForContext<AppDbContext>(config => config
                    .IncludeEntityObjects()
                .ForEntity<ShipperPricing>(_ => _
                    .Ignore(x => x.UpdatedAt)
                    .Override(x => x.PriceRanges, value =>
                    {
                        return value.Entity as List<ShipperPricing>;
                    })
                    .Format(x => x.PriceRanges, value =>
                    {
                        return JsonSerializer.Serialize(value);
                    })
                )
                // .ForEntity<ShipperPricing>(_ => _
                //     .Format(sp => sp.PriceRanges, pr => JsonSerializer.Serialize(pr))))
                )
            .UseOptOut()
            ;

        Audit.Core.Configuration.Setup()
            .UseEntityFramework(ef => ef
                .AuditTypeExplicitMapper(m => m
                    .Map<Shipper, AuditLog>((shipper, entry, auditLog) =>
                    {
                        auditLog.TableName = nameof(AppDbContext.Shippers);
                        return true;
                    })
                    .Map<ShipperCarrier, AuditLog>((shipper, entry, auditLog) =>
                    {
                        auditLog.TableName = nameof(AppDbContext.ShipperCarriers);
                        auditLog.Reference.Add((entry.GetEntry().Entity as ShipperCarrier)!.ShipperId.ToString());
                        return true;
                    })
                    // .Map<SkuRangePricing, AuditLog>((shipper, entry, auditLog) =>
                    // {
                    //     return true;
                    // })
                    .Map<ShipperPricing, AuditLog>((shipper, entry, auditLog) =>
                    {
                        auditLog.TableName = nameof(AppDbContext.ShipperPricings);
                        auditLog.Reference.Add((entry.GetEntry().Entity as ShipperPricing)!.ShipperId.ToString());
                        return true;
                    })
                    .AuditEntityAction<AuditLog>((ev, entry, entity) =>
                    {
                        // To fix audit infinity cycle in case of DbUpdateException
                        if (ev.Environment.Exception != null)
                        {
                            return false;
                        }

                        // If only created at or updated at is modified and not prop is change ignoring that event
                        if (entry.GetEntry().Entity is IAuditableEntity auditableEntity)
                        {
                            if (entry.Changes == null || !entry.Changes.Any()) return false;

                            if (entry.Changes != null && entry.Changes.Count == 1)
                            {
                                if (entry.Changes.First().ColumnName == nameof(IAuditableEntity.CreatedAt) ||
                                    entry.Changes.First().ColumnName == nameof(IAuditableEntity.UpdatedAt))
                                {
                                    return false;
                                }
                            }
                        }

                        entity.Action = entry.Action;
                        entity.Exception = ev.GetEntityFrameworkEvent().ErrorMessage;
                        entity.Data = entry.ToJson();
                        entity.Changes = JsonSerializer.Serialize(entry.Changes ?? []);
                        entity.EntityType = entry.EntityType.Name;
                        entity.AuditDate = DateTime.UtcNow;

                        entity.TablePk = entry.PrimaryKey.First().Value.ToString()!;
                        var userId = ev.CustomFields[AuditKeys.UserId];
                        var email = ev.CustomFields[AuditKeys.Email];
                        if (userId is not null)
                        {
                            entity.ByUserId = userId.ToString()!;
                            entity.ByUserName = email.ToString()!;
                            // ev.CustomFields["User"] = userId;
                        }

                        return true;
                    }))
                .IgnoreMatchedProperties(true));

I have a few questions

  1. Do i need both configurations in case i want to override serialization of complex property, or it can be done in single configuration, also that overrides of "Format" and "Override" of a complex callbacks are never called.
  2. see this chunk of code. It is commented in the above configuration. When trying to figure the issue, if i uncomment this code. It gets called although its a complex property and not an independent Entity. Why is it getting called on a complex property
// .Map<SkuRangePricing, AuditLog>((shipper, entry, auditLog) =>
                    // {
                    //     return true;
                    // })
hassanrazakhalid commented 1 month ago

I'm sharing my findings, may be it will give you a clue where things are going wrong. If still, a minimal project is required. Let me know.

thepirat000 commented 1 month ago

The Override and Format methods are not applied to navigation properties (or owned/complex properties of the owner entity) because they rely on EF Core change tracker entries. In your scenario, the EF change tracker will create separate entries for ShipperPricing and SkuRangePricing, even though the latter is defined as an owned entity in the same table. Internally, EF treats them as distinct entities.

The Map<SkuRangePricing, AuditLog> action is executed for the same reason.

To address the issue mentioned in your comment // To fix audit infinity cycle in case of DbUpdateException, you can resolve it by using a separate instance of your DbContext.

You can invoke UseDbContext to define a new DbContext instance like this:

Audit.Core.Configuration.Setup()
   .UseEntityFramework(ef => ef
       .UseDbContext(ctx => ctx.GetEntityFrameworkEvent().GetDbContext().GetService<AppDbContext>())
   );

Ultimately, the solution depends on your specific implementation and requirements, which are not entirely clear. If you need further assistance, please provide a minimal sample project that reproduces the issue, along with details about your expected audit output.

ross1296 commented 3 weeks ago

Thought I'd piggy back on this open issue instead of opening another, very similar one.

@thepirat000 Is there no way to bundle parent and owned entities together in the one AuditEvent output?

So, for example, let's say I have a Customer entity which has a "PrimaryAddress" and "SecondaryAddress" both of which are type "CustomerAddress" which itself is a Value Object (C# record):

Updating the addresses will produce a DELETE and INSERT record in the audit_logs table for the changes, and there are no entries in the Customer Changes[] array. This is true even if there are no changes to the address. A DELETE and INSERT will still be added to the audit_logs table even if changes are only made to the parent entity.

Guessing there is no way to have the owned entities also show up in the Changes[] array? It's slightly problematic because the entity types in the audit_logs entity_type column are different. The customer is Customer and the addresses are CustomerAddress, which means querying the audit log for reconstruction and displaying on the FE becomes cumbersome depending on how many owned entities there are.

Cheers for any clarification!

thepirat000 commented 2 weeks ago

Actually, the parent and owned entities modified together are included in the same AuditEvent. I believe you mean having the entries grouped within the same audit event.

There isn't an automatic built-in way to achieve this, but you can always manipulate the output with a custom action, for example:

using Audit.EntityFramework;

Audit.Core.Configuration.AddOnCreatedAction(scope =>
{
    var efEvent = scope.GetEntityFrameworkEvent();

    efEvent.Entries = ReArrangeEntries(efEvent.Entries);
});
ross1296 commented 2 weeks ago

Actually, the parent and owned entities modified together are included in the same AuditEvent. I believe you mean having the entries grouped within the same audit event.

There isn't an automatic built-in way to achieve this, but you can always manipulate the output with a custom action, for example:

using Audit.EntityFramework;

Audit.Core.Configuration.AddOnCreatedAction(scope =>
{
    var efEvent = scope.GetEntityFrameworkEvent();

    efEvent.Entries = ReArrangeEntries(efEvent.Entries);
});

My apologies, you're right. Thank you, I'll explore this further :)