Update modified date when referenced entities are edited

82 Views Asked by At

I would like to have any automatic creation and modified column for one of the classes being stored by entity framework.

I found this neat solution which gets me half way there.

https://stackoverflow.com/a/53669556/392485 (I am unsure on policy if I should be including a selection of the answer here for clarity seeing as it is not on an external site)

However in my data structure I have child related entities that if modified I would like to update the parents modified date, simplified version of my data structure below.

public class Recipe : IAuditableModel
{
    [Key]
    public int Id { get; set; }

    public string Name { get; set; }
    public string Description { get; set; }

    public DateTime Created { get; set; } = DateTime.Now;
    public DateTime? Modified { get; set; }

    public ObservableCollection<Step> Steps { get; set; } = new ObservableCollection<Step>();
}

public class Step
{
    [Key]
    public int Id { get; set; }
    
    public string Name { get; set; }
}

Using the above solution Modified would never be updated if the number of steps was changed or the information in each step.

The Step entity will only ever be modified when accessed via the Recipe entity.

I can see how I could further modify the SaveChanges override to handle this particular case, I was hoping for a cleaner solution extending upon Linked IAuditableModel where it would also update modified if any of the objects navigation properties are modified.

I did see the DbEntityEntry.Collection(string) method but couldn't see how one might find the names of the collections to feed this function.

Alternately I was wondering if I should be trying to side step the issue by giving up having steps as their own entity and have them serialized directly into the Recipe entity. Doing this would mean I lose out on some of the neat migration features if I do make changes to the Step class in the future.

1

There are 1 best solutions below

3
Maik Hasler On

I would highly recommend using a custom SaveChangesInterceptor for handling tasks like updating audit-related properties.

The SaveChanges interception feature was announced with Entity Framework Core 5. Here you can read more about interceptors.

Entity Framework Core (EF Core) interceptors enable interception, modification, and/or suppression of EF Core operations. This includes low-level database operations such as executing a command, as well as higher-level operations, such as calls to SaveChanges.

Here's an example implementation demonstrating what your custom SaveChangesInterceptor might look like:

internal sealed class AuditableEntityInterceptor
    : SaveChangesInterceptor
{
    public override InterceptionResult<int> SavingChanges(
        DbContextEventData eventData,
        InterceptionResult<int> result)
    {
        UpdateEntities(eventData.Context);

        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(
        DbContextEventData eventData,
        InterceptionResult<int> result,
        CancellationToken cancellationToken = default)
    {
        UpdateEntities(eventData.Context);

        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }

    public void UpdateEntities(
        DbContext? context)
    {
        if (context == null) return;

        foreach (var entry in context.ChangeTracker.Entries<IAuditableModel>())
        {
            if (entry.State == EntityState.Added)
            {
                entry.Property(nameof(IAuditableModel.Created)).CurrentValue = DateTime.UtcNow;
            }

            if (entry.State == EntityState.Added || entry.State == EntityState.Modified || entry.HasChangedOwnedEntities())
            {
                entry.Property(nameof(IAuditableModel.Modified)).CurrentValue = DateTime.UtcNow;
            }
        }
    }
}

You'll also need this extension method, which checks if any owned entity has been changed.

public static class EntityEntryExtensions
{
    public static bool HasChangedOwnedEntities(
        this EntityEntry entry)
    {
        return entry.References.Any(r =>
            r.TargetEntry != null &&
            r.TargetEntry.Metadata.IsOwned() &&
            (r.TargetEntry.State == EntityState.Added || r.TargetEntry.State == EntityState.Modified));
    }
}

At the end you also have to modify your service registration:

services.AddScoped<ISaveChangesInterceptor, AuditableEntityInterceptor>();

services.AddDbContext<ApplicationDbContext>((sp, options) =>
{
    var interceptors = sp.GetServices<ISaveChangesInterceptor>();

    options.AddInterceptors(interceptors);

    options.UseMyProvider(connectionString);
});

Edit: It is fairly easy to get rid of Any():

public static class EntityEntryExtensions
{
    public static bool HasChangedOwnedEntities(this EntityEntry entry)
    {
        foreach (var reference in entry.References)
        {
            if (reference.TargetEntry != null &&
                reference.TargetEntry.Metadata.IsOwned() &&
                (reference.TargetEntry.State == EntityState.Added || reference.TargetEntry.State == EntityState.Modified))
            {
                return true;
            }
        }

        return false;
    }
}