From 72b929f3fb50300aea8bc6f324c8ac1d1b1ed3c2 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 29 Mar 2026 19:42:40 +0000 Subject: [PATCH] Add opt-in EF Core entity improvements at framework level MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce layered entity interfaces and convenience base classes in SimpleModule.Core that modules can adopt for automatic field population. Interfaces (SimpleModule.Core/Entities/): - IHasCreationTime, IHasModificationTime — auto-set timestamps - IAuditable — timestamps + CreatedBy/UpdatedBy from current user - ISoftDelete — intercepts hard delete, converts to soft delete - IVersioned — auto-incrementing version number - IHasConcurrencyStamp — random concurrency stamp - IHasExtraProperties — JSON key-value bag - IMultiTenant — auto-set tenant ID from ITenantContext + query filter - IHasDomainEvents — domain event collection + auto-dispatch Base classes (SimpleModule.Core/Entities/): - Entity — timestamps + concurrency - AuditableEntity — + CreatedBy/UpdatedBy - FullAuditableEntity — + soft delete + versioning - MultiTenantFullAuditableEntity — + tenant + extra properties - AuditableAggregateRoot — + domain events Interceptors (SimpleModule.Database/Interceptors/): - EntityInterceptor — populates fields based on interfaces - DomainEventInterceptor — dispatches domain events after save - EntityChangeInterceptor — invokes IEntityChangeHandler after save Conventions auto-applied via ApplyModuleSchema(): - ISoftDelete → global query filter (named) - IMultiTenant → parameterized query filter via ApplyMultiTenantFilters() - IHasConcurrencyStamp/IVersioned → concurrency tokens - IHasExtraProperties → provider-aware JSON column Uses EF Core 10 named query filters so soft-delete and multi-tenant filters coexist independently. EntityChangeInterceptor uses compiled expression-tree delegates for zero-reflection dispatch at runtime. AuditSaveChangesInterceptor updated to detect soft-delete changes. --- .../Entities/AuditableAggregateRoot.cs | 18 + .../Entities/AuditableEntity.cs | 10 + .../SimpleModule.Core/Entities/Entity.cs | 12 + .../Entities/EntityChangeContext.cs | 6 + .../Entities/EntityChangeType.cs | 11 + .../Entities/FullAuditableEntity.cs | 13 + .../SimpleModule.Core/Entities/IAuditable.cs | 11 + .../Entities/IEntityChangeHandler.cs | 10 + .../Entities/IHasConcurrencyStamp.cs | 10 + .../Entities/IHasCreationTime.cs | 9 + .../Entities/IHasDomainEvents.cs | 13 + .../Entities/IHasExtraProperties.cs | 10 + .../Entities/IHasModificationTime.cs | 9 + .../Entities/IMultiTenant.cs | 11 + .../SimpleModule.Core/Entities/ISoftDelete.cs | 13 + .../Entities/ITenantContext.cs | 10 + .../SimpleModule.Core/Entities/IVersioned.cs | 10 + .../MultiTenantFullAuditableEntity.cs | 11 + .../DatabaseConstants.cs | 8 + .../Interceptors/DomainEventInterceptor.cs | 86 ++++ .../Interceptors/EntityChangeInterceptor.cs | 151 ++++++ .../Interceptors/EntityInterceptor.cs | 106 +++++ .../ModuleModelBuilderExtensions.cs | 109 ++++- .../SimpleModuleHostExtensions.cs | 7 + .../AuditSaveChangesInterceptor.cs | 6 + .../DomainEventInterceptorTests.cs | 164 +++++++ .../EntityChangeInterceptorTests.cs | 151 ++++++ .../EntityInterceptorTests.cs | 448 ++++++++++++++++++ 28 files changed, 1432 insertions(+), 1 deletion(-) create mode 100644 framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs create mode 100644 framework/SimpleModule.Core/Entities/AuditableEntity.cs create mode 100644 framework/SimpleModule.Core/Entities/Entity.cs create mode 100644 framework/SimpleModule.Core/Entities/EntityChangeContext.cs create mode 100644 framework/SimpleModule.Core/Entities/EntityChangeType.cs create mode 100644 framework/SimpleModule.Core/Entities/FullAuditableEntity.cs create mode 100644 framework/SimpleModule.Core/Entities/IAuditable.cs create mode 100644 framework/SimpleModule.Core/Entities/IEntityChangeHandler.cs create mode 100644 framework/SimpleModule.Core/Entities/IHasConcurrencyStamp.cs create mode 100644 framework/SimpleModule.Core/Entities/IHasCreationTime.cs create mode 100644 framework/SimpleModule.Core/Entities/IHasDomainEvents.cs create mode 100644 framework/SimpleModule.Core/Entities/IHasExtraProperties.cs create mode 100644 framework/SimpleModule.Core/Entities/IHasModificationTime.cs create mode 100644 framework/SimpleModule.Core/Entities/IMultiTenant.cs create mode 100644 framework/SimpleModule.Core/Entities/ISoftDelete.cs create mode 100644 framework/SimpleModule.Core/Entities/ITenantContext.cs create mode 100644 framework/SimpleModule.Core/Entities/IVersioned.cs create mode 100644 framework/SimpleModule.Core/Entities/MultiTenantFullAuditableEntity.cs create mode 100644 framework/SimpleModule.Database/Interceptors/DomainEventInterceptor.cs create mode 100644 framework/SimpleModule.Database/Interceptors/EntityChangeInterceptor.cs create mode 100644 framework/SimpleModule.Database/Interceptors/EntityInterceptor.cs create mode 100644 tests/SimpleModule.Database.Tests/DomainEventInterceptorTests.cs create mode 100644 tests/SimpleModule.Database.Tests/EntityChangeInterceptorTests.cs create mode 100644 tests/SimpleModule.Database.Tests/EntityInterceptorTests.cs diff --git a/framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs b/framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs new file mode 100644 index 00000000..ca3c1329 --- /dev/null +++ b/framework/SimpleModule.Core/Entities/AuditableAggregateRoot.cs @@ -0,0 +1,18 @@ +using SimpleModule.Core.Events; + +namespace SimpleModule.Core.Entities; + +/// +/// Aggregate root with audit tracking, soft delete, versioning, and domain events. +/// Domain events are automatically dispatched via after SaveChanges. +/// +public abstract class AuditableAggregateRoot : FullAuditableEntity, IHasDomainEvents +{ + private readonly List _domainEvents = []; + + public IReadOnlyList GetDomainEvents() => _domainEvents.AsReadOnly(); + + public void ClearDomainEvents() => _domainEvents.Clear(); + + protected void AddDomainEvent(IEvent domainEvent) => _domainEvents.Add(domainEvent); +} diff --git a/framework/SimpleModule.Core/Entities/AuditableEntity.cs b/framework/SimpleModule.Core/Entities/AuditableEntity.cs new file mode 100644 index 00000000..6cc8ffd5 --- /dev/null +++ b/framework/SimpleModule.Core/Entities/AuditableEntity.cs @@ -0,0 +1,10 @@ +namespace SimpleModule.Core.Entities; + +/// +/// Entity with full audit tracking: timestamps and the user who created/modified the entity. +/// +public abstract class AuditableEntity : Entity, IAuditable +{ + public string? CreatedBy { get; set; } + public string? UpdatedBy { get; set; } +} diff --git a/framework/SimpleModule.Core/Entities/Entity.cs b/framework/SimpleModule.Core/Entities/Entity.cs new file mode 100644 index 00000000..552a0baa --- /dev/null +++ b/framework/SimpleModule.Core/Entities/Entity.cs @@ -0,0 +1,12 @@ +namespace SimpleModule.Core.Entities; + +/// +/// Base entity with a strongly-typed ID, creation/modification timestamps, and a concurrency stamp. +/// +public abstract class Entity : IHasCreationTime, IHasModificationTime, IHasConcurrencyStamp +{ + public TId Id { get; set; } = default!; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public string ConcurrencyStamp { get; set; } = string.Empty; +} diff --git a/framework/SimpleModule.Core/Entities/EntityChangeContext.cs b/framework/SimpleModule.Core/Entities/EntityChangeContext.cs new file mode 100644 index 00000000..27a3cd5a --- /dev/null +++ b/framework/SimpleModule.Core/Entities/EntityChangeContext.cs @@ -0,0 +1,6 @@ +namespace SimpleModule.Core.Entities; + +/// +/// Context passed to when an entity change is detected after SaveChanges. +/// +public sealed record EntityChangeContext(T Entity, EntityChangeType ChangeType) where T : class; diff --git a/framework/SimpleModule.Core/Entities/EntityChangeType.cs b/framework/SimpleModule.Core/Entities/EntityChangeType.cs new file mode 100644 index 00000000..3dfadfc6 --- /dev/null +++ b/framework/SimpleModule.Core/Entities/EntityChangeType.cs @@ -0,0 +1,11 @@ +namespace SimpleModule.Core.Entities; + +/// +/// The type of change that occurred on an entity. +/// +public enum EntityChangeType +{ + Created, + Updated, + Deleted, +} diff --git a/framework/SimpleModule.Core/Entities/FullAuditableEntity.cs b/framework/SimpleModule.Core/Entities/FullAuditableEntity.cs new file mode 100644 index 00000000..46465f75 --- /dev/null +++ b/framework/SimpleModule.Core/Entities/FullAuditableEntity.cs @@ -0,0 +1,13 @@ +namespace SimpleModule.Core.Entities; + +/// +/// Entity with full audit tracking, soft delete, and versioning. +/// This is the most commonly used base class for business entities. +/// +public abstract class FullAuditableEntity : AuditableEntity, ISoftDelete, IVersioned +{ + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + public string? DeletedBy { get; set; } + public int Version { get; set; } +} diff --git a/framework/SimpleModule.Core/Entities/IAuditable.cs b/framework/SimpleModule.Core/Entities/IAuditable.cs new file mode 100644 index 00000000..4c23bc11 --- /dev/null +++ b/framework/SimpleModule.Core/Entities/IAuditable.cs @@ -0,0 +1,11 @@ +namespace SimpleModule.Core.Entities; + +/// +/// Entities implementing this interface get full audit tracking: +/// timestamps and the user who created/modified the entity (resolved from the current HTTP context). +/// +public interface IAuditable : IHasCreationTime, IHasModificationTime +{ + string? CreatedBy { get; set; } + string? UpdatedBy { get; set; } +} diff --git a/framework/SimpleModule.Core/Entities/IEntityChangeHandler.cs b/framework/SimpleModule.Core/Entities/IEntityChangeHandler.cs new file mode 100644 index 00000000..c4b6ff2a --- /dev/null +++ b/framework/SimpleModule.Core/Entities/IEntityChangeHandler.cs @@ -0,0 +1,10 @@ +namespace SimpleModule.Core.Entities; + +/// +/// Typed handler invoked automatically after SaveChanges when an entity of type changes. +/// Useful for cache invalidation, denormalized data updates, or triggering side effects. +/// +public interface IEntityChangeHandler where T : class +{ + Task HandleAsync(EntityChangeContext context, CancellationToken cancellationToken = default); +} diff --git a/framework/SimpleModule.Core/Entities/IHasConcurrencyStamp.cs b/framework/SimpleModule.Core/Entities/IHasConcurrencyStamp.cs new file mode 100644 index 00000000..0a195c94 --- /dev/null +++ b/framework/SimpleModule.Core/Entities/IHasConcurrencyStamp.cs @@ -0,0 +1,10 @@ +namespace SimpleModule.Core.Entities; + +/// +/// Entities implementing this interface get a random concurrency stamp set on each save. +/// Configured as a concurrency token for optimistic concurrency control. +/// +public interface IHasConcurrencyStamp +{ + string ConcurrencyStamp { get; set; } +} diff --git a/framework/SimpleModule.Core/Entities/IHasCreationTime.cs b/framework/SimpleModule.Core/Entities/IHasCreationTime.cs new file mode 100644 index 00000000..75396942 --- /dev/null +++ b/framework/SimpleModule.Core/Entities/IHasCreationTime.cs @@ -0,0 +1,9 @@ +namespace SimpleModule.Core.Entities; + +/// +/// Entities implementing this interface get automatically set on insert. +/// +public interface IHasCreationTime +{ + DateTimeOffset CreatedAt { get; set; } +} diff --git a/framework/SimpleModule.Core/Entities/IHasDomainEvents.cs b/framework/SimpleModule.Core/Entities/IHasDomainEvents.cs new file mode 100644 index 00000000..ba0da6ad --- /dev/null +++ b/framework/SimpleModule.Core/Entities/IHasDomainEvents.cs @@ -0,0 +1,13 @@ +using SimpleModule.Core.Events; + +namespace SimpleModule.Core.Entities; + +/// +/// Entities implementing this interface can raise domain events that are automatically +/// dispatched via after a successful SaveChanges. +/// +public interface IHasDomainEvents +{ + IReadOnlyList GetDomainEvents(); + void ClearDomainEvents(); +} diff --git a/framework/SimpleModule.Core/Entities/IHasExtraProperties.cs b/framework/SimpleModule.Core/Entities/IHasExtraProperties.cs new file mode 100644 index 00000000..f8fe0743 --- /dev/null +++ b/framework/SimpleModule.Core/Entities/IHasExtraProperties.cs @@ -0,0 +1,10 @@ +namespace SimpleModule.Core.Entities; + +/// +/// Entities implementing this interface have a flexible key-value property bag +/// stored as a JSON column. Useful for extensibility without schema changes. +/// +public interface IHasExtraProperties +{ + Dictionary ExtraProperties { get; set; } +} diff --git a/framework/SimpleModule.Core/Entities/IHasModificationTime.cs b/framework/SimpleModule.Core/Entities/IHasModificationTime.cs new file mode 100644 index 00000000..8ba7e15d --- /dev/null +++ b/framework/SimpleModule.Core/Entities/IHasModificationTime.cs @@ -0,0 +1,9 @@ +namespace SimpleModule.Core.Entities; + +/// +/// Entities implementing this interface get automatically set on insert and update. +/// +public interface IHasModificationTime +{ + DateTimeOffset UpdatedAt { get; set; } +} diff --git a/framework/SimpleModule.Core/Entities/IMultiTenant.cs b/framework/SimpleModule.Core/Entities/IMultiTenant.cs new file mode 100644 index 00000000..57bbea3d --- /dev/null +++ b/framework/SimpleModule.Core/Entities/IMultiTenant.cs @@ -0,0 +1,11 @@ +namespace SimpleModule.Core.Entities; + +/// +/// Entities implementing this interface are automatically assigned a tenant ID +/// from the current on insert. A global query filter +/// restricts queries to the current tenant. +/// +public interface IMultiTenant +{ + string TenantId { get; set; } +} diff --git a/framework/SimpleModule.Core/Entities/ISoftDelete.cs b/framework/SimpleModule.Core/Entities/ISoftDelete.cs new file mode 100644 index 00000000..681fc498 --- /dev/null +++ b/framework/SimpleModule.Core/Entities/ISoftDelete.cs @@ -0,0 +1,13 @@ +namespace SimpleModule.Core.Entities; + +/// +/// Entities implementing this interface are soft-deleted instead of hard-deleted. +/// When EF Core Remove() is called, the entity state is changed to Modified and +/// the soft-delete fields are set automatically. A global query filter excludes soft-deleted entities. +/// +public interface ISoftDelete +{ + bool IsDeleted { get; set; } + DateTimeOffset? DeletedAt { get; set; } + string? DeletedBy { get; set; } +} diff --git a/framework/SimpleModule.Core/Entities/ITenantContext.cs b/framework/SimpleModule.Core/Entities/ITenantContext.cs new file mode 100644 index 00000000..8f1d7250 --- /dev/null +++ b/framework/SimpleModule.Core/Entities/ITenantContext.cs @@ -0,0 +1,10 @@ +namespace SimpleModule.Core.Entities; + +/// +/// Provides the current tenant ID for multi-tenant entity filtering and assignment. +/// Resolved from the current request context (e.g., HTTP header, claim, or subdomain). +/// +public interface ITenantContext +{ + string? TenantId { get; } +} diff --git a/framework/SimpleModule.Core/Entities/IVersioned.cs b/framework/SimpleModule.Core/Entities/IVersioned.cs new file mode 100644 index 00000000..dabb4cf6 --- /dev/null +++ b/framework/SimpleModule.Core/Entities/IVersioned.cs @@ -0,0 +1,10 @@ +namespace SimpleModule.Core.Entities; + +/// +/// Entities implementing this interface get an auto-incrementing version number. +/// Starts at 1 on insert, increments on each update. Configured as a concurrency token. +/// +public interface IVersioned +{ + int Version { get; set; } +} diff --git a/framework/SimpleModule.Core/Entities/MultiTenantFullAuditableEntity.cs b/framework/SimpleModule.Core/Entities/MultiTenantFullAuditableEntity.cs new file mode 100644 index 00000000..dc03dce9 --- /dev/null +++ b/framework/SimpleModule.Core/Entities/MultiTenantFullAuditableEntity.cs @@ -0,0 +1,11 @@ +namespace SimpleModule.Core.Entities; + +/// +/// Entity with full audit tracking, soft delete, versioning, multi-tenancy, and extra properties. +/// Use this for entities in multi-tenant modules that need maximum extensibility. +/// +public abstract class MultiTenantFullAuditableEntity : FullAuditableEntity, IMultiTenant, IHasExtraProperties +{ + public string TenantId { get; set; } = string.Empty; + public Dictionary ExtraProperties { get; set; } = []; +} diff --git a/framework/SimpleModule.Database/DatabaseConstants.cs b/framework/SimpleModule.Database/DatabaseConstants.cs index 8690783e..ddd17ef1 100644 --- a/framework/SimpleModule.Database/DatabaseConstants.cs +++ b/framework/SimpleModule.Database/DatabaseConstants.cs @@ -8,4 +8,12 @@ public static class DatabaseConstants public const string SqlServerCatalogPrefix = "Initial Catalog="; public const string SqlServerLocalPrefix = @"Server=.\"; public const string SqlServerExpressionPrefix = @"Server=("; + + /// Named query filter key for entities. + public const string SoftDeleteQueryFilterKey = "SimpleModule:SoftDelete"; + + /// Named query filter key for entities. + public const string MultiTenantQueryFilterKey = "SimpleModule:MultiTenant"; + + internal const string EntityConventionsAppliedAnnotation = "SimpleModule:EntityConventionsApplied"; } diff --git a/framework/SimpleModule.Database/Interceptors/DomainEventInterceptor.cs b/framework/SimpleModule.Database/Interceptors/DomainEventInterceptor.cs new file mode 100644 index 00000000..11d65eb7 --- /dev/null +++ b/framework/SimpleModule.Database/Interceptors/DomainEventInterceptor.cs @@ -0,0 +1,86 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core.Entities; +using SimpleModule.Core.Events; + +namespace SimpleModule.Database.Interceptors; + +/// +/// Interceptor that collects domain events from entities +/// before SaveChanges and dispatches them via after a successful save. +/// Events are cleared from entities after dispatch to prevent re-processing. +/// Registered as scoped — each DbContext gets its own instance, so instance fields are safe. +/// +public sealed class DomainEventInterceptor(IServiceProvider serviceProvider) : SaveChangesInterceptor +{ + private static readonly MethodInfo PublishAsyncMethod = + typeof(IEventBus).GetMethod(nameof(IEventBus.PublishAsync))!; + private static readonly ConcurrentDictionary PublishMethodCache = new(); + + private List? _collectedEvents; + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (eventData.Context is not null) + { + var events = new List(); + + foreach (var entry in eventData.Context.ChangeTracker.Entries()) + { + var domainEvents = entry.Entity.GetDomainEvents(); + if (domainEvents.Count > 0) + { + events.AddRange(domainEvents); + entry.Entity.ClearDomainEvents(); + } + } + + _collectedEvents = events.Count > 0 ? events : null; + } + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + public override async ValueTask SavedChangesAsync( + SaveChangesCompletedEventData eventData, + int result, + CancellationToken cancellationToken = default) + { + var events = _collectedEvents; + _collectedEvents = null; + + if (events is { Count: > 0 }) + { + var eventBus = serviceProvider.GetService(); + if (eventBus is not null) + { + foreach (var domainEvent in events) + { + // Invoke PublishAsync with the concrete event type so that + // IEventHandler registrations are resolved correctly. + // A static call to PublishAsync(IEvent) would resolve T as IEvent, + // missing all concrete handlers. + var concreteMethod = PublishMethodCache.GetOrAdd( + domainEvent.GetType(), + static type => PublishAsyncMethod.MakeGenericMethod(type)); + await (Task)concreteMethod.Invoke(eventBus, [domainEvent, cancellationToken])!; + } + } + } + + return await base.SavedChangesAsync(eventData, result, cancellationToken); + } + + public override Task SaveChangesFailedAsync( + DbContextErrorEventData eventData, + CancellationToken cancellationToken = default) + { + _collectedEvents = null; + return base.SaveChangesFailedAsync(eventData, cancellationToken); + } +} diff --git a/framework/SimpleModule.Database/Interceptors/EntityChangeInterceptor.cs b/framework/SimpleModule.Database/Interceptors/EntityChangeInterceptor.cs new file mode 100644 index 00000000..31606e0b --- /dev/null +++ b/framework/SimpleModule.Database/Interceptors/EntityChangeInterceptor.cs @@ -0,0 +1,151 @@ +using System.Collections.Concurrent; +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using SimpleModule.Core.Entities; + +namespace SimpleModule.Database.Interceptors; + +/// +/// Interceptor that captures entity changes before SaveChanges and dispatches them to +/// typed implementations after a successful save. +/// Registered as scoped — each DbContext gets its own instance, so instance fields are safe. +/// Caches compiled delegates per entity type to avoid per-save reflection overhead. +/// +public sealed class EntityChangeInterceptor( + IServiceProvider serviceProvider, + ILogger logger +) : SaveChangesInterceptor +{ + private static readonly ConcurrentDictionary MetadataCache = new(); + + private List<(object Entity, EntityChangeType ChangeType)>? _capturedChanges; + + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (eventData.Context is not null) + { + var changes = new List<(object Entity, EntityChangeType ChangeType)>(); + + foreach (var entry in eventData.Context.ChangeTracker.Entries()) + { + if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted)) + continue; + + var changeType = entry.State switch + { + EntityState.Added => EntityChangeType.Created, + EntityState.Deleted => EntityChangeType.Deleted, + EntityState.Modified when entry.Entity is ISoftDelete { IsDeleted: true } + && entry.Property(nameof(ISoftDelete.IsDeleted)).IsModified + => EntityChangeType.Deleted, + _ => EntityChangeType.Updated, + }; + + changes.Add((entry.Entity, changeType)); + } + + _capturedChanges = changes.Count > 0 ? changes : null; + } + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + public override async ValueTask SavedChangesAsync( + SaveChangesCompletedEventData eventData, + int result, + CancellationToken cancellationToken = default) + { + var changes = _capturedChanges; + _capturedChanges = null; + + if (changes is { Count: > 0 }) + { + foreach (var (entity, changeType) in changes) + { + await DispatchToHandlersAsync(entity, changeType, cancellationToken); + } + } + + return await base.SavedChangesAsync(eventData, result, cancellationToken); + } + + public override Task SaveChangesFailedAsync( + DbContextErrorEventData eventData, + CancellationToken cancellationToken = default) + { + _capturedChanges = null; + return base.SaveChangesFailedAsync(eventData, cancellationToken); + } + + private async Task DispatchToHandlersAsync( + object entity, + EntityChangeType changeType, + CancellationToken cancellationToken) + { + var entityType = entity.GetType(); + var metadata = MetadataCache.GetOrAdd(entityType, BuildMetadata); + var handlers = serviceProvider.GetServices(metadata.HandlerType); + + foreach (var handler in handlers) + { + try + { + var context = metadata.ContextFactory(entity, changeType); + await metadata.Invoker(handler!, context, cancellationToken); + } +#pragma warning disable CA1031 // Handlers should not crash the save pipeline + catch (Exception ex) +#pragma warning restore CA1031 + { + logger.LogError( + ex, + "Entity change handler {HandlerType} failed for {EntityType} ({ChangeType})", + handler?.GetType().Name, + entityType.Name, + changeType); + } + } + } + + private static HandlerMetadata BuildMetadata(Type entityType) + { + var handlerType = typeof(IEntityChangeHandler<>).MakeGenericType(entityType); + var contextType = typeof(EntityChangeContext<>).MakeGenericType(entityType); + + // Compile a factory delegate: (object entity, EntityChangeType ct) => new EntityChangeContext((T)entity, ct) + var entityParam = Expression.Parameter(typeof(object), "entity"); + var changeTypeParam = Expression.Parameter(typeof(EntityChangeType), "changeType"); + var ctor = contextType.GetConstructor([entityType, typeof(EntityChangeType)])!; + var newExpr = Expression.New(ctor, Expression.Convert(entityParam, entityType), changeTypeParam); + var contextFactory = Expression.Lambda>( + Expression.Convert(newExpr, typeof(object)), + entityParam, changeTypeParam).Compile(); + + // Compile an invoker delegate: (object handler, object ctx, CancellationToken ct) => ((IEntityChangeHandler)handler).HandleAsync((EntityChangeContext)ctx, ct) + var handlerParam = Expression.Parameter(typeof(object), "handler"); + var ctxParam = Expression.Parameter(typeof(object), "ctx"); + var ctParam = Expression.Parameter(typeof(CancellationToken), "ct"); + var handleMethod = handlerType.GetMethod(nameof(IEntityChangeHandler.HandleAsync))!; + var callExpr = Expression.Call( + Expression.Convert(handlerParam, handlerType), + handleMethod, + Expression.Convert(ctxParam, contextType), + ctParam); + var invoker = Expression.Lambda>( + callExpr, handlerParam, ctxParam, ctParam).Compile(); + + return new HandlerMetadata(handlerType, contextFactory, invoker); + } + + private sealed record HandlerMetadata( + Type HandlerType, + Func ContextFactory, + Func Invoker); +} diff --git a/framework/SimpleModule.Database/Interceptors/EntityInterceptor.cs b/framework/SimpleModule.Database/Interceptors/EntityInterceptor.cs new file mode 100644 index 00000000..930b6437 --- /dev/null +++ b/framework/SimpleModule.Database/Interceptors/EntityInterceptor.cs @@ -0,0 +1,106 @@ +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using Microsoft.EntityFrameworkCore.Diagnostics; +using SimpleModule.Core.Entities; + +namespace SimpleModule.Database.Interceptors; + +/// +/// Interceptor that automatically populates entity fields based on implemented interfaces: +/// +/// — sets CreatedAt on insert +/// — sets UpdatedAt on insert and update +/// — sets CreatedBy/UpdatedBy from the current user +/// — converts hard delete to soft delete +/// — auto-increments Version +/// — sets a new random concurrency stamp +/// — sets TenantId from +/// +/// +public sealed class EntityInterceptor( + IHttpContextAccessor httpContextAccessor, + ITenantContext? tenantContext = null +) : SaveChangesInterceptor +{ + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (eventData.Context is null) + return base.SavingChangesAsync(eventData, result, cancellationToken); + + var userId = httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier); + var now = DateTimeOffset.UtcNow; + + foreach (var entry in eventData.Context.ChangeTracker.Entries()) + { + if (entry.State is not (EntityState.Added or EntityState.Modified or EntityState.Deleted)) + continue; + + switch (entry.State) + { + case EntityState.Added: + SetCreationFields(entry, now, userId); + SetModificationFields(entry, now, userId); + SetConcurrencyFields(entry); + SetTenantId(entry); + if (entry.Entity is IVersioned v) + v.Version = 1; + break; + + case EntityState.Modified: + SetModificationFields(entry, now, userId); + SetConcurrencyFields(entry); + if (entry.Entity is IVersioned v2) + v2.Version++; + break; + + case EntityState.Deleted when entry.Entity is ISoftDelete sd: + entry.State = EntityState.Modified; + sd.IsDeleted = true; + sd.DeletedAt = now; + sd.DeletedBy = userId; + SetModificationFields(entry, now, userId); + SetConcurrencyFields(entry); + if (entry.Entity is IVersioned v3) + v3.Version++; + break; + } + } + + return base.SavingChangesAsync(eventData, result, cancellationToken); + } + + private static void SetCreationFields(EntityEntry entry, DateTimeOffset now, string? userId) + { + if (entry.Entity is IHasCreationTime c) + c.CreatedAt = now; + + if (entry.Entity is IAuditable a) + a.CreatedBy = userId; + } + + private static void SetModificationFields(EntityEntry entry, DateTimeOffset now, string? userId) + { + if (entry.Entity is IHasModificationTime m) + m.UpdatedAt = now; + + if (entry.Entity is IAuditable a) + a.UpdatedBy = userId; + } + + private static void SetConcurrencyFields(EntityEntry entry) + { + if (entry.Entity is IHasConcurrencyStamp cs) + cs.ConcurrencyStamp = Guid.NewGuid().ToString("N"); + } + + private void SetTenantId(EntityEntry entry) + { + if (entry.Entity is IMultiTenant mt && tenantContext?.TenantId is not null) + mt.TenantId = tenantContext.TenantId; + } +} diff --git a/framework/SimpleModule.Database/ModuleModelBuilderExtensions.cs b/framework/SimpleModule.Database/ModuleModelBuilderExtensions.cs index 9a37ebf8..d77f37d2 100644 --- a/framework/SimpleModule.Database/ModuleModelBuilderExtensions.cs +++ b/framework/SimpleModule.Database/ModuleModelBuilderExtensions.cs @@ -1,9 +1,12 @@ -using Microsoft.EntityFrameworkCore; +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core.Entities; namespace SimpleModule.Database; public static class ModuleModelBuilderExtensions { + #pragma warning disable CA1308 // Schema names are conventionally lowercase in PostgreSQL/SQL Server public static void ApplyModuleSchema( this ModelBuilder modelBuilder, @@ -41,6 +44,110 @@ tableName is not null entity.SetSchema(schema); } } + + ApplyEntityConventions(modelBuilder, provider); } #pragma warning restore CA1308 + + /// + /// Applies multi-tenant query filters to all entities. + /// Call this from your DbContext's OnModelCreating after . + /// + /// The filter expression closes over the field reference, + /// so EF Core evaluates the current tenant ID at query time (parameterized). + /// + /// + /// + /// protected override void OnModelCreating(ModelBuilder modelBuilder) + /// { + /// modelBuilder.ApplyModuleSchema("Products", dbOptions.Value); + /// modelBuilder.ApplyMultiTenantFilters(tenantContext); + /// } + /// + /// + /// + public static void ApplyMultiTenantFilters( + this ModelBuilder modelBuilder, + ITenantContext tenantContext + ) + { + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + if (!typeof(IMultiTenant).IsAssignableFrom(entityType.ClrType)) + continue; + + var parameter = Expression.Parameter(entityType.ClrType, "e"); + var tenantIdProperty = Expression.Property(parameter, nameof(IMultiTenant.TenantId)); + var tenantContextExpr = Expression.Constant(tenantContext); + var currentTenantId = Expression.Property(tenantContextExpr, nameof(ITenantContext.TenantId)); + + // Handle null tenant: when no tenant is set, the filter becomes e.TenantId == null + // which effectively returns no rows (TenantId is non-nullable string). + var filter = Expression.Lambda( + Expression.Equal(tenantIdProperty, Expression.Coalesce(currentTenantId, Expression.Constant(""))), + parameter + ); + + entityType.SetQueryFilter(DatabaseConstants.MultiTenantQueryFilterKey, filter); + } + } + + /// + /// Applies EF Core conventions for entity interfaces. Guarded against re-entry + /// so it runs at most once per model even if is called + /// multiple times. + /// + private static void ApplyEntityConventions(ModelBuilder modelBuilder, DatabaseProvider provider) + { + if (modelBuilder.Model.FindAnnotation(DatabaseConstants.EntityConventionsAppliedAnnotation) is not null) + return; + + modelBuilder.Model.AddAnnotation(DatabaseConstants.EntityConventionsAppliedAnnotation, true); + + foreach (var entityType in modelBuilder.Model.GetEntityTypes()) + { + var clrType = entityType.ClrType; + + if (typeof(ISoftDelete).IsAssignableFrom(clrType)) + { + var parameter = Expression.Parameter(clrType, "e"); + var property = Expression.Property(parameter, nameof(ISoftDelete.IsDeleted)); + var filter = Expression.Lambda(Expression.Not(property), parameter); + entityType.SetQueryFilter(DatabaseConstants.SoftDeleteQueryFilterKey, filter); + } + + if (typeof(IHasConcurrencyStamp).IsAssignableFrom(clrType)) + { + var concurrencyProp = entityType.FindProperty(nameof(IHasConcurrencyStamp.ConcurrencyStamp)); + if (concurrencyProp is not null) + { + concurrencyProp.IsConcurrencyToken = true; + } + } + + if (typeof(IVersioned).IsAssignableFrom(clrType)) + { + var versionProp = entityType.FindProperty(nameof(IVersioned.Version)); + if (versionProp is not null) + { + versionProp.IsConcurrencyToken = true; + } + } + + if (typeof(IHasExtraProperties).IsAssignableFrom(clrType)) + { + var prop = entityType.FindProperty(nameof(IHasExtraProperties.ExtraProperties)); + if (prop is not null) + { + var columnType = provider switch + { + DatabaseProvider.PostgreSql => "jsonb", + DatabaseProvider.SqlServer => "nvarchar(max)", + _ => "TEXT", + }; + prop.SetColumnType(columnType); + } + } + } + } } diff --git a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs index debca8f5..68cf5071 100644 --- a/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs +++ b/framework/SimpleModule.Hosting/SimpleModuleHostExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.StaticFiles; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -16,6 +17,7 @@ using SimpleModule.Core.Menu; using SimpleModule.Database; using SimpleModule.Database.Health; +using SimpleModule.Database.Interceptors; using SimpleModule.DevTools; namespace SimpleModule.Hosting; @@ -68,6 +70,11 @@ public static WebApplicationBuilder AddSimpleModuleInfrastructure( builder.Services.AddScoped(); builder.Services.AddScoped(); + // Entity framework interceptors for automatic entity field population + builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + // Authentication is configured by modules via their ConfigureServices // (e.g., OpenIddict registers SmartAuth policy scheme). // Register a baseline so the middleware pipeline works even without an auth module. diff --git a/modules/AuditLogs/src/SimpleModule.AuditLogs/Interceptors/AuditSaveChangesInterceptor.cs b/modules/AuditLogs/src/SimpleModule.AuditLogs/Interceptors/AuditSaveChangesInterceptor.cs index d9941711..92c13e3e 100644 --- a/modules/AuditLogs/src/SimpleModule.AuditLogs/Interceptors/AuditSaveChangesInterceptor.cs +++ b/modules/AuditLogs/src/SimpleModule.AuditLogs/Interceptors/AuditSaveChangesInterceptor.cs @@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection; using SimpleModule.AuditLogs.Contracts; using SimpleModule.AuditLogs.Pipeline; +using SimpleModule.Core.Entities; using SimpleModule.Core.Settings; using SimpleModule.Settings.Contracts; @@ -78,6 +79,7 @@ public override async ValueTask> SavingChangesAsync( var action = entry.State switch { EntityState.Added => AuditAction.Created, + EntityState.Modified when IsSoftDeleteChange(entry) => AuditAction.Deleted, EntityState.Modified => AuditAction.Updated, EntityState.Deleted => AuditAction.Deleted, _ => (AuditAction?)null, @@ -143,4 +145,8 @@ public override async ValueTask> SavingChangesAsync( return changeSet.Count > 0 ? JsonSerializer.Serialize(changeSet) : null; } + + private static bool IsSoftDeleteChange(EntityEntry entry) => + entry.Entity is ISoftDelete { IsDeleted: true } + && entry.Property(nameof(ISoftDelete.IsDeleted)).IsModified; } diff --git a/tests/SimpleModule.Database.Tests/DomainEventInterceptorTests.cs b/tests/SimpleModule.Database.Tests/DomainEventInterceptorTests.cs new file mode 100644 index 00000000..3bb78969 --- /dev/null +++ b/tests/SimpleModule.Database.Tests/DomainEventInterceptorTests.cs @@ -0,0 +1,164 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using SimpleModule.Core.Entities; +using SimpleModule.Core.Events; +using SimpleModule.Database.Interceptors; + +namespace SimpleModule.Database.Tests; + +public sealed class DomainEventInterceptorTests +{ + [Fact] + public async Task Domain_Events_Dispatched_After_SaveChanges() + { + var eventBus = new TestEventBus(); + await using var fixture = CreateFixture(eventBus); + + var entity = new AggregateRootTestEntity { Name = "Test" }; + entity.TriggerSomethingHappened(); + + fixture.Context.AggregateRoots.Add(entity); + await fixture.Context.SaveChangesAsync(); + + eventBus.PublishedEvents.Should().HaveCount(1); + eventBus.PublishedEvents[0].Should().BeOfType(); + } + + [Fact] + public async Task Domain_Events_Cleared_After_Dispatch() + { + var eventBus = new TestEventBus(); + await using var fixture = CreateFixture(eventBus); + + var entity = new AggregateRootTestEntity { Name = "Test" }; + entity.TriggerSomethingHappened(); + + fixture.Context.AggregateRoots.Add(entity); + await fixture.Context.SaveChangesAsync(); + + entity.GetDomainEvents().Should().BeEmpty(); + } + + [Fact] + public async Task No_Events_Dispatched_When_No_Domain_Events() + { + var eventBus = new TestEventBus(); + await using var fixture = CreateFixture(eventBus); + + var entity = new AggregateRootTestEntity { Name = "Test" }; + + fixture.Context.AggregateRoots.Add(entity); + await fixture.Context.SaveChangesAsync(); + + eventBus.PublishedEvents.Should().BeEmpty(); + } + + [Fact] + public async Task Multiple_Events_From_Multiple_Entities_All_Dispatched() + { + var eventBus = new TestEventBus(); + await using var fixture = CreateFixture(eventBus); + + var entity1 = new AggregateRootTestEntity { Name = "First" }; + entity1.TriggerSomethingHappened(); + entity1.TriggerSomethingHappened(); + + var entity2 = new AggregateRootTestEntity { Name = "Second" }; + entity2.TriggerSomethingHappened(); + + fixture.Context.AggregateRoots.AddRange(entity1, entity2); + await fixture.Context.SaveChangesAsync(); + + eventBus.PublishedEvents.Should().HaveCount(3); + } + + private static TestFixture CreateFixture(IEventBus eventBus) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["Database:DefaultConnection"] = "Data Source=:memory:", + } + ) + .Build(); + + var services = new ServiceCollection(); + services.AddSingleton(eventBus); + services.AddScoped(); + services.AddModuleDbContext(config, "DomainEventTest"); + + var provider = services.BuildServiceProvider(); + var context = provider.GetRequiredService(); + context.Database.OpenConnection(); + context.Database.EnsureCreated(); + + return new TestFixture(provider, context); + } + + private sealed class TestFixture(ServiceProvider provider, DomainEventTestDbContext context) + : IAsyncDisposable + { + public DomainEventTestDbContext Context => context; + + public async ValueTask DisposeAsync() + { + await context.DisposeAsync(); + await provider.DisposeAsync(); + } + } +} + +public sealed record SomethingHappenedEvent : IEvent; + +public class AggregateRootTestEntity : IHasDomainEvents +{ + private readonly List _events = []; + + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + + public IReadOnlyList GetDomainEvents() => _events.AsReadOnly(); + public void ClearDomainEvents() => _events.Clear(); + public void TriggerSomethingHappened() => _events.Add(new SomethingHappenedEvent()); +} + +public sealed class TestEventBus : IEventBus +{ + public List PublishedEvents { get; } = []; + + public Task PublishAsync(T @event, CancellationToken cancellationToken = default) + where T : IEvent + { + PublishedEvents.Add(@event); + return Task.CompletedTask; + } + + public void PublishInBackground(T @event) + where T : IEvent + { + PublishedEvents.Add(@event); + } +} + +public class DomainEventTestDbContext( + DbContextOptions options, + IOptions dbOptions +) : DbContext(options) +{ + public DbSet AggregateRoots => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + }); + + modelBuilder.ApplyModuleSchema("DomainEventTest", dbOptions.Value); + } +} diff --git a/tests/SimpleModule.Database.Tests/EntityChangeInterceptorTests.cs b/tests/SimpleModule.Database.Tests/EntityChangeInterceptorTests.cs new file mode 100644 index 00000000..db5fea61 --- /dev/null +++ b/tests/SimpleModule.Database.Tests/EntityChangeInterceptorTests.cs @@ -0,0 +1,151 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using SimpleModule.Core.Entities; +using SimpleModule.Database.Interceptors; + +namespace SimpleModule.Database.Tests; + +public sealed class EntityChangeInterceptorTests +{ + [Fact] + public async Task Handler_Invoked_On_Entity_Add() + { + var handler = new TestChangeHandler(); + await using var fixture = CreateFixture(handler); + + fixture.Context.ChangeTrackedEntities.Add(new ChangeTrackedTestEntity { Name = "Test" }); + await fixture.Context.SaveChangesAsync(); + + handler.Changes.Should().HaveCount(1); + handler.Changes[0].ChangeType.Should().Be(EntityChangeType.Created); + handler.Changes[0].Entity.Name.Should().Be("Test"); + } + + [Fact] + public async Task Handler_Invoked_On_Entity_Update() + { + var handler = new TestChangeHandler(); + await using var fixture = CreateFixture(handler); + + var entity = new ChangeTrackedTestEntity { Name = "Test" }; + fixture.Context.ChangeTrackedEntities.Add(entity); + await fixture.Context.SaveChangesAsync(); + handler.Changes.Clear(); + + entity.Name = "Updated"; + await fixture.Context.SaveChangesAsync(); + + handler.Changes.Should().HaveCount(1); + handler.Changes[0].ChangeType.Should().Be(EntityChangeType.Updated); + } + + [Fact] + public async Task Handler_Invoked_On_Entity_Delete() + { + var handler = new TestChangeHandler(); + await using var fixture = CreateFixture(handler); + + var entity = new ChangeTrackedTestEntity { Name = "Test" }; + fixture.Context.ChangeTrackedEntities.Add(entity); + await fixture.Context.SaveChangesAsync(); + handler.Changes.Clear(); + + fixture.Context.ChangeTrackedEntities.Remove(entity); + await fixture.Context.SaveChangesAsync(); + + handler.Changes.Should().HaveCount(1); + handler.Changes[0].ChangeType.Should().Be(EntityChangeType.Deleted); + } + + [Fact] + public async Task No_Handler_No_Errors() + { + await using var fixture = CreateFixture(handler: null); + + fixture.Context.ChangeTrackedEntities.Add(new ChangeTrackedTestEntity { Name = "Test" }); + var act = () => fixture.Context.SaveChangesAsync(); + + await act.Should().NotThrowAsync(); + } + + private static TestFixture CreateFixture(TestChangeHandler? handler) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["Database:DefaultConnection"] = "Data Source=:memory:", + } + ) + .Build(); + + var services = new ServiceCollection(); + + if (handler is not null) + { + services.AddSingleton>(handler); + } + + services.AddSingleton>(NullLogger.Instance); + services.AddScoped(); + services.AddModuleDbContext(config, "ChangeTrackingTest"); + + var provider = services.BuildServiceProvider(); + var context = provider.GetRequiredService(); + context.Database.OpenConnection(); + context.Database.EnsureCreated(); + + return new TestFixture(provider, context); + } + + private sealed class TestFixture(ServiceProvider provider, ChangeTrackingTestDbContext context) + : IAsyncDisposable + { + public ChangeTrackingTestDbContext Context => context; + + public async ValueTask DisposeAsync() + { + await context.DisposeAsync(); + await provider.DisposeAsync(); + } + } +} + +public class ChangeTrackedTestEntity +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; +} + +public sealed class TestChangeHandler : IEntityChangeHandler +{ + public List> Changes { get; } = []; + + public Task HandleAsync( + EntityChangeContext context, + CancellationToken cancellationToken = default) + { + Changes.Add(context); + return Task.CompletedTask; + } +} + +public class ChangeTrackingTestDbContext( + DbContextOptions options, + IOptions dbOptions +) : DbContext(options) +{ + public DbSet ChangeTrackedEntities => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => e.HasKey(x => x.Id)); + modelBuilder.ApplyModuleSchema("ChangeTrackingTest", dbOptions.Value); + } +} diff --git a/tests/SimpleModule.Database.Tests/EntityInterceptorTests.cs b/tests/SimpleModule.Database.Tests/EntityInterceptorTests.cs new file mode 100644 index 00000000..ae65f0a8 --- /dev/null +++ b/tests/SimpleModule.Database.Tests/EntityInterceptorTests.cs @@ -0,0 +1,448 @@ +using System.Security.Claims; +using FluentAssertions; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using SimpleModule.Core.Entities; +using SimpleModule.Database.Interceptors; + +namespace SimpleModule.Database.Tests; + +public sealed class EntityInterceptorTests +{ + private const string TestUserId = "user-123"; + + [Fact] + public async Task Added_Entity_Sets_CreatedAt_And_UpdatedAt() + { + await using var fixture = CreateFixture(); + var entity = new TimestampedTestEntity { Name = "Test" }; + + fixture.Context.TimestampedEntities.Add(entity); + await fixture.Context.SaveChangesAsync(); + + entity.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); + entity.UpdatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); + } + + [Fact] + public async Task Modified_Entity_Sets_UpdatedAt_But_Not_CreatedAt() + { + await using var fixture = CreateFixture(); + var entity = new TimestampedTestEntity { Name = "Test" }; + + fixture.Context.TimestampedEntities.Add(entity); + await fixture.Context.SaveChangesAsync(); + + var originalCreatedAt = entity.CreatedAt; + await Task.Delay(10); + + entity.Name = "Updated"; + await fixture.Context.SaveChangesAsync(); + + entity.CreatedAt.Should().Be(originalCreatedAt); + entity.UpdatedAt.Should().BeAfter(originalCreatedAt); + } + + [Fact] + public async Task Added_Auditable_Entity_Sets_CreatedBy_And_UpdatedBy() + { + await using var fixture = CreateFixture(TestUserId); + var entity = new AuditableTestEntity { Name = "Test" }; + + fixture.Context.AuditableEntities.Add(entity); + await fixture.Context.SaveChangesAsync(); + + entity.CreatedBy.Should().Be(TestUserId); + entity.UpdatedBy.Should().Be(TestUserId); + } + + [Fact] + public async Task Modified_Auditable_Entity_Updates_UpdatedBy_Only() + { + await using var fixture = CreateFixture(TestUserId); + var entity = new AuditableTestEntity { Name = "Test" }; + + fixture.Context.AuditableEntities.Add(entity); + await fixture.Context.SaveChangesAsync(); + + entity.Name = "Updated"; + await fixture.Context.SaveChangesAsync(); + + entity.CreatedBy.Should().Be(TestUserId); + entity.UpdatedBy.Should().Be(TestUserId); + } + + [Fact] + public async Task Delete_SoftDelete_Entity_Converts_To_Modified() + { + await using var fixture = CreateFixture(TestUserId); + var entity = new SoftDeleteTestEntity { Name = "Test" }; + + fixture.Context.SoftDeleteEntities.Add(entity); + await fixture.Context.SaveChangesAsync(); + + fixture.Context.SoftDeleteEntities.Remove(entity); + await fixture.Context.SaveChangesAsync(); + + entity.IsDeleted.Should().BeTrue(); + entity.DeletedAt.Should().NotBeNull(); + entity.DeletedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); + entity.DeletedBy.Should().Be(TestUserId); + } + + [Fact] + public async Task SoftDeleted_Entity_Excluded_By_Query_Filter() + { + await using var fixture = CreateFixture(); + var entity = new SoftDeleteTestEntity { Name = "Test" }; + + fixture.Context.SoftDeleteEntities.Add(entity); + await fixture.Context.SaveChangesAsync(); + + fixture.Context.SoftDeleteEntities.Remove(entity); + await fixture.Context.SaveChangesAsync(); + + var results = await fixture.Context.SoftDeleteEntities.ToListAsync(); + results.Should().BeEmpty(); + + // But it should still exist when ignoring query filters + var allResults = await fixture.Context.SoftDeleteEntities.IgnoreQueryFilters().ToListAsync(); + allResults.Should().HaveCount(1); + } + + [Fact] + public async Task Added_Versioned_Entity_Gets_Version_1() + { + await using var fixture = CreateFixture(); + var entity = new VersionedTestEntity { Name = "Test" }; + + fixture.Context.VersionedEntities.Add(entity); + await fixture.Context.SaveChangesAsync(); + + entity.Version.Should().Be(1); + } + + [Fact] + public async Task Modified_Versioned_Entity_Increments_Version() + { + await using var fixture = CreateFixture(); + var entity = new VersionedTestEntity { Name = "Test" }; + + fixture.Context.VersionedEntities.Add(entity); + await fixture.Context.SaveChangesAsync(); + entity.Version.Should().Be(1); + + entity.Name = "Updated"; + await fixture.Context.SaveChangesAsync(); + entity.Version.Should().Be(2); + + entity.Name = "Updated Again"; + await fixture.Context.SaveChangesAsync(); + entity.Version.Should().Be(3); + } + + [Fact] + public async Task Added_Entity_Gets_ConcurrencyStamp() + { + await using var fixture = CreateFixture(); + var entity = new ConcurrencyTestEntity { Name = "Test" }; + + fixture.Context.ConcurrencyEntities.Add(entity); + await fixture.Context.SaveChangesAsync(); + + entity.ConcurrencyStamp.Should().NotBeNullOrEmpty(); + entity.ConcurrencyStamp.Should().HaveLength(32); // Guid without hyphens + } + + [Fact] + public async Task Modified_Entity_Gets_New_ConcurrencyStamp() + { + await using var fixture = CreateFixture(); + var entity = new ConcurrencyTestEntity { Name = "Test" }; + + fixture.Context.ConcurrencyEntities.Add(entity); + await fixture.Context.SaveChangesAsync(); + + var originalStamp = entity.ConcurrencyStamp; + + entity.Name = "Updated"; + await fixture.Context.SaveChangesAsync(); + + entity.ConcurrencyStamp.Should().NotBe(originalStamp); + } + + [Fact] + public async Task MultiTenant_Entity_Gets_TenantId_On_Add() + { + await using var fixture = CreateFixture(tenantId: "tenant-abc"); + var entity = new MultiTenantTestEntity { Name = "Test" }; + + fixture.Context.MultiTenantEntities.Add(entity); + await fixture.Context.SaveChangesAsync(); + + entity.TenantId.Should().Be("tenant-abc"); + } + + [Fact] + public async Task MultiTenant_Query_Filter_Restricts_To_Current_Tenant() + { + // Uses a dedicated DbContext type to avoid EF Core model caching issues + // with the main EntityTestDbContext (which may be built without tenant context). + var tenantContext = new TestTenantContext("tenant-a"); + await using var fixture = CreateMultiTenantFixture(tenantContext); + + // Insert tenant-a entity via the interceptor + var entityA = new MultiTenantTestEntity { Name = "A" }; + fixture.Context.MultiTenantEntities.Add(entityA); + await fixture.Context.SaveChangesAsync(); + + // Insert tenant-b entity directly via SQL to bypass the interceptor. + // The table name is from EF Core metadata, not user input. +#pragma warning disable EF1003 // Test-only raw SQL with no user input + var tableName = fixture.Context.Model.FindEntityType(typeof(MultiTenantTestEntity))!.GetTableName(); + await fixture.Context.Database.ExecuteSqlRawAsync( + "INSERT INTO \"" + tableName + "\" (\"Name\", \"TenantId\") VALUES ('B', 'tenant-b')"); +#pragma warning restore EF1003 + + // Query should only return tenant-a's entities + var results = await fixture.Context.MultiTenantEntities.ToListAsync(); + results.Should().HaveCount(1); + results[0].Name.Should().Be("A"); + + // IgnoreQueryFilters should return all + var allResults = await fixture.Context.MultiTenantEntities.IgnoreQueryFilters().ToListAsync(); + allResults.Should().HaveCount(2); + } + + private static TestFixture CreateMultiTenantFixture(TestTenantContext tenantContext) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["Database:DefaultConnection"] = "Data Source=:memory:", + } + ) + .Build(); + + var services = new ServiceCollection(); + var httpContextAccessor = new HttpContextAccessor { HttpContext = new DefaultHttpContext() }; + services.AddSingleton(httpContextAccessor); + services.AddSingleton(tenantContext); + services.AddScoped(); + services.AddModuleDbContext(config, "MultiTenantTest"); + + var provider = services.BuildServiceProvider(); + var context = provider.GetRequiredService(); + context.Database.OpenConnection(); + context.Database.EnsureCreated(); + + return new TestFixture(provider, context); + } + + [Fact] + public async Task FullAuditableEntity_BaseClass_Works() + { + await using var fixture = CreateFixture(TestUserId); + var entity = new FullAuditableTestEntity { Name = "Test" }; + + fixture.Context.FullAuditableEntities.Add(entity); + await fixture.Context.SaveChangesAsync(); + + entity.CreatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); + entity.UpdatedAt.Should().BeCloseTo(DateTimeOffset.UtcNow, TimeSpan.FromSeconds(5)); + entity.CreatedBy.Should().Be(TestUserId); + entity.UpdatedBy.Should().Be(TestUserId); + entity.Version.Should().Be(1); + entity.ConcurrencyStamp.Should().NotBeNullOrEmpty(); + entity.IsDeleted.Should().BeFalse(); + } + + private static TestFixture CreateFixture(string? userId = null, string? tenantId = null, TestTenantContext? tenantContext = null) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection( + new Dictionary + { + ["Database:DefaultConnection"] = "Data Source=:memory:", + } + ) + .Build(); + + var services = new ServiceCollection(); + + var httpContext = new DefaultHttpContext(); + if (userId is not null) + { + httpContext.User = new ClaimsPrincipal( + new ClaimsIdentity( + [new Claim(ClaimTypes.NameIdentifier, userId)], + "TestAuth" + ) + ); + } + + var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext }; + services.AddSingleton(httpContextAccessor); + + var effectiveTenantContext = tenantContext ?? (tenantId is not null ? new TestTenantContext(tenantId) : null); + if (effectiveTenantContext is not null) + { + services.AddSingleton(effectiveTenantContext); + } + + services.AddScoped(); + services.AddModuleDbContext(config, "EntityTest"); + + var provider = services.BuildServiceProvider(); + var context = provider.GetRequiredService(); + context.Database.OpenConnection(); + context.Database.EnsureCreated(); + + return new TestFixture(provider, context); + } + + private sealed class TestFixture(ServiceProvider provider, EntityTestDbContext context) + : IAsyncDisposable + { + public EntityTestDbContext Context => context; + + public async ValueTask DisposeAsync() + { + await context.DisposeAsync(); + await provider.DisposeAsync(); + } + } + + private sealed class TestFixture(ServiceProvider provider, T context) + : IAsyncDisposable where T : DbContext + { + public T Context => context; + + public async ValueTask DisposeAsync() + { + await context.DisposeAsync(); + await provider.DisposeAsync(); + } + } + + private sealed record TestTenantContext(string? TenantId) : ITenantContext; +} + +// --- Test entities --- + +public class TimestampedTestEntity : IHasCreationTime, IHasModificationTime +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } +} + +public class AuditableTestEntity : IAuditable +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public string? CreatedBy { get; set; } + public string? UpdatedBy { get; set; } +} + +public class SoftDeleteTestEntity : ISoftDelete, IHasCreationTime +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public DateTimeOffset CreatedAt { get; set; } + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + public string? DeletedBy { get; set; } +} + +public class VersionedTestEntity : IVersioned +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public int Version { get; set; } +} + +public class ConcurrencyTestEntity : IHasConcurrencyStamp +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string ConcurrencyStamp { get; set; } = string.Empty; +} + +public class MultiTenantTestEntity : IMultiTenant +{ + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string TenantId { get; set; } = string.Empty; +} + +public class FullAuditableTestEntity : FullAuditableEntity +{ + public string Name { get; set; } = string.Empty; +} + +public class EntityTestDbContext( + DbContextOptions options, + IOptions dbOptions, + ITenantContext? tenantContext = null +) : DbContext(options) +{ + public DbSet TimestampedEntities => Set(); + public DbSet AuditableEntities => Set(); + public DbSet SoftDeleteEntities => Set(); + public DbSet VersionedEntities => Set(); + public DbSet ConcurrencyEntities => Set(); + public DbSet MultiTenantEntities => Set(); + public DbSet FullAuditableEntities => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => e.HasKey(x => x.Id)); + modelBuilder.Entity(e => e.HasKey(x => x.Id)); + modelBuilder.Entity(e => e.HasKey(x => x.Id)); + modelBuilder.Entity(e => e.HasKey(x => x.Id)); + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.ConcurrencyStamp).HasMaxLength(50); + }); + modelBuilder.Entity(e => e.HasKey(x => x.Id)); + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.Id).ValueGeneratedOnAdd(); + e.Property(x => x.ConcurrencyStamp).HasMaxLength(50); + }); + + modelBuilder.ApplyModuleSchema("EntityTest", dbOptions.Value); + + if (tenantContext is not null) + { + modelBuilder.ApplyMultiTenantFilters(tenantContext); + } + } +} + +public class MultiTenantTestDbContext( + DbContextOptions options, + IOptions dbOptions, + ITenantContext tenantContext +) : DbContext(options) +{ + public DbSet MultiTenantEntities => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => e.HasKey(x => x.Id)); + modelBuilder.ApplyModuleSchema("MultiTenantTest", dbOptions.Value); + modelBuilder.ApplyMultiTenantFilters(tenantContext); + } +}