Skip to content

A plug-in for Microsoft.EntityFrameworkCore to generate historical records of data changes, with support for inserting, updating and deleting.

License

Notifications You must be signed in to change notification settings

eglauko/AutoHistoryFork

 
 

Repository files navigation

AutoHistoryFork

A plugin for Microsoft.EntityFrameworkCore to support automatically recording data changes history.

This fork works for added entities.

Version 9.0 (Microsoft.EntityFrameworkCore.AutoHistoryFork 9.x) supports .NET 8.0 and .NET 9.0 (with their respective EF Core 8/9 versions).

For applications targeting .NET 5.0, .NET 6.0 or .NET 7.0, use package version 7.x of this fork, which supports those frameworks and their corresponding EF Core versions.


Breaking change (v9+): generic type parameter required on EnableAutoHistory

Starting with this version the extension methods modelBuilder.EnableAutoHistory() require at least the generic parameter for the DbContext:

Before (v7.x / old API):

modelBuilder.EnableAutoHistory();
modelBuilder.EnableAutoHistory(options => { /*...*/ });
modelBuilder.EnableAutoHistory<CustomAutoHistory>(options => { /*...*/ });

Now (v9+):

modelBuilder.EnableAutoHistory<BloggingContext>();
modelBuilder.EnableAutoHistory<BloggingContext>(options => { /*...*/ });
modelBuilder.EnableAutoHistory<BloggingContext, CustomAutoHistory>(options => { /*...*/ });

The first generic parameter is ALWAYS your DbContext type. The second (optional) is the custom type that inherits from AutoHistory.


How to use

AutoHistoryFork will record all the data changing history in one table named AutoHistories. This table will record data UPDATE, DELETE history and, optionally, ADD history.

This fork adds two additional fields to the original version: UserName and ApplicationName (and now also optional GroupId).

1. Install package

PM> Install-Package Microsoft.EntityFrameworkCore.AutoHistoryFork

2. Enable AutoHistory

Use the generic method providing your DbContext type:

public class BloggingContext : DbContext
{
    public BloggingContext(DbContextOptions<BloggingContext> options) : base(options) {}

    public DbSet<Blog> Blogs { get; set; }
    public DbSet<Post> Posts { get; set; }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        // enable auto history functionality (minimal)
        modelBuilder.EnableAutoHistory<BloggingContext>();
    }
}

3. Ensure AutoHistory before SaveChanges

bloggingContext.EnsureAutoHistory(); // Modified & Deleted entities

4. Automatically record Modified/Deleted in overridden SaveChanges

public class BloggingContext : DbContext
{
    public BloggingContext(DbContextOptions<BloggingContext> options) : base(options) {}

    public override int SaveChanges()
    {
        this.EnsureAutoHistory(); // Modified & Deleted
        return base.SaveChanges();
    }

    public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
    {
        this.EnsureAutoHistory();
        return base.SaveChangesAsync(cancellationToken);
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.EnableAutoHistory<BloggingContext>();
    }
}

5. Recording Added entities (two-phase save)

public class BloggingContext : DbContext
{
    public override int SaveChanges()
    {
        var addedEntities = ChangeTracker
            .Entries()
            .Where(e => e.State == EntityState.Added)
            .ToArray();

        var hasAdded = addedEntities.Any();
        IDbContextTransaction? transaction = null;
        if (hasAdded)
            transaction = Database.BeginTransaction();

        // First ensure history for Modified/Deleted before persistence changes states
        this.EnsureAutoHistory();
        int changes = base.SaveChanges();

        if (hasAdded)
        {
            // Now Added entities have real keys -> create Added history
            this.EnsureAddedHistory(addedEntities);
            changes += base.SaveChanges();
            transaction!.Commit();
        }

        return changes;
    }

    protected override void OnModelCreating(ModelBuilder modelBuilder)
    {
        modelBuilder.EnableAutoHistory<BloggingContext>();
    }
}

Use Current User Name

public override int SaveChanges()
{
    this.EnsureAutoHistory(currentUserName);
    return base.SaveChanges();
}

Added entities:

this.EnsureAddedHistory(addedEntities, currentUserName);

Using a separate DbContext for saving history

public override int SaveChanges()
{
    var addedEntities = ChangeTracker.Entries().Where(e => e.State == EntityState.Added).ToArray();

    // write Modified/Deleted history to an external history context
    this.EnsureAutoHistory(historyDbContext, currentUserName);
    var changes = base.SaveChanges();

    historyDbContext.EnsureAddedHistory(addedEntities, currentUserName);
    historyDbContext.TrySaveChanges(); // (custom extension you implement)

    return changes;
}

Application Name

Configure via parameter or options. Remember to specify the DbContext type now.

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.EnableAutoHistory<BloggingContext>("MyApplicationName");
}

Disable mapping column:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.EnableAutoHistory<BloggingContext>(options =>
    {
        options.MapApplicationName = false;
    });
}

Or keep column & pass fixed name:

modelBuilder.EnableAutoHistory<BloggingContext>("MyFixedAppName");

Custom AutoHistory entity

class CustomAutoHistory : AutoHistory
{
    public string CustomField { get; set; }
}

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.EnableAutoHistory<BloggingContext, CustomAutoHistory>(options => { });
}

Supplying custom factory when ensuring history:

db.EnsureAutoHistory<BloggingContext, CustomAutoHistory>(() => new CustomAutoHistory
{
    CustomField = "CustomValue"
});

The properties inherited from AutoHistory are automatically populated by the framework.

For Added entities (same overload pattern already available):

this.EnsureAddedHistory<BloggingContext, CustomAutoHistory>(() => new CustomAutoHistory { CustomField = "X" }, addedEntries);

Excluding properties / entities from history

Four ways:

  1. Attribute on property [ExcludeFromHistory]
  2. Attribute on the class (excludes the entire entity)
  3. Fluent API to exclude a property (WithExcludeProperty)
  4. Fluent API to exclude an entity (WithExcludeFromHistory)

Example:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.EnableAutoHistory<BloggingContext>(options => options
        .ConfigureType<Blog>(t =>
        {
            t.WithExcludeProperty(b => b.ExcludedProperty);
        })
        .ConfigureType<NotTracked2>(t => t.WithExcludeFromHistory()));
}

public class Blog
{
    public int BlogId { get; set; }
    public string Url { get; set; }
    [ExcludeFromHistory]
    public string PrivateURL { get; set; }
    public string ExcludedProperty { get; set; }
    public List<Post> Posts { get; set; }
}

[ExcludeFromHistory]
public class NotTracked { /* ... */ }
public class NotTracked2 { /* ... */ }

Rules (summary):

  • If only excluded properties changed, no history entry is generated.
  • An entirely excluded entity never generates history.
  • Final exclusion set = union of attributes + fluent configuration.

GroupId (Grouping related entity histories)

Allows grouping (e.g., Blog + Posts) by sharing a logical identifier.

Enable globally and configure per type:

protected override void OnModelCreating(ModelBuilder modelBuilder)
{
    modelBuilder.EnableAutoHistory<BloggingContext>(options => options
        .WithGroupId(true)
        .ConfigureType<Blog>(t => t.WithGroupProperty(nameof(Blog.BlogId)))
        .ConfigureType<Post>(t => t.WithGroupProperty(nameof(Post.BlogId))));
}

During SaveChanges (including Added):

public override int SaveChanges()
{
    var added = ChangeTracker.Entries().Where(e => e.State == EntityState.Added).ToArray();
    var hasAdded = added.Any();
    IDbContextTransaction? tx = null;
    if (hasAdded) tx = Database.BeginTransaction();

    this.EnsureAutoHistory(); // Modified & Deleted
    var changes = base.SaveChanges();

    if (hasAdded)
    {
        this.EnsureAddedHistory(added);
        changes += base.SaveChanges();
        tx!.Commit();
    }
    return changes;
}

Grouped query:

var blogId = 42;
var grouped = context.Set<AutoHistory>()
    .Where(h => h.GroupId == blogId.ToString())
    .OrderBy(h => h.ChangedOn)
    .ToList();

Fallback: if WithGroupId was not enabled or a type did not set WithGroupProperty, GroupId remains null.


ChangedHistory format

ChangedHistory is a dictionary: PropertyName -> string[].

  • Added: array with 1 slot (new value)
  • Deleted: array with 1 slot (old value)
  • Modified: array with 2 slots ([old, new]) Serialized as JSON in AutoHistory.Changed.

Changes vs original (Microsoft.EntityFrameworkCore.AutoHistory 6.0.0)

  • Functional support for Added entities (EnsureAddedHistory).
  • Additional fields: UserName, ApplicationName, GroupId (optional via configuration).
  • Default size of Changed expanded to 8000.
  • New options in AutoHistoryOptions: ApplicationName, DateTimeFactory, UserNameMaxLength, ApplicationNameMaxLength, MapApplicationName, UseGroupId and per-type configurations (ConfigureType).
  • New JSON format (ChangedHistory).
  • EnsureAutoHistory can receive userName and/or another DbContext to persist history.
  • (v9+) API EnableAutoHistory now requires the DbContext type as a generic parameter.

Migration tip (from older non-generic EnableAutoHistory)

  1. Add the context type: modelBuilder.EnableAutoHistory<BloggingContext>();
  2. For custom type: modelBuilder.EnableAutoHistory<BloggingContext, CustomAutoHistory>(opts => { ... });
  3. Verify if any old internal documentation snippet still uses the old form.
  4. Recreate migrations if the change comes with new columns (e.g., GroupId).

Example summary

Scenario Snippet
Basic modelBuilder.EnableAutoHistory<BloggingContext>();
With options modelBuilder.EnableAutoHistory<BloggingContext>(o => o.WithGroupId());
Custom entity modelBuilder.EnableAutoHistory<BloggingContext, CustomAutoHistory>(o => { });
Application name modelBuilder.EnableAutoHistory<BloggingContext>("AppName");
Disable AppName column modelBuilder.EnableAutoHistory<BloggingContext>(o => o.MapApplicationName = false);
GroupId + per type modelBuilder.EnableAutoHistory<BloggingContext>(o => o.WithGroupId().ConfigureType<Blog>(t=>t.WithGroupProperty(nameof(Blog.BlogId))));

Glossary

  • EnsureAutoHistory: Generates history for Modified/Deleted before SaveChanges.
  • EnsureAddedHistory: Generates history for Added after SaveChanges (when keys are available).
  • GroupId: Optional field to group history of related entities.
  • ChangedHistory: Structure used to store the changes (JSON).

License

(Add license information here, if applicable)

About

A plug-in for Microsoft.EntityFrameworkCore to generate historical records of data changes, with support for inserting, updating and deleting.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • C# 98.6%
  • Batchfile 1.4%