Skip to content
kambalinPublic

About

entity framework data manager

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Repository files navigation

EFDM — Entity Framework Data Manager

NuGet Status NuGet Count License: MIT

A thin, opinionated layer on top of EF Core that removes the boilerplate most data-access code repeats:

  • Queries as objects. Filtering lives in typed *Query classes instead of Where(...) calls scattered across services. Queries are serializable, composable and reusable — send one from an API controller straight to a domain service.
  • Repository + domain service. CRUD, paging, projections, soft delete, transactions and detached-graph saves are already implemented — you only add the logic that is actually yours.
  • Field-level audit. Writes who / what / when / old → new into your own audit tables, including bulk ExecuteUpdate / ExecuteDelete operations, which EF Core normally performs without touching the change tracker.
  • Automatic audit stamps. Created, Modified, CreatedById, ModifiedById are filled on save, without a single line in your service.

Contents


Requirements & install

Target framework .NET 10
EF Core 10.x
Databases anything EF Core supports (samples ship SQL Server + PostgreSQL)
dotnet add package EFDM.Core

The single EFDM.Core package ships three assemblies:

Assembly What is inside
EFDM.Abstractions Interfaces only: IRepository<,>, IDomainService<,,>, IDataQuery<,>, IAuditSettings, …
EFDM.Core Entity base classes, query base classes, QueryFilter, DomainServiceBase, AuditSettings
EFDM.DAL EFDMDatabaseContext, Repository<,>, DBContextAuditor — the EF Core-facing part

Quick start

Six short steps take you from an empty project to a working data layer. Auditing is optional and has its own section.

1. Describe the entity

Pick a base class that matches the shape of your table (full list). DictDeletableEntityBase<int> gives you Id, Title, IsDeleted and the audit stamps.

using EFDM.Core.Models.Domain;

public class Group : DictDeletableEntityBase<int>
{
    public int TypeId { get; set; }
    public virtual GroupType Type { get; set; } = default!;
    public virtual ICollection<GroupUser> Users { get; set; } = default!;
}

Map it with a regular EF Core IEntityTypeConfiguration<Group> — EFDM does not interfere with your mapping.

2. Describe the query

Everything a caller may filter by becomes a property. ToFilter() turns the properties that were actually set into an expression tree.

using EFDM.Abstractions.DataQueries;
using EFDM.Core.DataQueries;

public class GroupQuery : DictIntDeletableDataQuery<Group>   // Ids, IsDeleted, Title*, paging, sorting
{
    public int[]? TypeIds { get; set; }
    public int[]? UserIds { get; set; }

    public override IQueryFilter<Group> ToFilter()
    {
        var and = new QueryFilter<Group>();

        if (TypeIds?.Any() == true)
            and.Add(x => TypeIds.Contains(x.TypeId));

        if (UserIds?.Any() == true)
            and.Add(x => x.Users.Any(gu => UserIds.Contains(gu.UserId)));

        return base.ToFilter().Add(and);   // never drop the inherited filters
    }
}

3. Describe the service

DomainServiceBase already implements the full CRUD surface, so an empty class is a perfectly valid service. Add methods only when you have real domain logic.

public interface IGroupService : IDomainService<Group, GroupQuery, int>
{
    Task AddUserAsync(int groupId, int userId, CancellationToken ct = default);
}

public class GroupService
    : DomainServiceBase<Group, GroupQuery, int, IRepository<Group, int>>, IGroupService
{
    private readonly IRepository<User, int> _userRepo;

    public GroupService(IRepository<Group, int> repository, IRepository<User, int> userRepo, ILogger logger)
        : base(repository, logger)
    {
        _userRepo = userRepo ?? throw new ArgumentNullException(nameof(userRepo));
    }

    public async Task AddUserAsync(int groupId, int userId, CancellationToken ct = default)
    {
        var user = (await _userRepo.FetchAsync(new UserQuery
        {
            Ids = [userId],
            IsDeleted = false,
            Includes = [nameof(User.Groups)],
            Take = 1
        }, tracking: true, ct)).FirstOrDefault()
            ?? throw new InvalidOperationException($"User '{userId}' not found");

        if (user.Groups.Any(g => g.GroupId == groupId))
            return;

        user.Groups.Add(new GroupUser { GroupId = groupId, UserId = userId });
        await _userRepo.SaveAsync(user, ct);
    }
}

4. Describe the DbContext

Inherit from EFDMDatabaseContext. Two members are required: ExecutorId (the user id stamped into CreatedById / ModifiedById) and InitAuditMapping() (leave it empty until you need audit).

using EFDM.DAL.Providers;

public class AppDbContext : EFDMDatabaseContext
{
    public override int ExecutorId { get; protected set; } = UserValues.SystemId;

    public DbSet<Group> Groups { get; set; }
    public DbSet<User> Users { get; set; }

    public AppDbContext(DbContextOptions<AppDbContext> options, ILoggerFactory? factory = null,
        IAuditSettings? auditSettings = null, Action<ModelConfigurationBuilder>? conventionsAction = null)
        : base(options, factory, auditSettings, conventionsAction) { }

    protected override void OnModelCreating(ModelBuilder builder)
    {
        base.OnModelCreating(builder);
        builder.ApplyConfigurationsFromAssembly(Assembly.GetAssembly(GetType())!);
    }

    public override void InitAuditMapping() { }   // see the Auditing section
}

Call SetExecutor(currentUserId) per request (e.g. in middleware) so audit stamps point at the real user.

5. Register everything

services.AddScoped(sp => new AppDbContext(
    new DbContextOptionsBuilder<AppDbContext>()
        .UseSqlServer(configuration.GetConnectionString("app"))
        .Options,
    sp.GetService<ILoggerFactory>()));

// the repository resolves EFDMDatabaseContext, so expose your context as the base type too
services.AddScoped<EFDMDatabaseContext>(sp => sp.GetRequiredService<AppDbContext>());
services.AddScoped(typeof(IRepository<,>), typeof(Repository<,>));

services.AddScoped<IGroupService, GroupService>();

6. Use it

var groups = await groupSvc.FetchAsync(new GroupQuery
{
    TypeIds  = [GroupTypeValues.Users],
    IsDeleted = false,
    Includes = [nameof(Group.Type), $"{nameof(Group.Users)}.{nameof(GroupUser.User)}"],
    Sorts    = [new Sort { Field = nameof(Group.Title) }],
    Take     = 20
});

var group = new Group { Title = "Analysts", TypeId = GroupTypeValues.Users };
await groupSvc.SaveAsync(group);          // Created/Modified/…ById are stamped automatically
Console.WriteLine(group.Id);              // identity is back on the instance

await groupSvc.DeleteAsync(group.Id);     // IsDeleted = true (soft delete), row is kept

Building blocks

Entity base classes

All of them live in EFDM.Core.Models.Domain and are generic over the key type (int, long, Guid, …).

Base class Id Created/Modified + …ById IsDeleted Title
IdKeyEntityBase<TKey> ✔
IdKeyDeletableEntityBase<TKey> ✔ ✔
EntityBase<TKey> ✔ ✔
DeletableEntityBase<TKey> ✔ ✔ ✔
DictEntityBase<TKey> ✔ ✔ ✔
DictDeletableEntityBase<TKey> ✔ ✔ ✔ ✔

Extras you get for free:

  • IsNew — true while Id still holds its default value.
  • PreserveLastModified, PreserveLastModifiedBy, PreserveLastModifiedFields — [NotMapped] flags. Set one before saving and EFDM will not overwrite the corresponding Modified* value (useful for imports and migrations that must keep the original timestamps).
  • On update, Created and CreatedById are automatically marked as not modified, so they can never be overwritten by a stale value carried on a detached entity.

Prefer to define one project-local base class per key type (public abstract class DictIntDeletableEntity : DictDeletableEntityBase<int>) — the sample does exactly that, and it keeps entity declarations short.

The base classes expose CreatedById / ModifiedById as plain columns; CreatedBy / ModifiedBy are declared through IAuditablePrincipalEntity and typed as IUser, which EF Core cannot map. If you want real navigation properties (so Includes = [nameof(Group.CreatedBy)] and Sorts = [new Sort { Field = "CreatedBy.Title" }] work), re-declare them on your own base class with your concrete user type:

public abstract class DomainEntityBase<TKey> : EntityBase<TKey>, IAuditableUserEntity
    where TKey : IComparable, IEquatable<TKey>
{
    public User CreatedBy { get; set; } = default!;
    public User ModifiedBy { get; set; } = default!;
}

The sample then maps both relations for every entity implementing that interface in one pass — see AuditableUserEntityConfig<T> and the builder.ApplyConfiguration<IAuditableUserEntity>(...) loop in TestDatabaseContext.OnModelCreating.

Query base classes

All of them live in EFDM.Core.DataQueries. Each level adds filter properties that are applied by its own ToFilter():

Base class Adds
DataQueryBase<TModel> Take, Skip, Sorts, Includes, SplitQuery
IdKeyDataQueryBase<TModel, TKey> Ids, NotIds
EntityDataQueryBase<TModel, TKey> CreatedByIds, ModifiedByIds, CreatedQueryParams, ModifiedQueryParams
DeletableEntityDataQueryBase<TModel, TKey> IsDeleted
DictDataQueryBase<TModel, TKey> Title, TitleContains, TitleSplitContains
DictDeletableDataQueryBase<TModel, TKey> IsDeleted, Title, TitleContains, TitleSplitContains
DictIntDataQuery<TModel> / DictIntDeletableDataQuery<TModel> ready-made int-keyed shortcuts

Created/Modified are filtered through DateOffsetPeriodQueryParams (MoreOrEquals, LessOrEquals, OrIsNull):

var query = new GroupQuery
{
    CreatedQueryParams = new DateOffsetPeriodQueryParams
    {
        MoreOrEquals = DateTimeOffset.Now.AddDays(-7)
    }
};

TitleContains matches the whole string; TitleSplitContains splits the input on spaces and requires every word to be present — a cheap "search box" behaviour.


Querying

Writing a query class

Two rules cover almost everything:

  1. Only add a condition when the property is set. An unset property must not narrow the result.
  2. Always chain onto base.ToFilter(), otherwise Ids, IsDeleted, Title, … silently stop working.
public override IQueryFilter<User> ToFilter()
{
    var and = new QueryFilter<User>();

    if (Logins?.Any() == true)
    {
        var lcLogins = Logins.Select(x => x.ToLower()).ToArray();
        and.Add(x => lcLogins.Contains(x.Login.ToLower()));
    }

    if (GroupId.HasValue)
        and.Add(x => x.Groups.Any(g => g.GroupId == GroupId));

    return base.ToFilter().Add(and);
}

Combining filters: AND / OR

QueryFilter<T> is a small tree. Expressions inside one node are combined with its own operator; child nodes are nested as brackets.

Call Meaning
new QueryFilter<T>() AND node (default)
new QueryFilter<T>(or: true) OR node
.Add(x => …) add a condition to the current node
.Add(childFilter) nest another filter
.And(node => …) nest an AND group
.Or(node => …) nest an OR group

A full-text-ish search — every word must match, but each word may match any of three columns, i.e. (login~w1 OR title~w1 OR email~w1) AND (login~w2 OR …):

if (!string.IsNullOrEmpty(Text))
    foreach (var word in GetWords(Text))
        and.Or(or => or
            .Add(x => x.Login.Contains(word))
            .Add(x => x.Title.Contains(word))
            .Add(x => x.Email.Contains(word))
        );

Queries are executed through LinqKit's AsExpandable(), so PredicateBuilder and Invoke(...) compose exactly as expected and still translate to SQL:

Expression<Func<TaskAnswer, bool>> predicate = PredicateBuilder.New<TaskAnswer>(true);
predicate = predicate.And(x => x.ValidFrom >= from);
predicate = predicate.Or(x => x.ValidFrom == null);
and.Add(x => predicate.Invoke(x));

Sorting, includes, paging

var query = new GroupQuery
{
    Includes = [
        nameof(Group.Type),                                          // navigation
        $"{nameof(Group.Users)}.{nameof(GroupUser.User)}"            // nested navigation
    ],
    Sorts = [
        new Sort { Field = "CreatedBy.Title" },                      // nested path, ascending
        new Sort { Field = nameof(Group.Id), Desc = true }            // then by Id descending
    ],
    SplitQuery = true,                                               // AsSplitQuery() for collection includes
    Skip = 40,
    Take = 20
};
  • Default sorting is Id DESC when Sorts is empty — results are always deterministic.
  • Take = 0 (the default) means no limit; Skip = 0 means no offset.
  • Tracking is off by default. Pass tracking: true when you intend to modify what you fetched.
  • SplitQuery = true avoids the cartesian explosion when several collection navigations are included.

Paged results come back with the total count attached:

IPagedList<Group> page = await groupSvc.FetchPagedAsync(new GroupQuery { Skip = 40, Take = 20 });
// page.Items, page.TotalCount, page.Skipped

FetchPaged derives TotalCount from the page itself whenever the page proves it (no paging at all, or a partially filled last page) and only then falls back to a second COUNT query — one round-trip fewer, and TotalCount can never contradict Items.

Lightweight projections

When you only need a few columns, *Lite methods project inside SQL instead of materialising whole rows:

// explicit selector
var titles = await groupSvc.FetchLiteAsync(query, x => new Group { Id = x.Id, Title = x.Title });

// or the service default (LiteSelector — Id only unless you override it)
var refs = await groupSvc.FetchLiteAsync(query);

// just the keys
IEnumerable<int> ids = await groupSvc.FetchIdsAsync(query);

Override LiteSelector in your service to define the "reference shape" of the entity once:

public class GroupService : DomainServiceBase<Group, GroupQuery, int, IRepository<Group, int>>
{
    public GroupService(IRepository<Group, int> repository, ILogger logger) : base(repository, logger)
    {
        LiteSelector = x => new Group { Id = x.Id, Title = x.Title };
    }
}

Projected instances are not tracked by EF; when you pass tracking: true, EFDM attaches them for you (unless an entity with the same key is already tracked).


Services & repositories

IDomainService<TModel, TQuery, TKey> is what your application code talks to; IRepository<TEntity, TKey> is the lower-level API underneath it (and is what you inject when a service needs to touch a different entity).

Category Members
Read many FetchAsync, FetchPagedAsync, FetchLiteAsync, FetchIdsAsync, CountAsync
Read one GetAsync, GetByIdAsync, GetByIdsAsync, GetLiteAsync, GetLiteByIdAsync, ExistsAsync
Write AddAsync, SaveAsync, SaveChangesAsync
Delete DeleteAsync(id | entity | entities, forceDeleteEvenDeletable)
Bulk ExecuteUpdateAsync, ExecuteDeleteAsync
Plumbing BeginTransaction(Async), ClearChangeTracker, AutoDetectChangesEnabled, ExecutorId, QueryableSql (repository)

Every method has a synchronous twin (Fetch, Save, Delete, …) and every async one accepts a CancellationToken.

Two conventions worth knowing:

  • TQuery is instantiated internally by GetByIdAsync / ExistsAsync, so query classes need a public parameterless constructor.
  • DeleteValidation(model) and IsCreator(model, userId) are virtual hooks — override them to plug in your own rules.

Saving and deleting

await svc.SaveAsync(entity);        // insert when Id is default, update otherwise
await svc.AddAsync(e1, e2, e3);     // stage several inserts…
await svc.SaveChangesAsync();       // …and flush them in one round-trip

Detached graphs just work. Repository.SaveAsync detects a detached instance, loads the stored row together with its collection navigations, applies scalar values, and synchronises collections (adds new items, updates matched ones, removes the missing ones) — so an entity coming from an HTTP request body can be saved directly.

Partial updates — write exactly the listed properties and nothing else:

await userRepo.SaveAsync(user, ct, x => x.Email, x => x.Phone);

Soft delete. DeleteAsync sets IsDeleted = true for anything implementing IDeletableEntity and issues a physical DELETE for everything else. Pass forceDeleteEvenDeletable: true to delete the row for real:

await svc.DeleteAsync(id);                                // UPDATE … SET IsDeleted = 1
await svc.DeleteAsync(id, forceDeleteEvenDeletable: true); // DELETE FROM …

Selective commit. When one context holds pending changes for several entity types and you need to persist just one of them, EFDMDatabaseContext.SaveChangesOnlyAsync<T>() temporarily freezes every other tracked entry, saves (and audits) only T, and then restores the frozen states so their changes stay pending.


Transactions

BeginTransaction / BeginTransactionAsync are available on both the context and the repository. isolationLevel is optional (IsolationLevel.Unspecified by default) and comes from System.Transactions; ReadUncommitted, ReadCommitted, RepeatableRead, Serializable, Snapshot and Chaos are mapped through.

using System.Transactions;

var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await using var tx = await dbContext.BeginTransactionAsync(IsolationLevel.ReadCommitted);
try
{
    var groupSvc = scope.ServiceProvider.GetRequiredService<IGroupService>();
    var groups = await groupSvc.FetchAsync(new GroupQuery { Take = 2 }, tracking: true);

    foreach (var group in groups)
        group.Title = $"Group_{Guid.NewGuid()}";

    await groupSvc.SaveChangesAsync();
    await tx.CommitAsync();
}
catch (Exception ex)
{
    await tx.RollbackAsync();
    Console.WriteLine($"Transaction rolled back: {ex.Message}");
}

Audit records are written inside the same transaction, so a rollback discards them together with the data.


Bulk operations (ExecuteUpdate / ExecuteDelete)

Set-based operations run in the database, without loading entities into the change tracker:

var query = new GroupQuery { TypeIds = [GroupTypeValues.Users] };

var updated = await groupSvc.ExecuteUpdateAsync(query,
    s => s.SetProperty(x => x.TextField1, x => "after_bulk_update")
          .SetProperty(x => x.TextField2, x => "new_field2"));

var deleted = await groupSvc.ExecuteDeleteAsync(query);

Both honour the whole ToFilter() of the query, so the same query object works for reading and for bulk writing.

They are audited too. EF Core cannot audit these operations on its own — EFDM does it explicitly when auditing is on and the entity type has an audit mapping:

  • one audit event per affected row (not one per statement);
  • ExecuteDelete records every column value as OldValue;
  • ExecuteUpdate records OldValue → NewValue, taking new values straight out of the SetProperty calls;
  • rows are snapshotted in batches of Repository.AuditBulkBatchSize (default 100) so memory stays bounded, and multi-batch operations are wrapped in one transaction started through the EF execution strategy;
  • the audit snapshot and the statement target the same fixed id set, so exactly the rows that were audited are the rows that were changed — even under concurrent writes.

Without an audit mapping the call degrades to a plain EF Core ExecuteUpdate / ExecuteDelete, at full speed.


Auditing

The auditor sits in SaveChanges: it inspects the change tracker, decides what is auditable, builds an audit event per entity, hands it to your mapping code, and persists the resulting rows in the same transaction as the data.

You stay in control of the storage — EFDM never invents tables. Setting it up is four steps.

1. Audit tables

Derive an event entity (one row per changed entity) and a property entity (one row per changed column) and map them with regular EF configurations. You may keep one pair for the whole database or a dedicated pair per subsystem:

using EFDM.Core.Models.Audit;

// Id, Created, CreatedById, ObjectType, ObjectId, ActionId
public class AuditGroupEvent : AuditEventBase<long> { }

// Id, AuditId, Name, OldValue, NewValue
public class AuditGroupProperty : AuditPropertyBase<long, long> { }

ActionId values come from EFDM.Core.Constants.AuditStateActionVals: Insert = 1, Update = 2, Delete = 3, Unknown = 4.

2. Audit settings

AuditSettings decides what is audited. Build it once and pass it into the context:

Property Effect
Enabled master switch
IncludedTypes allow-list — only these entity types are audited (an empty list audits nothing)
ExcludedTypeStateActions skip specific actions per type (e.g. don't audit inserts of Group)
IgnoredTypeProperties deny-list of properties per type
OnlyIncludedTypeProperties allow-list of properties per type
GlobalIgnoredProperties properties ignored for every type
var auditSettings = new AuditSettings
{
    Enabled = true,
    IncludedTypes = new ConcurrentDictionary<Type, byte>
    {
        [typeof(Group)] = 1,
        [typeof(GroupUser)] = 1,
        [typeof(TaskAnswer)] = 1
    },
    ExcludedTypeStateActions = new ConcurrentDictionary<Type, List<int>>
    {
        [typeof(Group)] = [AuditStateActionVals.Insert]      // updates and deletes only
    }
};

// don't store this column's values for Group
auditSettings.IgnoredTypeProperties.TryAdd(typeof(Group), [nameof(Group.TextField1)]);
// for TaskAnswer, store this column and nothing else
auditSettings.OnlyIncludedTypeProperties.TryAdd(typeof(TaskAnswer), [nameof(TaskAnswer.TextField1)]);

services.AddScoped(sp => new AppDbContext(GetDbOptions(sp, configuration),
    sp.GetService<ILoggerFactory>(), auditSettings));
services.AddScoped<EFDMDatabaseContext>(sp => sp.GetRequiredService<AppDbContext>());

The audit stamps themselves (Created, Modified, CreatedBy(Id), ModifiedBy(Id)) are excluded by default — they carry no information the audit event does not already have. Each context copies the settings on creation, so per-context tweaks never leak into the shared instance.

Keep the include/exclude lists tight: fewer audited types and columns mean fewer audit rows per save.

3. Audit mapping

InitAuditMapping() connects entity types to audit tables and describes how one audit event turns into rows.

  • Auditor.Map<TEntity, TEvent, TProperty>(...) — which tables an entity is audited into, plus a per-entity action (typically filling ObjectId).
  • Auditor.SetEventCommonAction<T>(...) — the shared logic that builds the event row and its property rows.
  • Auditor.EnqueueAuditEntity(entity, parent) — hand an audit entity over to EFDM. It is persisted after the main SaveChanges completes, and the parent argument wires up the foreign key (AuditId) automatically. Use this instead of calling SaveChanges from inside the mapping code.
public override void InitAuditMapping()
{
    // GroupUser rows are audited under their parent group's id
    Auditor.Map<GroupUser, AuditGroupEvent, AuditGroupProperty>((auditEvent, entry, eventEntity) =>
        eventEntity.ObjectId = GetObjectId(entry, nameof(GroupUser.GroupId)));

    Auditor.Map<Group, AuditGroupEvent, AuditGroupProperty>((auditEvent, entry, eventEntity) =>
        eventEntity.ObjectId = GetObjectId(entry, "Id"));

    Auditor.Map<TaskAnswer, AuditTaskAnswerEvent, AuditTaskAnswerProperty>((auditEvent, entry, eventEntity) =>
        eventEntity.ObjectId = GetObjectId(entry, "Id"));

    Auditor.SetEventCommonAction<IAuditEventBase<long>>((auditEvent, entry, eventEntity) =>
    {
        eventEntity.ActionId    = entry.Action;
        eventEntity.CreatedById = ExecutorId;
        eventEntity.ObjectType  = entry.EntityType.Name;
        eventEntity.Created     = DateTimeOffset.Now;
        Auditor.EnqueueAuditEntity(eventEntity);

        IAuditPropertyBase<long, long> NewProperty() =>
            (IAuditPropertyBase<long, long>)Activator.CreateInstance(Auditor.GetPropertyType(entry.EntityType))!;

        void Enqueue(IAuditPropertyBase<long, long> property)
        {
            if (!string.IsNullOrEmpty(property.Name))
                Auditor.EnqueueAuditEntity(property, eventEntity);   // AuditId is set from the parent
        }

        switch (entry.Action)
        {
            case AuditStateActionVals.Insert:
                foreach (var (name, value) in entry.ColumnValues)
                {
                    var property = NewProperty();
                    property.Name = name;
                    property.NewValue = Convert.ToString(value);
                    Enqueue(property);
                }
                break;

            case AuditStateActionVals.Delete:
                foreach (var (name, value) in entry.ColumnValues)
                {
                    var property = NewProperty();
                    property.Name = name;
                    property.OldValue = Convert.ToString(value);
                    Enqueue(property);
                }
                break;

            case AuditStateActionVals.Update:
                foreach (var change in entry.Changes)
                {
                    var property = NewProperty();
                    property.Name = change.ColumnName;
                    property.OldValue = Convert.ToString(change.OriginalValue);
                    property.NewValue = Convert.ToString(change.NewValue);
                    Enqueue(property);
                }
                break;
        }
        return Task.CompletedTask;
    });
}

// entry.GetEntry() is null for bulk operations — fall back to the snapshotted column values
private static string? GetObjectId(IEventEntry entry, string propertyName)
{
    var entityEntry = entry.GetEntry();
    if (entityEntry != null)
        return entityEntry.Entity.GetPropValue(propertyName)?.ToString();
    return entry.ColumnValues?.TryGetValue(propertyName, out var value) == true ? value?.ToString() : null;
}

entry.GetEntry() returns the EF EntityEntry for change-tracker saves and null for audited bulk operations, where only ColumnValues are available. Handling both — as GetObjectId above does — keeps one mapping working for every path.

4. Foreign-key display values

When a foreign key changes, the audit stores a readable value instead of a bare number. By default it is built by reflection from Id and Title ("42: Administrators"); all FKs of one audit event are resolved with a single query per related type, not one query per value.

Register a resolver to change the format for a type:

public override void InitAuditMapping()
{
    Auditor.RegisterLookupResolver<User>(user => $"{user.Id}: {user.LastName} {user.FirstName}");

    // non-generic overload, when the type is known only at runtime
    Auditor.RegisterLookupResolver(typeof(Group), obj => $"{((Group)obj).Id}: {((Group)obj).Title}");

    // or one resolver for everything; returning null falls through to the per-type logic
    Auditor.LookupValueResolver = obj => obj is IDictEntityBase<int> dict ? dict.Title : null;

    // … the rest of the mapping
}

Registered resolvers take precedence over the reflection-based default. If a resolver throws, the failure is logged (when a ILoggerFactory was supplied) and the default behaviour is used, so audit writes never fail because of formatting code.

5. Runtime control

dbContext.Auditor.Enabled = false;    // e.g. around a large import
// … bulk load …
dbContext.Auditor.Enabled = true;

Other useful knobs: Auditor.ExcludeProperty<T>(x => x.SomeProperty) to drop a property globally, and Auditor.ExcludeTypeStateActions<T>([AuditStateActionVals.Insert]) to skip an action for a type — both available from InitAuditMapping().


Sample project

Sample/ is a runnable end-to-end application (users, groups, task answers, audit tables, migrations for SQL Server and PostgreSQL):

Project Role
EFDM.Sample.Core Entities, queries, domain services, audit models
EFDM.Sample.DAL TestDatabaseContext, EF configurations, repository
EFDM.Sample.Mssql / EFDM.Sample.Npgs Provider-specific migrations and design-time factories
EFDM.Sample.IOC DI wiring, including AuditSettings
EFDM.Sample.TestConsole Console entry point — uncomment a scenario in Program.cs and run

To run it:

  1. Choose the provider in Sample/EFDM.Sample.IOC/Managers/IoCManagerCommon.cs (SQL Server is enabled, PostgreSQL is commented out right below it).

  2. Set the connection string in appsettings.json — or, better, in a machine-local appsettings.{MachineName}.json, which every sample project loads and git ignores.

  3. Create the database from the provider project:

    dotnet tool restore
    dotnet ef database update --context TestDatabaseContext --project Sample/EFDM.Sample.Mssql --startup-project Sample/EFDM.Sample.TestConsole
  4. Run the console:

    dotnet run --project Sample/EFDM.Sample.TestConsole

Program.cs is a menu of small scenarios — paging and sorting, split queries, transactions, detached saves, audited bulk ExecuteUpdate / ExecuteDelete with the resulting audit rows printed to the console. Uncomment the one you want to look at.


Repository layout

EFDM.Abstractions/   interfaces (no EF Core dependency beyond the model)
EFDM.Core/           entity & query base classes, QueryFilter, DomainServiceBase, AuditSettings
EFDM.DAL/            EFDMDatabaseContext, Repository, DBContextAuditor
Sample/              runnable sample application (see above)

License

MIT © Pavel Kambalin

About

entity framework data manager

Topics

Resources

Stars

0 stars

Watchers

1 watching

Forks

Used by

Contributors

Languages