11

Looking forward to build a framework, (No repository pattern to working with DbSets directly) to autopopulate Created and last modified automatically, rather than spitting out these codes through out code base.

Could you point me out in right direction how to achieve it.

In past I tried populating these in constructors, however that seems like a nasty code and every time we pull up somting from database EF change tracking will mark the entity as modified.

.ctor()
    {
        Created = DateTime.Now;
        LastModified = DateTime.Now;
    }

public interface IHasCreationLastModified
    {
        DateTime Created { get; set; }
        DateTime? LastModified { get; set; }
    }

public class Account : IEntity, IHasCreationLastModified
{
    public long Id { get; set; }

    public DateTime Created { get; set; }
    public DateTime? LastModified { get; set; }
    public virtual IdentityUser IdentityUser { get; set; }
}
Ivan Stoev
  • 195,425
  • 15
  • 312
  • 343
Abhijeet
  • 13,562
  • 26
  • 94
  • 175
  • I used to have an opinion that a database trigger is the most accurate and appropriate way to populate such fields. Not any more. It's a conviction now. – Gert Arnold Nov 11 '18 at 19:17

3 Answers3

29

Starting with v2.1, EF Core provides State change events:

New Tracked And StateChanged events on ChangeTracker can be used to write logic that reacts to entities entering the DbContext or changing their state.

You can subscribe to these events from inside your DbContext constructor

ChangeTracker.Tracked += OnEntityTracked;
ChangeTracker.StateChanged += OnEntityStateChanged;

and do something like this:

void OnEntityTracked(object sender, EntityTrackedEventArgs e)
{
    if (!e.FromQuery && e.Entry.State == EntityState.Added && e.Entry.Entity is IHasCreationLastModified entity)
        entity.Created = DateTime.Now;
}

void OnEntityStateChanged(object sender, EntityStateChangedEventArgs e)
{
    if (e.NewState == EntityState.Modified && e.Entry.Entity is IHasCreationLastModified entity)
        entity.LastModified = DateTime.Now;
}
Ivan Stoev
  • 195,425
  • 15
  • 312
  • 343
  • This is a nice solution. But we need to leave the properties public and cannot prevent changing them from the code. I am searching right now for a safe solution preventing manipulations with created date. – Tolbxela Dec 09 '20 at 17:36
  • 3
    @Tolbxela There are many ways to do that, for instance making the property get only (or even shadow) and let EF Core manipulate it through backing field. e.g. instead of `entity.Created = DateTime.Now;` one can use `e.Entry.CurrentValues["Created"] = DateTime.Now;` etc. – Ivan Stoev Dec 09 '20 at 17:50
  • Almost there, but you neet to use only `Tracked` event for both cases `if (e.Entry.Entity is IDateTrackable entity && (e.Entry.State == EntityState.Added || e.Entry.State == EntityState.Modified))` – Andrii Jan 24 '22 at 17:58
6

One possible solution (the one we're currently using where I work) is to override the SaveChanges() method in the DbContext and add a little bit of code to loop through changed entities setting the values

public override int SaveChanges()
{
    var changedEntriesCopy = ChangeTracker.Entries()
                .Where(e => e.State == EntityState.Added ||
                e.State == EntityState.Modified ||
                e.State == EntityState.Deleted)
                .ToList();
    var saveTime = DateTime.Now;

        foreach (var entityEntry in changedEntriesCopy)
        {
            if (entityEntry.Metadata.FindProperty("Created") != null && entityEntry.Property("Created").CurrentValue == null)
            {
                entityEntry.Property("Created").CurrentValue = saveTime;
            }

            if (entityEntry.Metadata.FindProperty("Updated") != null)
            {
                entityEntry.Property("Updated").CurrentValue = saveTime;
            }
        }
    return base.SaveChanges();
}

Our situation is basically set up similar to that - Created in our situation is nullable so that when a new entity is created the value is null in code up to creation (makes it easy to check if Created has been populated ever).

There may be other solutions, but this was the easiest to get set up for us, and hasnt had any noticeable performance impact

Gibbon
  • 2,633
  • 1
  • 13
  • 19
  • 2
    thanks +1, had to mark other answer as correct, as that was more elegant way to do for `EF Core`. – Abhijeet Nov 14 '18 at 06:04
  • 1
    @Abhijeet Yeah, I didnt actually know that was added in the 2.1 release, but it does seem like a preferable solution :) – Gibbon Nov 14 '18 at 08:24
0

SaveChangesInterceptor

In EF7, I didn't have much success with ChangeTracker.StateChanged so I used a SaveChangesInterceptor instead...

public class AtFieldsCalculationInterceptor : SaveChangesInterceptor
{
    public override InterceptionResult<int> SavingChanges(DbContextEventData eventData, InterceptionResult<int> result)
    {
        BeforeSaveTriggers(eventData.Context!);
        
        return base.SavingChanges(eventData, result);
    }

    public override ValueTask<InterceptionResult<int>> SavingChangesAsync(DbContextEventData eventData, 
        InterceptionResult<int> result, CancellationToken cancellationToken = new ())
    {
        BeforeSaveTriggers(eventData.Context!);
        
        return base.SavingChangesAsync(eventData, result, cancellationToken);
    }
    
    private void BeforeSaveTriggers(DbContext context)
    {
        var now = new Lazy<DateTime>(() =>
        {
            // Database has millisecond accuracy only
            var d = DateTime.UtcNow;
            return new(d.Year, d.Month, d.Day, d.Hour, d.Minute, d.Second, d.Millisecond, d.Kind);
        });
        
        foreach (var entry in context.ChangeTracker.Entries())
        {
            if (entry.Entity is BaseEntity baseEntity)
            {
                if (entry.State == EntityState.Added)
                {
                    baseEntity.CreatedAt = now.Value;
                    baseEntity.UpdatedAt = now.Value;
                }
                else if (entry.State == EntityState.Modified)
                {
                    baseEntity.UpdatedAt = now.Value;
                }
            }
        }
    }    
}

Use this in your DbContext.OnConfiguring() override:

        optionsBuilder.AddInterceptors(new AtFieldsCalculationInterceptor());
Peter L
  • 2,921
  • 1
  • 29
  • 31