From 2153d4f9b80cf9602a60ab3fdda49f9bd676dc47 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Sun, 10 May 2026 22:55:43 +0200 Subject: [PATCH] feat(database): complete soft-delete recovery surface (closes #165) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the recovery operations missing from the existing ISoftDelete pipeline: WithTrashed/OnlyTrashed query extensions, ForceDelete bypass, an ISoftDeleteService with Restore/ForceDelete/Purge, and CrudEndpoints helpers — modelled on Laravel's SoftDeletes trait. --- docs/site/.vitepress/config.ts | 1 + docs/site/guide/soft-delete.md | 197 ++++++++++++++ .../Endpoints/CrudEndpoints.cs | 22 ++ .../Interceptors/EntityInterceptor.cs | 6 + .../SoftDelete/ForceDeleteExtensions.cs | 82 ++++++ .../SoftDelete/ISoftDeleteService.cs | 44 +++ .../SoftDelete/SoftDeleteQueryExtensions.cs | 36 +++ .../SoftDelete/SoftDeleteService.cs | 233 ++++++++++++++++ .../SoftDeleteServiceCollectionExtensions.cs | 28 ++ .../SoftDeleteTests.cs | 257 ++++++++++++++++++ 10 files changed, 906 insertions(+) create mode 100644 docs/site/guide/soft-delete.md create mode 100644 framework/SimpleModule.Database/SoftDelete/ForceDeleteExtensions.cs create mode 100644 framework/SimpleModule.Database/SoftDelete/ISoftDeleteService.cs create mode 100644 framework/SimpleModule.Database/SoftDelete/SoftDeleteQueryExtensions.cs create mode 100644 framework/SimpleModule.Database/SoftDelete/SoftDeleteService.cs create mode 100644 framework/SimpleModule.Database/SoftDelete/SoftDeleteServiceCollectionExtensions.cs create mode 100644 tests/SimpleModule.Database.Tests/SoftDeleteTests.cs diff --git a/docs/site/.vitepress/config.ts b/docs/site/.vitepress/config.ts index 532bcd3f..e9f98ce4 100644 --- a/docs/site/.vitepress/config.ts +++ b/docs/site/.vitepress/config.ts @@ -43,6 +43,7 @@ export default defineConfig({ { text: 'Endpoints', link: '/guide/endpoints' }, { text: 'Contracts & DTOs', link: '/guide/contracts' }, { text: 'Database', link: '/guide/database' }, + { text: 'Soft Delete', link: '/guide/soft-delete' }, ], }, { diff --git a/docs/site/guide/soft-delete.md b/docs/site/guide/soft-delete.md new file mode 100644 index 00000000..e2cbcc90 --- /dev/null +++ b/docs/site/guide/soft-delete.md @@ -0,0 +1,197 @@ +--- +outline: deep +--- + +# Soft Delete + +SimpleModule's soft-delete surface mirrors Laravel's `SoftDeletes` trait. Entities that implement `ISoftDelete` are automatically excluded from queries by a global query filter and are kept in the database when `Remove()` is called. The framework also exposes a recovery surface — `WithTrashed`, `OnlyTrashed`, `Restore`, `ForceDelete` — so admin restore UIs, audit flows, and retention policies do not have to leak the abstraction by reaching for `IgnoreQueryFilters()`. + +## ISoftDelete + +```csharp +public interface ISoftDelete +{ + bool IsDeleted { get; set; } + DateTimeOffset? DeletedAt { get; set; } + string? DeletedBy { get; set; } +} +``` + +`FullAuditableEntity` implements this for you. When you call `dbContext.Remove(entity)` on an `ISoftDelete` entity, the `EntityInterceptor` converts the delete to an update that sets `IsDeleted=true`, `DeletedAt=UtcNow`, and `DeletedBy` to the current user. A real `DELETE` statement is never issued unless you opt into [force delete](#force-delete). + +## Querying Trashed Rows + +By default, soft-deleted rows are hidden from every `DbSet` query. + +```csharp +public static class SoftDeleteQueryExtensions +{ + public static IQueryable WithTrashed(this IQueryable q) + where T : class, ISoftDelete; + + public static IQueryable OnlyTrashed(this IQueryable q) + where T : class, ISoftDelete; +} +``` + +| Extension | Result | +|-----------|--------| +| (default) | Live rows only — soft-delete filter applied | +| `WithTrashed()` | Live + trashed rows | +| `OnlyTrashed()` | Trashed rows only | + +```csharp +// Live rows +var customers = await db.Customers.ToListAsync(); + +// Live + trashed +var all = await db.Customers.WithTrashed().ToListAsync(); + +// Trashed only — for an admin "Recently Deleted" view +var trashed = await db.Customers + .OnlyTrashed() + .OrderByDescending(c => c.DeletedAt) + .Take(50) + .ToListAsync(); +``` + +::: tip Multi-tenant safety +`WithTrashed` / `OnlyTrashed` ignore only the soft-delete filter. The multi-tenant filter (and any other named filter) stays active, so tenant isolation is preserved when admins browse the trash. +::: + +## ISoftDeleteService + +For per-row recovery operations, register `ISoftDeleteService` and inject it into your endpoints or services. + +```csharp +public interface ISoftDeleteService where T : class, ISoftDelete +{ + Task RestoreAsync(object id, CancellationToken ct = default); + Task ForceDeleteAsync(object id, CancellationToken ct = default); + Task ForceDeleteRangeAsync(IEnumerable ids, CancellationToken ct = default); + Task PurgeOlderThanAsync(TimeSpan age, CancellationToken ct = default); +} +``` + +Register one per soft-deletable entity in the owning module's `ConfigureServices`: + +```csharp +public void ConfigureServices(IServiceCollection services, IConfiguration configuration) +{ + services.AddModuleDbContext(configuration, "Customers"); + services.AddSoftDelete(); +} +``` + +`AddSoftDelete()` registers a scoped `ISoftDeleteService` backed by the supplied `DbContext`. + +## Restore + +```csharp +app.MapPost("/customers/{id:int}/restore", + (int id, ISoftDeleteService svc, CancellationToken ct) => + CrudEndpoints.Restore(() => svc.RestoreAsync(id, ct))); +``` + +Returns `204 No Content` if a trashed row was restored, `404 Not Found` if the id doesn't match a trashed row. + +`RestoreAsync` clears `IsDeleted`, `DeletedAt`, and `DeletedBy`. The `EntityInterceptor` writes a fresh `UpdatedAt`/`UpdatedBy` for the row. + +## Force Delete + +When you genuinely need to remove a row from the database — GDPR purges, test cleanup, or admin-initiated permanent deletion — call the `ForceDelete` extension instead of `Remove`: + +```csharp +db.ForceDelete(customer); // single +db.ForceDeleteRange(customers); // batch +await db.SaveChangesAsync(); +``` + +The interceptor consults a per-context marker set, recognises the entry as opted-out of soft delete, and lets the `DELETE` go through. The marker is consumed on observation, so it never leaks across calls. + +For id-based force delete, use the service: + +```csharp +await svc.ForceDeleteAsync(customerId, ct); +await svc.ForceDeleteRangeAsync([id1, id2, id3], ct); +``` + +`ForceDeleteAsync` finds the row regardless of trashed state, so it works on both live and already-trashed records. + +## Retention / Purge Job + +`PurgeOlderThanAsync(age)` permanently deletes every trashed row whose `DeletedAt` is older than the given window. Wire it up as a recurring job using the `BackgroundJobs` module: + +```csharp +public sealed class PurgeOldCustomersJob( + ISoftDeleteService softDelete, + ISettingsContracts settings +) : IModuleJob +{ + public async Task ExecuteAsync(IJobExecutionContext context, CancellationToken ct) + { + var raw = await settings.GetSettingAsync("SoftDelete.Customer.RetainDays", SettingScope.System); + var days = int.TryParse(raw, out var d) ? d : 90; + var purged = await softDelete.PurgeOlderThanAsync(TimeSpan.FromDays(days), ct); + context.Log($"Purged {purged} customer rows older than {days} days"); + } +} + +// In your module's ConfigureServices +services.AddModuleJob(); + +// On startup, schedule it +await backgroundJobs.AddRecurringAsync( + name: "Purge old customers", + cronExpression: "0 3 * * *"); // every day at 03:00 +``` + +The retention window is read from a per-entity setting (`SoftDelete.{Entity}.RetainDays`), so operators can tune it without redeploying. + +## End-to-End Example + +```csharp +// Endpoint module +public sealed class RestoreCustomerEndpoint : IEndpoint +{ + public void Configure(RouteGroupBuilder group) + { + group.MapPost("/{id:int}/restore", + (int id, ISoftDeleteService svc, CancellationToken ct) => + CrudEndpoints.Restore(() => svc.RestoreAsync(id, ct))) + .RequireAuthorization("Customers.Restore"); + } +} + +public sealed class ForceDeleteCustomerEndpoint : IEndpoint +{ + public void Configure(RouteGroupBuilder group) + { + group.MapDelete("/{id:int}/force", + (int id, ISoftDeleteService svc, CancellationToken ct) => + CrudEndpoints.ForceDelete(() => svc.ForceDeleteAsync(id, ct))) + .RequireAuthorization("Customers.ForceDelete"); + } +} +``` + +## Reference + +| Symbol | Purpose | +|--------|---------| +| `WithTrashed()` | Include soft-deleted rows in a query | +| `OnlyTrashed()` | Return only soft-deleted rows | +| `DbContext.ForceDelete(entity)` | Issue a real DELETE for one row | +| `DbContext.ForceDeleteRange(entities)` | Issue real DELETEs for many rows | +| `ISoftDeleteService.RestoreAsync(id)` | Clear soft-delete fields by id | +| `ISoftDeleteService.ForceDeleteAsync(id)` | Hard delete by id | +| `ISoftDeleteService.ForceDeleteRangeAsync(ids)` | Batch hard delete | +| `ISoftDeleteService.PurgeOlderThanAsync(age)` | Retention sweep | +| `CrudEndpoints.Restore` | Endpoint helper, 204/404 | +| `CrudEndpoints.ForceDelete` | Endpoint helper, 204/404 | + +## Next Steps + +- [Database](/guide/database) -- query filters, schema isolation, and DbContext wiring +- [Background Jobs](/guide/background-jobs) -- scheduling the retention sweep +- [EF Core Interceptors](/advanced/interceptors) -- how the soft-delete interceptor is wired in diff --git a/framework/SimpleModule.Core/Endpoints/CrudEndpoints.cs b/framework/SimpleModule.Core/Endpoints/CrudEndpoints.cs index ad96abe4..6f744fc8 100644 --- a/framework/SimpleModule.Core/Endpoints/CrudEndpoints.cs +++ b/framework/SimpleModule.Core/Endpoints/CrudEndpoints.cs @@ -34,4 +34,26 @@ public static async Task Delete(Func delete) await delete(); return TypedResults.NoContent(); } + + /// + /// Endpoint helper for restoring a soft-deleted row. The + /// delegate should call ISoftDeleteService<T>.RestoreAsync(id) and return + /// the number of rows affected (0 → 404, 1 → 204). + /// + public static async Task Restore(Func> restore) + { + var affected = await restore(); + return affected > 0 ? TypedResults.NoContent() : TypedResults.NotFound(); + } + + /// + /// Endpoint helper for force-deleting a row (bypassing soft delete). The + /// delegate should call + /// ISoftDeleteService<T>.ForceDeleteAsync(id). + /// + public static async Task ForceDelete(Func> forceDelete) + { + var affected = await forceDelete(); + return affected > 0 ? TypedResults.NoContent() : TypedResults.NotFound(); + } } diff --git a/framework/SimpleModule.Database/Interceptors/EntityInterceptor.cs b/framework/SimpleModule.Database/Interceptors/EntityInterceptor.cs index a878aa22..21858ba5 100644 --- a/framework/SimpleModule.Database/Interceptors/EntityInterceptor.cs +++ b/framework/SimpleModule.Database/Interceptors/EntityInterceptor.cs @@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore.ChangeTracking; using Microsoft.EntityFrameworkCore.Diagnostics; using SimpleModule.Core.Entities; +using SimpleModule.Database.SoftDelete; namespace SimpleModule.Database.Interceptors; @@ -65,6 +66,11 @@ is not (EntityState.Added or EntityState.Modified or EntityState.Deleted) break; case EntityState.Deleted when entry.Entity is ISoftDelete sd: + if (ForceDeleteExtensions.IsMarked(eventData.Context, sd)) + { + ForceDeleteExtensions.Consume(eventData.Context, sd); + break; + } entry.State = EntityState.Modified; sd.IsDeleted = true; sd.DeletedAt = now; diff --git a/framework/SimpleModule.Database/SoftDelete/ForceDeleteExtensions.cs b/framework/SimpleModule.Database/SoftDelete/ForceDeleteExtensions.cs new file mode 100644 index 00000000..b631edeb --- /dev/null +++ b/framework/SimpleModule.Database/SoftDelete/ForceDeleteExtensions.cs @@ -0,0 +1,82 @@ +using System.Runtime.CompilerServices; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.ChangeTracking; +using SimpleModule.Core.Entities; + +namespace SimpleModule.Database.SoftDelete; + +/// +/// Extensions that bypass the soft-delete interceptor and issue a real DELETE +/// against the database. Required for GDPR purges, test cleanup, and recovery flows +/// where retaining the row is undesirable. +/// +/// +/// The marker is tracked per-entity in a +/// keyed by , then consumed (and removed) by +/// EntityInterceptor when it inspects each entry. Markers do not survive past +/// the next SaveChanges on that context. +/// +/// If SaveChanges never runs after a ForceDelete call (e.g. an exception +/// aborts the unit of work, or the entity is detached via ChangeTracker.Clear() +/// before save), the marker remains until the itself is collected. +/// This is bounded — scoped contexts are short-lived — but callers using long-lived +/// contexts should not stage force-deletes they don't intend to commit. +/// +/// +public static class ForceDeleteExtensions +{ + private static readonly ConditionalWeakTable> Markers = new(); + + /// + /// Marks for hard delete and removes it from the context. + /// The soft-delete interceptor will leave this entry alone, so a real DELETE + /// statement is issued on SaveChanges. + /// + public static EntityEntry ForceDelete(this DbContext context, T entity) + where T : class, ISoftDelete + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(entity); + + Mark(context, entity); + return context.Remove(entity); + } + + /// + /// Marks each entity for hard delete and removes them from the context. + /// + public static void ForceDeleteRange(this DbContext context, IEnumerable entities) + where T : class, ISoftDelete + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(entities); + + foreach (var entity in entities) + { + Mark(context, entity); + context.Remove(entity); + } + } + + internal static bool IsMarked(DbContext context, object entity) => + Markers.TryGetValue(context, out var set) && set.Contains(entity); + + /// + /// Removes the marker for after the interceptor has + /// observed it. Called by EntityInterceptor per entry to keep the marker + /// set bounded. + /// + internal static void Consume(DbContext context, object entity) + { + if (Markers.TryGetValue(context, out var set)) + { + set.Remove(entity); + } + } + + private static void Mark(DbContext context, object entity) + { + var set = Markers.GetValue(context, _ => []); + set.Add(entity); + } +} diff --git a/framework/SimpleModule.Database/SoftDelete/ISoftDeleteService.cs b/framework/SimpleModule.Database/SoftDelete/ISoftDeleteService.cs new file mode 100644 index 00000000..c38e675b --- /dev/null +++ b/framework/SimpleModule.Database/SoftDelete/ISoftDeleteService.cs @@ -0,0 +1,44 @@ +using SimpleModule.Core.Entities; + +namespace SimpleModule.Database.SoftDelete; + +/// +/// Recovery operations for a soft-deletable entity. Mirrors the operations needed by +/// admin restore UIs, GDPR purge flows, and retention policies. +/// +/// The entity type managed by this service. +public interface ISoftDeleteService + where T : class, ISoftDelete +{ + /// + /// Restores a single soft-deleted row by primary key. The row's , + /// , and fields are cleared. + /// + /// 1 if a row was restored; 0 if the id was not found among trashed rows. + Task RestoreAsync(object id, CancellationToken cancellationToken = default); + + /// + /// Issues a real DELETE for the row with the given key, bypassing the soft-delete + /// interceptor. The row is removed from the database whether or not it was previously + /// soft-deleted. + /// + /// 1 if a row was deleted; 0 if the id was not found. + Task ForceDeleteAsync(object id, CancellationToken cancellationToken = default); + + /// + /// Issues a real DELETE for each row matching the given keys. + /// + /// The number of rows actually deleted. + Task ForceDeleteRangeAsync( + IEnumerable ids, + CancellationToken cancellationToken = default + ); + + /// + /// Permanently deletes every soft-deleted row whose + /// is older than . Use this from a scheduled background job to + /// implement retention policies. + /// + /// The number of rows purged. + Task PurgeOlderThanAsync(TimeSpan age, CancellationToken cancellationToken = default); +} diff --git a/framework/SimpleModule.Database/SoftDelete/SoftDeleteQueryExtensions.cs b/framework/SimpleModule.Database/SoftDelete/SoftDeleteQueryExtensions.cs new file mode 100644 index 00000000..409490fa --- /dev/null +++ b/framework/SimpleModule.Database/SoftDelete/SoftDeleteQueryExtensions.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core.Entities; + +namespace SimpleModule.Database.SoftDelete; + +/// +/// Query extensions for working with soft-deleted () entities. +/// Provides a fluent surface that mirrors Laravel's withTrashed() / onlyTrashed() +/// helpers so callers do not have to reach for +/// directly and leak the soft-delete abstraction. +/// +public static class SoftDeleteQueryExtensions +{ + /// + /// Includes soft-deleted rows in the query result alongside live rows by ignoring + /// the soft-delete query filter. + /// + public static IQueryable WithTrashed(this IQueryable query) + where T : class, ISoftDelete + { + ArgumentNullException.ThrowIfNull(query); + return query.IgnoreQueryFilters([DatabaseConstants.SoftDeleteQueryFilterKey]); + } + + /// + /// Returns only soft-deleted rows. Other query filters (e.g. multi-tenant) remain active. + /// + public static IQueryable OnlyTrashed(this IQueryable query) + where T : class, ISoftDelete + { + ArgumentNullException.ThrowIfNull(query); + return query + .IgnoreQueryFilters([DatabaseConstants.SoftDeleteQueryFilterKey]) + .Where(e => e.IsDeleted); + } +} diff --git a/framework/SimpleModule.Database/SoftDelete/SoftDeleteService.cs b/framework/SimpleModule.Database/SoftDelete/SoftDeleteService.cs new file mode 100644 index 00000000..c7cbd0e1 --- /dev/null +++ b/framework/SimpleModule.Database/SoftDelete/SoftDeleteService.cs @@ -0,0 +1,233 @@ +using System.Linq.Expressions; +using Microsoft.EntityFrameworkCore; +using SimpleModule.Core.Entities; + +namespace SimpleModule.Database.SoftDelete; + +/// +/// Default implementation. Generic over the entity +/// type and its owning ; register one per soft-deletable entity +/// via . +/// +public sealed class SoftDeleteService(TContext context) : ISoftDeleteService + where T : class, ISoftDelete + where TContext : DbContext +{ + private const int PurgeBatchSize = 1000; + + private static KeyMetadata? _keyMetadata; + + public async Task RestoreAsync(object id, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(id); + + var entity = await FindTrashedAsync(id, cancellationToken).ConfigureAwait(false); + if (entity is null) + return 0; + + entity.IsDeleted = false; + entity.DeletedAt = null; + entity.DeletedBy = null; + return await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false) > 0 ? 1 : 0; + } + + public async Task ForceDeleteAsync( + object id, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(id); + + var entity = await FindAnyAsync(id, cancellationToken).ConfigureAwait(false); + if (entity is null) + return 0; + + context.ForceDelete(entity); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return 1; + } + + public async Task ForceDeleteRangeAsync( + IEnumerable ids, + CancellationToken cancellationToken = default + ) + { + ArgumentNullException.ThrowIfNull(ids); + + var meta = GetKeyMetadata(); + var typedIds = CoerceIds(ids, meta.KeyType); + if (typedIds.Length == 0) + return 0; + + var predicate = BuildKeyContainsPredicate(meta, typedIds); + var entities = await context + .Set() + .WithTrashed() + .Where(predicate) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (entities.Count == 0) + return 0; + + context.ForceDeleteRange(entities); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + return entities.Count; + } + + public async Task PurgeOlderThanAsync( + TimeSpan age, + CancellationToken cancellationToken = default + ) + { + if (age < TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(age), "Age must be non-negative."); + + var cutoff = DateTimeOffset.UtcNow - age; + var meta = GetKeyMetadata(); + var totalPurged = 0; + var skip = 0; + + // Page through trashed rows ordered by primary key. We can't filter or order on + // DeletedAt server-side under SQLite (no DateTimeOffset translation), so each + // page is filtered against the cutoff in memory. Chunk size bounds the change + // tracker and keeps memory under control on large tables. + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var page = await meta.OrderByKey(context.Set().OnlyTrashed()) + .Skip(skip) + .Take(PurgeBatchSize) + .ToListAsync(cancellationToken) + .ConfigureAwait(false); + + if (page.Count == 0) + break; + + var stale = page.Where(e => e.DeletedAt is { } d && d < cutoff).ToList(); + if (stale.Count > 0) + { + context.ForceDeleteRange(stale); + await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false); + totalPurged += stale.Count; + } + + // Skipped rows shift left after their preceding peers are deleted. + // Advance the cursor only past the rows we kept. + skip += page.Count - stale.Count; + + if (page.Count < PurgeBatchSize) + break; + } + + return totalPurged; + } + + private Task FindTrashedAsync(object id, CancellationToken cancellationToken) + { + var meta = GetKeyMetadata(); + var coerced = CoerceId(id, meta.KeyType); + return context + .Set() + .OnlyTrashed() + .FirstOrDefaultAsync(meta.BuildKeyEqualsPredicate(coerced), cancellationToken); + } + + private Task FindAnyAsync(object id, CancellationToken cancellationToken) + { + var meta = GetKeyMetadata(); + var coerced = CoerceId(id, meta.KeyType); + return context + .Set() + .WithTrashed() + .FirstOrDefaultAsync(meta.BuildKeyEqualsPredicate(coerced), cancellationToken); + } + + private KeyMetadata GetKeyMetadata() + { + if (_keyMetadata is not null) + return _keyMetadata; + + var entityType = + context.Model.FindEntityType(typeof(T)) + ?? throw new InvalidOperationException( + $"Entity type {typeof(T).Name} is not registered with {typeof(TContext).Name}." + ); + var key = + entityType.FindPrimaryKey() + ?? throw new InvalidOperationException( + $"Entity type {typeof(T).Name} has no primary key." + ); + if (key.Properties.Count != 1) + throw new InvalidOperationException( + $"SoftDeleteService requires a single-property primary key on {typeof(T).Name}." + ); + + var keyProperty = key.Properties[0]; + _keyMetadata = new KeyMetadata(keyProperty.Name, keyProperty.ClrType); + return _keyMetadata; + } + + private static Expression> BuildKeyContainsPredicate( + KeyMetadata meta, + Array typedIds + ) + { + var parameter = Expression.Parameter(typeof(T), "e"); + var keyAccess = Expression.Property(parameter, meta.KeyName); + var idsConstant = Expression.Constant(typedIds); + var contains = ContainsMethodFor(meta.KeyType); + var call = Expression.Call(contains, idsConstant, keyAccess); + return Expression.Lambda>(call, parameter); + } + + private static System.Reflection.MethodInfo ContainsMethodFor(Type keyType) => + typeof(Enumerable) + .GetMethods() + .First(m => m.Name == nameof(Enumerable.Contains) && m.GetParameters().Length == 2) + .MakeGenericMethod(keyType); + + private static Array CoerceIds(IEnumerable ids, Type keyType) + { + var list = ids.ToList(); + var typed = Array.CreateInstance(keyType, list.Count); + for (var i = 0; i < list.Count; i++) + { + typed.SetValue(CoerceId(list[i], keyType), i); + } + return typed; + } + + private static object CoerceId(object id, Type keyType) + { + ArgumentNullException.ThrowIfNull(id); + return id.GetType() == keyType + ? id + : Convert.ChangeType(id, keyType, System.Globalization.CultureInfo.InvariantCulture); + } + + private sealed record KeyMetadata(string KeyName, Type KeyType) + { + public Expression> BuildKeyEqualsPredicate(object id) + { + var parameter = Expression.Parameter(typeof(T), "e"); + var keyAccess = Expression.Property(parameter, KeyName); + var idConstant = Expression.Constant(id, KeyType); + var equals = Expression.Equal(keyAccess, idConstant); + return Expression.Lambda>(equals, parameter); + } + + public IOrderedQueryable OrderByKey(IQueryable source) + { + var parameter = Expression.Parameter(typeof(T), "e"); + var keyAccess = Expression.Property(parameter, KeyName); + var lambda = Expression.Lambda(keyAccess, parameter); + var orderBy = typeof(Queryable) + .GetMethods() + .First(m => m.Name == nameof(Queryable.OrderBy) && m.GetParameters().Length == 2) + .MakeGenericMethod(typeof(T), KeyType); + return (IOrderedQueryable)orderBy.Invoke(null, [source, lambda])!; + } + } +} diff --git a/framework/SimpleModule.Database/SoftDelete/SoftDeleteServiceCollectionExtensions.cs b/framework/SimpleModule.Database/SoftDelete/SoftDeleteServiceCollectionExtensions.cs new file mode 100644 index 00000000..7c45a1d0 --- /dev/null +++ b/framework/SimpleModule.Database/SoftDelete/SoftDeleteServiceCollectionExtensions.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using SimpleModule.Core.Entities; + +namespace SimpleModule.Database.SoftDelete; + +/// +/// Service registration helpers for . +/// +public static class SoftDeleteServiceCollectionExtensions +{ + /// + /// Registers an backed by + /// in DI. Call this once per soft-deletable entity from the owning module's + /// ConfigureServices: + /// + /// services.AddSoftDelete<Customer, CustomersDbContext>(); + /// + /// + public static IServiceCollection AddSoftDelete(this IServiceCollection services) + where T : class, ISoftDelete + where TContext : DbContext + { + ArgumentNullException.ThrowIfNull(services); + services.AddScoped, SoftDeleteService>(); + return services; + } +} diff --git a/tests/SimpleModule.Database.Tests/SoftDeleteTests.cs b/tests/SimpleModule.Database.Tests/SoftDeleteTests.cs new file mode 100644 index 00000000..aa75bd8d --- /dev/null +++ b/tests/SimpleModule.Database.Tests/SoftDeleteTests.cs @@ -0,0 +1,257 @@ +using System.Security.Claims; +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; +using SimpleModule.Database.SoftDelete; + +namespace SimpleModule.Database.Tests; + +public sealed class SoftDeleteTests +{ + [Fact] + public async Task WithTrashed_Returns_Live_And_Soft_Deleted_Rows() + { + await using var fixture = CreateFixture(); + fixture.Context.Items.AddRange( + new SoftDeleteItem { Name = "alive" }, + new SoftDeleteItem { Name = "trashed" } + ); + await fixture.Context.SaveChangesAsync(); + + var trashed = await fixture.Context.Items.FirstAsync(x => x.Name == "trashed"); + fixture.Context.Items.Remove(trashed); + await fixture.Context.SaveChangesAsync(); + + var visible = await fixture.Context.Items.ToListAsync(); + var withTrashed = await fixture.Context.Items.WithTrashed().ToListAsync(); + + visible.Should().ContainSingle().Which.Name.Should().Be("alive"); + withTrashed.Should().HaveCount(2); + withTrashed.Should().Contain(x => x.Name == "trashed" && x.IsDeleted); + } + + [Fact] + public async Task OnlyTrashed_Returns_Only_Soft_Deleted_Rows() + { + await using var fixture = CreateFixture(); + fixture.Context.Items.AddRange( + new SoftDeleteItem { Name = "alive" }, + new SoftDeleteItem { Name = "trashed" } + ); + await fixture.Context.SaveChangesAsync(); + + var trashed = await fixture.Context.Items.FirstAsync(x => x.Name == "trashed"); + fixture.Context.Items.Remove(trashed); + await fixture.Context.SaveChangesAsync(); + + var only = await fixture.Context.Items.OnlyTrashed().ToListAsync(); + + only.Should().ContainSingle().Which.Name.Should().Be("trashed"); + only[0].IsDeleted.Should().BeTrue(); + } + + [Fact] + public async Task RestoreAsync_Clears_Soft_Delete_Fields() + { + await using var fixture = CreateFixture("user-1"); + var item = new SoftDeleteItem { Name = "doomed" }; + fixture.Context.Items.Add(item); + await fixture.Context.SaveChangesAsync(); + + fixture.Context.Items.Remove(item); + await fixture.Context.SaveChangesAsync(); + item.IsDeleted.Should().BeTrue(); + + var service = fixture.GetService(); + var affected = await service.RestoreAsync(item.Id); + + affected.Should().Be(1); + var restored = await fixture.Context.Items.FirstAsync(x => x.Id == item.Id); + restored.IsDeleted.Should().BeFalse(); + restored.DeletedAt.Should().BeNull(); + restored.DeletedBy.Should().BeNull(); + } + + [Fact] + public async Task RestoreAsync_Returns_Zero_When_Not_Trashed() + { + await using var fixture = CreateFixture(); + var item = new SoftDeleteItem { Name = "alive" }; + fixture.Context.Items.Add(item); + await fixture.Context.SaveChangesAsync(); + + var affected = await fixture.GetService().RestoreAsync(item.Id); + + affected.Should().Be(0); + } + + [Fact] + public async Task ForceDelete_Issues_Real_Delete_Bypassing_Soft_Delete() + { + await using var fixture = CreateFixture(); + var item = new SoftDeleteItem { Name = "purge-me" }; + fixture.Context.Items.Add(item); + await fixture.Context.SaveChangesAsync(); + + fixture.Context.ForceDelete(item); + await fixture.Context.SaveChangesAsync(); + + var any = await fixture.Context.Items.WithTrashed().AnyAsync(x => x.Id == item.Id); + any.Should().BeFalse(); + } + + [Fact] + public async Task ForceDeleteAsync_Removes_Trashed_Row() + { + await using var fixture = CreateFixture(); + var item = new SoftDeleteItem { Name = "two-step" }; + fixture.Context.Items.Add(item); + await fixture.Context.SaveChangesAsync(); + + fixture.Context.Items.Remove(item); + await fixture.Context.SaveChangesAsync(); + + var affected = await fixture.GetService().ForceDeleteAsync(item.Id); + + affected.Should().Be(1); + var any = await fixture.Context.Items.WithTrashed().AnyAsync(x => x.Id == item.Id); + any.Should().BeFalse(); + } + + [Fact] + public async Task ForceDeleteRangeAsync_Removes_Multiple_Rows() + { + await using var fixture = CreateFixture(); + var a = new SoftDeleteItem { Name = "a" }; + var b = new SoftDeleteItem { Name = "b" }; + var c = new SoftDeleteItem { Name = "c" }; + fixture.Context.Items.AddRange(a, b, c); + await fixture.Context.SaveChangesAsync(); + + var affected = await fixture.GetService().ForceDeleteRangeAsync([a.Id, b.Id]); + + affected.Should().Be(2); + var remaining = await fixture.Context.Items.WithTrashed().Select(x => x.Name).ToListAsync(); + remaining.Should().ContainSingle().Which.Should().Be("c"); + } + + [Fact] + public async Task PurgeOlderThanAsync_Deletes_Only_Old_Trashed_Rows() + { + await using var fixture = CreateFixture(); + var fresh = new SoftDeleteItem { Name = "fresh" }; + var stale = new SoftDeleteItem { Name = "stale" }; + fixture.Context.Items.AddRange(fresh, stale); + await fixture.Context.SaveChangesAsync(); + + fixture.Context.Items.Remove(stale); + await fixture.Context.SaveChangesAsync(); + stale.DeletedAt = DateTimeOffset.UtcNow - TimeSpan.FromDays(40); + await fixture.Context.SaveChangesAsync(); + + fixture.Context.Items.Remove(fresh); + await fixture.Context.SaveChangesAsync(); + + var purged = await fixture.GetService().PurgeOlderThanAsync(TimeSpan.FromDays(30)); + + purged.Should().Be(1); + var survivors = await fixture.Context.Items.WithTrashed().Select(x => x.Name).ToListAsync(); + survivors.Should().ContainSingle().Which.Should().Be("fresh"); + } + + [Fact] + public async Task PurgeOlderThanAsync_Skips_Non_Trashed_Rows() + { + await using var fixture = CreateFixture(); + var alive = new SoftDeleteItem { Name = "alive" }; + fixture.Context.Items.Add(alive); + await fixture.Context.SaveChangesAsync(); + + var purged = await fixture.GetService().PurgeOlderThanAsync(TimeSpan.Zero); + + purged.Should().Be(0); + (await fixture.Context.Items.AnyAsync(x => x.Id == alive.Id)).Should().BeTrue(); + } + + private static TestFixture CreateFixture(string? userId = 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") + ); + } + + services.AddSingleton( + new HttpContextAccessor { HttpContext = httpContext } + ); + services.AddScoped(); + services.AddModuleDbContext(config, "SoftDeleteTest"); + services.AddSoftDelete(); + + 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, SoftDeleteTestDbContext context) + : IAsyncDisposable + { + public SoftDeleteTestDbContext Context => context; + + public ISoftDeleteService GetService() => + provider.GetRequiredService>(); + + public async ValueTask DisposeAsync() + { + await context.DisposeAsync(); + await provider.DisposeAsync(); + } + } + + public sealed class SoftDeleteItem : ISoftDelete + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public bool IsDeleted { get; set; } + public DateTimeOffset? DeletedAt { get; set; } + public string? DeletedBy { get; set; } + } + + public sealed class SoftDeleteTestDbContext( + DbContextOptions options, + IOptions dbOptions + ) : DbContext(options) + { + public DbSet Items => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(e => + { + e.HasKey(x => x.Id); + e.Property(x => x.Id).ValueGeneratedOnAdd(); + }); + modelBuilder.ApplyModuleSchema("SoftDeleteTest", dbOptions.Value); + } + } +}