diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index e12dcfaf..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(dotnet build:*)", - "WebFetch(domain:github.com)", - "Bash(ls:*)", - "Bash(dotnet test:*)", - "Bash(dotnet restore:*)", - "Bash(\"bin/Debug/net10.0/RCommon.Entities.Tests.exe\")", - "Bash(dotnet:*)", - "Bash(\"c:\\\\Users\\\\jason.webb\\\\source\\\\repos\\\\RCommon\\\\Tests\\\\RCommon.Persistence.Tests\\\\bin\\\\Debug\\\\net10.0\\\\RCommon.Persistence.Tests.exe\")", - "Bash(\"RCommon.Mediator.Tests.exe\")", - "Bash(\"RCommon.MemoryCache.Tests.exe\")", - "Bash(\"c:/Users/jason.webb/source/repos/RCommon/Tests/RCommon.Mediator.Tests/bin/Debug/net10.0/RCommon.Mediator.Tests.exe\")", - "Bash(dir:*)", - "Bash(DOTNET_CLI_TESTINGPLATFORM_ENABLE=true dotnet test:*)", - "Bash(grep:*)", - "Bash(\"c:\\\\Users\\\\jason.webb\\\\source\\\\repos\\\\RCommon\\\\Tests\\\\RCommon.JsonNet.Tests\\\\bin\\\\Debug\\\\net10.0\\\\RCommon.JsonNet.Tests.exe\")", - "Bash(\"c:\\\\Users\\\\jason.webb\\\\source\\\\repos\\\\RCommon\\\\Tests\\\\RCommon.RedisCache.Tests\\\\bin\\\\Debug\\\\net10.0\\\\RCommon.RedisCache.Tests.exe\")", - "Bash(\"c:\\\\Users\\\\jason.webb\\\\source\\\\repos\\\\RCommon\\\\Tests\\\\RCommon.SystemTextJson.Tests\\\\bin\\\\Debug\\\\net10.0\\\\RCommon.SystemTextJson.Tests.exe\" --help)", - "Bash(echo:*)", - "Bash(test:*)", - "Bash(./RCommon.FluentValidation.Tests.exe:*)", - "WebFetch(domain:aka.ms)", - "WebFetch(domain:raw.githubusercontent.com)", - "Bash(./RCommon.Mediatr.Tests.exe:*)", - "Bash(\"Tests/RCommon.Mediatr.Tests/bin/Debug/net10.0/RCommon.Mediatr.Tests.exe\")", - "Bash(for dir in RCommon.*.Tests)", - "Bash(do echo \"=== $dir ===\")", - "Bash(done)", - "Bash(git add:*)", - "Bash(findstr:*)", - "Bash(Select-String -Pattern \"warning CS8\")", - "Bash(Select-String -Pattern \"Build succeeded|Error\\\\\\(s\\\\\\)|Warning\\\\\\(s\\\\\\)|error CS\")" - ] - } -} diff --git a/.gitignore b/.gitignore index 011ff5a6..e4e4b4f5 100644 --- a/.gitignore +++ b/.gitignore @@ -342,6 +342,3 @@ ASALocalRun/ # BeatPulse healthcheck temp database healthchecksdb /Src/RCommon.Persistence.EfCore/IEFCoreConfiguration.cs -/.claude -.claude/settings.local.json -.claude/settings.local.json diff --git a/Src/RCommon.Dapper/Crud/DapperRepository.cs b/Src/RCommon.Dapper/Crud/DapperRepository.cs index b7479cae..5246b8d7 100644 --- a/Src/RCommon.Dapper/Crud/DapperRepository.cs +++ b/Src/RCommon.Dapper/Crud/DapperRepository.cs @@ -84,9 +84,20 @@ public override async Task AddAsync(TEntity entity, CancellationToken token = de } - /// + /// + /// Deletes the entity. If implements , + /// a soft delete is performed automatically (sets IsDeleted = true and issues an UPDATE). + /// Otherwise a physical DELETE is executed. + /// public override async Task DeleteAsync(TEntity entity, CancellationToken token = default) { + if (SoftDeleteHelper.IsSoftDeletable()) + { + SoftDeleteHelper.MarkAsDeleted(entity); + await UpdateAsync(entity, token); + return; + } + await using (var db = DataStore.GetDbConnection()) { try @@ -115,9 +126,18 @@ public override async Task DeleteAsync(TEntity entity, CancellationToken token = } } - /// + /// + /// Deletes entities matching the expression. If implements + /// , a soft delete is performed automatically (marks each matching + /// entity as deleted and issues UPDATEs). Otherwise a physical DELETE is executed. + /// public async override Task DeleteManyAsync(Expression> expression, CancellationToken token = default) { + if (SoftDeleteHelper.IsSoftDeletable()) + { + return await DeleteManyAsync(expression, isSoftDelete: true, token); + } + await using (var db = DataStore.GetDbConnection()) { try @@ -145,12 +165,157 @@ public async override Task DeleteManyAsync(Expression> } } - /// + /// + /// Deletes entities matching the specification. If implements + /// , a soft delete is performed automatically. + /// public async override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) { return await DeleteManyAsync(specification.Predicate, token); } + /// + /// Deletes the entity using the explicitly specified delete mode. When + /// is true, the entity must implement ; its IsDeleted property + /// is set to true and an UPDATE is issued. When false, a physical DELETE is always + /// performed — even if the entity implements . + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public override async Task DeleteAsync(TEntity entity, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(); + } + + EventTracker.AddEntity(entity); + await db.DeleteAsync(entity, cancellationToken: token); + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.DeleteAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync(); + } + } + } + return; + } + + SoftDeleteHelper.EnsureSoftDeletable(); + SoftDeleteHelper.MarkAsDeleted(entity); + await UpdateAsync(entity, token); + } + + /// + /// Deletes entities matching the expression. When is true, + /// each matching entity must implement — its IsDeleted property is + /// set to true and an UPDATE is issued instead of a DELETE. + /// + /// + /// The soft-delete path selects matching entities, marks each as deleted, then updates them + /// one by one via Dommel's UpdateAsync. This is consistent with Dapper/Dommel's + /// per-entity operation model (there is no bulk update-by-expression in Dommel). + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public async override Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(); + } + + return await db.DeleteMultipleAsync(expression, cancellationToken: token); + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.DeleteManyAsync while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync(); + } + } + } + } + + SoftDeleteHelper.EnsureSoftDeletable(); + + await using (var db = DataStore.GetDbConnection()) + { + try + { + if (db.State == ConnectionState.Closed) + { + await db.OpenAsync(); + } + + var entities = (await db.SelectAsync(expression, cancellationToken: token)).ToList(); + int count = 0; + foreach (var entity in entities) + { + SoftDeleteHelper.MarkAsDeleted(entity); + await db.UpdateAsync(entity, cancellationToken: token); + count++; + } + return count; + } + catch (ApplicationException exception) + { + Logger.LogError(exception, "Error in {0}.DeleteManyAsync (soft delete) while executing on the DbConnection.", GetType().FullName); + throw; + } + finally + { + if (db.State == ConnectionState.Open) + { + await db.CloseAsync(); + } + } + } + } + + /// + /// Deletes entities matching the specification. When is true, + /// each matching entity must implement — its IsDeleted property is + /// set to true and an UPDATE is issued instead of a DELETE. + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public async override Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + { + return await DeleteManyAsync(specification.Predicate, isSoftDelete, token); + } /// @@ -202,7 +367,8 @@ public override async Task> FindAsync(Expression(expression); + var results = await db.SelectAsync(filteredExpression, cancellationToken: token); return results.ToList(); } catch (ApplicationException exception) @@ -233,6 +399,13 @@ public override async Task FindAsync(object primaryKey, CancellationTok } var result = await db.GetAsync(primaryKey, cancellationToken: token); + + // Post-fetch soft-delete check: if the entity was soft-deleted, treat it as not found + if (result != null && SoftDeleteHelper.IsSoftDeletable() && ((ISoftDelete)result).IsDeleted) + { + return default!; + } + return result!; } catch (ApplicationException exception) @@ -262,7 +435,8 @@ public override async Task GetCountAsync(ISpecification selectSpe await db.OpenAsync(); } - var results = await db.CountAsync(selectSpec.Predicate); + var filteredPredicate = SoftDeleteHelper.CombineWithNotDeletedFilter(selectSpec.Predicate); + var results = await db.CountAsync(filteredPredicate); return results; } catch (ApplicationException exception) @@ -292,7 +466,8 @@ public override async Task GetCountAsync(Expression> e await db.OpenAsync(); } - var results = await db.CountAsync(expression); + var filteredExpression = SoftDeleteHelper.CombineWithNotDeletedFilter(expression); + var results = await db.CountAsync(filteredExpression); return results; } catch (ApplicationException exception) @@ -350,7 +525,8 @@ public override async Task AnyAsync(Expression> expres await db.OpenAsync(); } - var results = await db.AnyAsync(expression); + var filteredExpression = SoftDeleteHelper.CombineWithNotDeletedFilter(expression); + var results = await db.AnyAsync(filteredExpression); return results; } catch (ApplicationException exception) diff --git a/Src/RCommon.EfCore/Crud/EFCoreRepository.cs b/Src/RCommon.EfCore/Crud/EFCoreRepository.cs index db6d9715..3091e98a 100644 --- a/Src/RCommon.EfCore/Crud/EFCoreRepository.cs +++ b/Src/RCommon.EfCore/Crud/EFCoreRepository.cs @@ -164,24 +164,120 @@ public override async Task AddAsync(TEntity entity, CancellationToken token = de } - /// + /// + /// Deletes the entity. If implements , + /// a soft delete is performed automatically (sets IsDeleted = true and issues an UPDATE). + /// Otherwise a physical DELETE is executed. + /// public async override Task DeleteAsync(TEntity entity, CancellationToken token = default) { + if (SoftDeleteHelper.IsSoftDeletable()) + { + SoftDeleteHelper.MarkAsDeleted(entity); + await UpdateAsync(entity, token); + return; + } + EventTracker.AddEntity(entity); ObjectSet.Remove(entity); await SaveAsync(); } - /// + /// + /// Deletes the entity using the explicitly specified delete mode. When + /// is true, the entity must implement ; its IsDeleted property + /// is set to true and an UPDATE is issued. When false, a physical DELETE is always + /// performed — even if the entity implements . + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public async override Task DeleteAsync(TEntity entity, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + EventTracker.AddEntity(entity); + ObjectSet.Remove(entity); + await SaveAsync(); + return; + } + + SoftDeleteHelper.EnsureSoftDeletable(); + SoftDeleteHelper.MarkAsDeleted(entity); + await UpdateAsync(entity, token); + } + + /// + /// Deletes entities matching the specification. If implements + /// , a soft delete is performed automatically. + /// public async override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) { return await this.DeleteManyAsync(specification.Predicate, token); } - /// + /// + /// Deletes entities matching the specification. When is true, + /// each matching entity must implement — its IsDeleted property is + /// set to true and an UPDATE is issued instead of a DELETE. + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public async override Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + { + return await this.DeleteManyAsync(specification.Predicate, isSoftDelete, token); + } + + /// + /// Deletes entities matching the expression. If implements + /// , a soft delete is performed automatically (marks each matching + /// entity as deleted and issues UPDATEs). Otherwise a physical DELETE is executed. + /// public async override Task DeleteManyAsync(Expression> expression, CancellationToken token = default) { - return await this.FindQuery(expression).ExecuteDeleteAsync(token); + if (SoftDeleteHelper.IsSoftDeletable()) + { + return await DeleteManyAsync(expression, isSoftDelete: true, token); + } + + return await RepositoryQuery.Where(expression).ExecuteDeleteAsync(token); + } + + /// + /// Deletes entities matching the expression. When is true, + /// each matching entity must implement — its IsDeleted property is + /// set to true and an UPDATE is issued instead of a DELETE. + /// + /// + /// The soft-delete path fetches matching entities into memory, marks each as deleted, then saves + /// in a single round-trip. This approach is used instead of ExecuteUpdateAsync with a cast + /// expression to ensure compatibility across all EF Core database providers. + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public async override Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection and soft-delete filter — force a physical delete + return await RepositoryQuery.Where(expression).ExecuteDeleteAsync(token); + } + + SoftDeleteHelper.EnsureSoftDeletable(); + + var entities = await this.FindQuery(expression).ToListAsync(token); + foreach (var entity in entities) + { + SoftDeleteHelper.MarkAsDeleted(entity); + ObjectSet.Update(entity); + } + return await SaveAsync(token); } /// @@ -204,8 +300,8 @@ private IQueryable FindCore(Expression> expression) IQueryable queryable; try { - Guard.Against(RepositoryQuery == null, "RepositoryQuery is null"); - queryable = RepositoryQuery!.Where(expression); + Guard.Against(FilteredRepositoryQuery == null, "RepositoryQuery is null"); + queryable = FilteredRepositoryQuery.Where(expression); } catch (ApplicationException exception) { @@ -242,7 +338,15 @@ public override IQueryable FindQuery(Expression> ex /// public override async Task FindAsync(object primaryKey, CancellationToken token = default) { - return (await ObjectSet.FindAsync(new object[] { primaryKey }, token))!; + var entity = await ObjectSet.FindAsync(new object[] { primaryKey }, token); + + // Post-fetch soft-delete check: if the entity was soft-deleted, treat it as not found + if (entity != null && SoftDeleteHelper.IsSoftDeletable() && ((ISoftDelete)entity).IsDeleted) + { + return default!; + } + + return entity!; } /// diff --git a/Src/RCommon.Entities/ISoftDelete.cs b/Src/RCommon.Entities/ISoftDelete.cs new file mode 100644 index 00000000..35b0332b --- /dev/null +++ b/Src/RCommon.Entities/ISoftDelete.cs @@ -0,0 +1,45 @@ +namespace RCommon.Entities +{ + /// + /// Marks an entity as capable of being soft-deleted. When soft delete is requested, + /// the repository will set to true instead of + /// physically removing the record from the data store. + /// + /// + /// + /// This is an opt-in capability interface. Entities that do not implement this interface + /// will continue to be physically deleted. If a caller requests soft delete on an entity + /// that does not implement this interface, an + /// is thrown at runtime. + /// + /// + /// Usage: To enable soft delete for an entity, implement this interface and + /// ensure the underlying data store has a corresponding IsDeleted column (boolean). + /// Then call DeleteAsync(entity, isSoftDelete: true) on the repository. The repository + /// will set to true and perform an UPDATE instead of a DELETE. + /// + /// + /// + /// public class Customer : BusinessEntity<int>, ISoftDelete + /// { + /// public string Name { get; set; } + /// public bool IsDeleted { get; set; } + /// } + /// + /// // Soft delete: sets IsDeleted = true, performs UPDATE + /// await repository.DeleteAsync(customer, isSoftDelete: true); + /// + /// // Physical delete: removes the record entirely + /// await repository.DeleteAsync(customer, isSoftDelete: false); + /// + /// + /// + public interface ISoftDelete + { + /// + /// Gets or sets a value indicating whether this entity has been soft-deleted. + /// When true, the entity is considered deleted but remains in the data store. + /// + bool IsDeleted { get; set; } + } +} diff --git a/Src/RCommon.Linq2Db/Crud/Linq2DbRepository.cs b/Src/RCommon.Linq2Db/Crud/Linq2DbRepository.cs index 6faa1618..9a03ed43 100644 --- a/Src/RCommon.Linq2Db/Crud/Linq2DbRepository.cs +++ b/Src/RCommon.Linq2Db/Crud/Linq2DbRepository.cs @@ -153,8 +153,8 @@ private IQueryable FindCore(Expression> expression) IQueryable queryable; try { - Guard.Against(RepositoryQuery == null, "RepositoryQuery is null"); - queryable = RepositoryQuery!.Where(expression); + Guard.Against(FilteredRepositoryQuery == null, "RepositoryQuery is null"); + queryable = FilteredRepositoryQuery.Where(expression); } catch (ApplicationException exception) { @@ -175,7 +175,7 @@ public async override Task AddAsync(TEntity entity, CancellationToken token = de /// public async override Task AnyAsync(Expression> expression, CancellationToken token = default) { - return await RepositoryQuery.AnyAsync(expression, token: token); + return await FilteredRepositoryQuery.AnyAsync(expression, token: token); } /// @@ -184,17 +184,96 @@ public async override Task AnyAsync(ISpecification specification, return await AnyAsync(specification.Predicate, token: token); } - /// + /// + /// Deletes the entity. If implements , + /// a soft delete is performed automatically (sets IsDeleted = true and issues an UPDATE). + /// Otherwise a physical DELETE is executed. + /// public async override Task DeleteAsync(TEntity entity, CancellationToken token = default) { + if (SoftDeleteHelper.IsSoftDeletable()) + { + SoftDeleteHelper.MarkAsDeleted(entity); + await UpdateAsync(entity, token); + return; + } + EventTracker.AddEntity(entity); await DataConnection.DeleteAsync(entity); } - /// + /// + /// Deletes the entity using the explicitly specified delete mode. When + /// is true, the entity must implement ; its IsDeleted property + /// is set to true and an UPDATE is issued. When false, a physical DELETE is always + /// performed — even if the entity implements . + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public async override Task DeleteAsync(TEntity entity, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + EventTracker.AddEntity(entity); + await DataConnection.DeleteAsync(entity); + return; + } + + SoftDeleteHelper.EnsureSoftDeletable(); + SoftDeleteHelper.MarkAsDeleted(entity); + await UpdateAsync(entity, token); + } + + /// + /// Deletes entities matching the expression. If implements + /// , a soft delete is performed automatically (marks each matching + /// entity as deleted and issues UPDATEs). Otherwise a physical DELETE is executed. + /// public async override Task DeleteManyAsync(Expression> expression, CancellationToken token = default) { - return await FindQuery(expression).DeleteAsync(token); + if (SoftDeleteHelper.IsSoftDeletable()) + { + return await DeleteManyAsync(expression, isSoftDelete: true, token); + } + + return await RepositoryQuery.Where(expression).DeleteAsync(token); + } + + /// + /// Deletes entities matching the expression. When is true, + /// each matching entity must implement — its IsDeleted property is + /// set to true and an UPDATE is issued instead of a DELETE. + /// + /// + /// The soft-delete path fetches matching entities into memory, marks each as deleted, then updates + /// them one by one via Linq2Db's UpdateAsync. + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public async override Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection and soft-delete filter — force a physical delete + return await RepositoryQuery.Where(expression).DeleteAsync(token); + } + + SoftDeleteHelper.EnsureSoftDeletable(); + + var entities = await FindQuery(expression).ToListAsync(token); + int count = 0; + foreach (var entity in entities) + { + SoftDeleteHelper.MarkAsDeleted(entity); + await DataConnection.UpdateAsync(entity, token: token); + count++; + } + return count; } /// @@ -203,6 +282,20 @@ public async override Task DeleteManyAsync(ISpecification specific return await DeleteManyAsync(specification.Predicate, token); } + /// + /// Deletes entities matching the specification. When is true, + /// each matching entity must implement — its IsDeleted property is + /// set to true and an UPDATE is issued instead of a DELETE. + /// + /// + /// Thrown when is true but + /// does not implement . + /// + public async override Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + { + return await DeleteManyAsync(specification.Predicate, isSoftDelete, token); + } + /// public override IQueryable FindQuery(ISpecification specification) { @@ -315,7 +408,7 @@ public override IQueryable FindQuery(Expression> ex /// public async override Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) { - return (await RepositoryQuery.SingleOrDefaultAsync(expression, token))!; + return (await FilteredRepositoryQuery.SingleOrDefaultAsync(expression, token))!; } /// @@ -333,7 +426,7 @@ public async override Task GetCountAsync(ISpecification selectSpe /// public async override Task GetCountAsync(Expression> expression, CancellationToken token = default) { - return await RepositoryQuery.CountAsync(expression, token); + return await FilteredRepositoryQuery.CountAsync(expression, token); } /// diff --git a/Src/RCommon.Persistence.Caching/Crud/CachingGraphRepository.cs b/Src/RCommon.Persistence.Caching/Crud/CachingGraphRepository.cs index 942aad21..5374408a 100644 --- a/Src/RCommon.Persistence.Caching/Crud/CachingGraphRepository.cs +++ b/Src/RCommon.Persistence.Caching/Crud/CachingGraphRepository.cs @@ -80,6 +80,12 @@ public async Task DeleteAsync(TEntity entity, CancellationToken token = default) await _repository.DeleteAsync(entity, token); } + /// + public async Task DeleteAsync(TEntity entity, bool isSoftDelete, CancellationToken token = default) + { + await _repository.DeleteAsync(entity, isSoftDelete, token); + } + /// public async Task FindAsync(object primaryKey, CancellationToken token = default) { @@ -201,12 +207,24 @@ public async Task DeleteManyAsync(ISpecification specification, Ca return await _repository.DeleteManyAsync(specification, token); } + /// + public async Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + { + return await _repository.DeleteManyAsync(specification, isSoftDelete, token); + } + /// public async Task DeleteManyAsync(Expression> expression, CancellationToken token = default) { return await _repository.DeleteManyAsync(expression, token); } + /// + public async Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + { + return await _repository.DeleteManyAsync(expression, isSoftDelete, token); + } + // Cached items — these overloads check the cache first and fall through to the inner repository on a miss. /// diff --git a/Src/RCommon.Persistence.Caching/Crud/CachingLinqRepository.cs b/Src/RCommon.Persistence.Caching/Crud/CachingLinqRepository.cs index 85298c32..b7eddea2 100644 --- a/Src/RCommon.Persistence.Caching/Crud/CachingLinqRepository.cs +++ b/Src/RCommon.Persistence.Caching/Crud/CachingLinqRepository.cs @@ -78,6 +78,12 @@ public async Task DeleteAsync(TEntity entity, CancellationToken token = default) await _repository.DeleteAsync(entity, token); } + /// + public async Task DeleteAsync(TEntity entity, bool isSoftDelete, CancellationToken token = default) + { + await _repository.DeleteAsync(entity, isSoftDelete, token); + } + /// public async Task FindAsync(object primaryKey, CancellationToken token = default) { @@ -199,12 +205,24 @@ public async Task DeleteManyAsync(ISpecification specification, Ca return await _repository.DeleteManyAsync(specification, token); } + /// + public async Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + { + return await _repository.DeleteManyAsync(specification, isSoftDelete, token); + } + /// public async Task DeleteManyAsync(Expression> expression, CancellationToken token = default) { return await _repository.DeleteManyAsync(expression, token); } + /// + public async Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + { + return await _repository.DeleteManyAsync(expression, isSoftDelete, token); + } + // Cached items — these overloads check the cache first and fall through to the inner repository on a miss. /// diff --git a/Src/RCommon.Persistence.Caching/Crud/CachingSqlMapperRepository.cs b/Src/RCommon.Persistence.Caching/Crud/CachingSqlMapperRepository.cs index 7a9c5ff2..8a48da17 100644 --- a/Src/RCommon.Persistence.Caching/Crud/CachingSqlMapperRepository.cs +++ b/Src/RCommon.Persistence.Caching/Crud/CachingSqlMapperRepository.cs @@ -69,6 +69,12 @@ public async Task DeleteAsync(TEntity entity, CancellationToken token = default) await _repository.DeleteAsync(entity, token); } + /// + public async Task DeleteAsync(TEntity entity, bool isSoftDelete, CancellationToken token = default) + { + await _repository.DeleteAsync(entity, isSoftDelete, token); + } + /// public async Task> FindAsync(ISpecification specification, CancellationToken token = default) { @@ -123,12 +129,24 @@ public async Task DeleteManyAsync(ISpecification specification, Ca return await _repository.DeleteManyAsync(specification, token); } + /// + public async Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + { + return await _repository.DeleteManyAsync(specification, isSoftDelete, token); + } + /// public async Task DeleteManyAsync(Expression> expression, CancellationToken token = default) { return await _repository.DeleteManyAsync(expression, token); } + /// + public async Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + { + return await _repository.DeleteManyAsync(expression, isSoftDelete, token); + } + // Cached Items — these overloads check the cache first and fall through to the inner repository on a miss. /// diff --git a/Src/RCommon.Persistence/Crud/IWriteOnlyRepository.cs b/Src/RCommon.Persistence/Crud/IWriteOnlyRepository.cs index 371587fd..2a5c83e1 100644 --- a/Src/RCommon.Persistence/Crud/IWriteOnlyRepository.cs +++ b/Src/RCommon.Persistence/Crud/IWriteOnlyRepository.cs @@ -1,4 +1,5 @@ -using RCommon.Persistence; +using RCommon.Entities; +using RCommon.Persistence; using System; using System.Collections.Generic; using System.Linq; @@ -38,17 +39,21 @@ public interface IWriteOnlyRepository : INamedDataSource Task AddRangeAsync(IEnumerable entities, CancellationToken token = default); /// - /// Marks the changes of an existing entity to be deleted from the store. + /// Deletes the specified entity. If implements , + /// a soft delete is performed automatically (sets IsDeleted = true and issues an UPDATE). + /// Otherwise a physical DELETE is executed. Use + /// to explicitly control the delete mode. /// - /// An instance of that should be - /// updated in the database. - /// /// Cancellation Token - /// Implementors of this method must handle the Delete scenario. + /// An instance of to delete. + /// Cancellation Token /// Task Task DeleteAsync(TEntity entity, CancellationToken token = default); /// - /// Deletes entities matching the criteria of the specification + /// Deletes entities matching the specification. If implements + /// , a soft delete is performed automatically. + /// Use + /// to explicitly control the delete mode. /// /// Query specification /// Cancellation Token @@ -56,7 +61,10 @@ public interface IWriteOnlyRepository : INamedDataSource Task DeleteManyAsync(ISpecification specification, CancellationToken token = default); /// - /// Deletes entities matching the criteria of the expression + /// Deletes entities matching the expression. If implements + /// , a soft delete is performed automatically. + /// Use + /// to explicitly control the delete mode. /// /// Query expression /// Cancellation Token @@ -71,5 +79,48 @@ public interface IWriteOnlyRepository : INamedDataSource /// Task Task UpdateAsync(TEntity entity, CancellationToken token = default); + /// + /// Deletes the entity using the explicitly specified delete mode, bypassing auto-detection. + /// When is true, the entity's + /// property is set to true and an UPDATE is issued. When false, a physical DELETE is + /// always performed — even if the entity implements . + /// + /// The entity to delete. + /// If true, performs a soft delete; if false, forces a physical delete. + /// Cancellation Token + /// Task + /// + /// Thrown when is true but the entity does not implement . + /// + Task DeleteAsync(TEntity entity, bool isSoftDelete, CancellationToken token = default); + + /// + /// Deletes entities matching the specification using the explicitly specified delete mode, + /// bypassing auto-detection. When is false, a physical + /// DELETE is always performed — even if the entity implements . + /// + /// Query specification + /// If true, performs a soft delete; if false, forces a physical delete. + /// Cancellation Token + /// Count of entities affected + /// + /// Thrown when is true but does not implement . + /// + Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default); + + /// + /// Deletes entities matching the expression using the explicitly specified delete mode, + /// bypassing auto-detection. When is false, a physical + /// DELETE is always performed — even if the entity implements . + /// + /// Query expression + /// If true, performs a soft delete; if false, forces a physical delete. + /// Cancellation Token + /// Count of entities affected + /// + /// Thrown when is true but does not implement . + /// + Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default); + } } diff --git a/Src/RCommon.Persistence/Crud/LinqRepositoryBase.cs b/Src/RCommon.Persistence/Crud/LinqRepositoryBase.cs index a4c80c5d..e42226f6 100644 --- a/Src/RCommon.Persistence/Crud/LinqRepositoryBase.cs +++ b/Src/RCommon.Persistence/Crud/LinqRepositoryBase.cs @@ -71,6 +71,26 @@ public LinqRepositoryBase(IDataStoreFactory dataStoreFactory, /// protected abstract IQueryable RepositoryQuery { get; } + /// + /// Gets the with an automatic soft-delete filter applied + /// when implements . + /// For non-soft-deletable entities, returns the raw unchanged. + /// + /// + /// All read operations should use this property instead of directly + /// to ensure soft-deleted entities are excluded. Write/delete operations that need unfiltered + /// access should continue to use . + /// + protected IQueryable FilteredRepositoryQuery + { + get + { + if (SoftDeleteHelper.IsSoftDeletable()) + return RepositoryQuery.Where(SoftDeleteHelper.GetNotDeletedFilter()); + return RepositoryQuery; + } + } + /// /// Returns an enumerator that iterates through the collection. /// @@ -80,7 +100,7 @@ public LinqRepositoryBase(IDataStoreFactory dataStoreFactory, /// 1 public IEnumerator GetEnumerator() { - return RepositoryQuery.GetEnumerator(); + return FilteredRepositoryQuery.GetEnumerator(); } /// @@ -92,7 +112,7 @@ public IEnumerator GetEnumerator() /// 2 IEnumerator IEnumerable.GetEnumerator() { - return RepositoryQuery.GetEnumerator(); + return FilteredRepositoryQuery.GetEnumerator(); } /// @@ -103,7 +123,7 @@ IEnumerator IEnumerable.GetEnumerator() /// public Expression Expression { - get { return RepositoryQuery.Expression; } + get { return FilteredRepositoryQuery.Expression; } } /// @@ -114,7 +134,7 @@ public Expression Expression /// public Type ElementType { - get { return RepositoryQuery.ElementType; } + get { return FilteredRepositoryQuery.ElementType; } } /// @@ -125,7 +145,7 @@ public Type ElementType /// public IQueryProvider Provider { - get { return RepositoryQuery.Provider; } + get { return FilteredRepositoryQuery.Provider; } } @@ -139,7 +159,7 @@ public IQueryProvider Provider /// of the query. public IEnumerable Query(ISpecification specification) { - return RepositoryQuery.Where(specification.Predicate).AsQueryable(); + return FilteredRepositoryQuery.Where(specification.Predicate).AsQueryable(); } /// @@ -161,12 +181,21 @@ public abstract IQueryable FindQuery(Expression> ex /// public abstract Task DeleteAsync(TEntity entity, CancellationToken token = default); + /// + public abstract Task DeleteAsync(TEntity entity, bool isSoftDelete, CancellationToken token = default); + /// public abstract Task DeleteManyAsync(Expression> expression, CancellationToken token = default); + /// + public abstract Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default); + /// public abstract Task DeleteManyAsync(ISpecification specification, CancellationToken token = default); + /// + public abstract Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default); + /// public abstract Task UpdateAsync(TEntity entity, CancellationToken token = default); diff --git a/Src/RCommon.Persistence/Crud/SoftDeleteHelper.cs b/Src/RCommon.Persistence/Crud/SoftDeleteHelper.cs new file mode 100644 index 00000000..be707d49 --- /dev/null +++ b/Src/RCommon.Persistence/Crud/SoftDeleteHelper.cs @@ -0,0 +1,93 @@ +using RCommon.Entities; +using RCommon.Linq; +using System; +using System.Linq.Expressions; + +namespace RCommon.Persistence.Crud +{ + /// + /// Provides shared validation and operations for soft-delete functionality across repository implementations. + /// + /// + /// Soft delete requires that the entity type implements . + /// If the entity does not implement this interface and soft delete is requested, + /// an is thrown. This ensures that callers + /// cannot accidentally soft-delete an entity that does not have an IsDeleted column. + /// + public static class SoftDeleteHelper + { + /// + /// Returns true if the entity type implements . + /// Used by repository delete methods to automatically choose soft delete when the entity supports it. + /// + /// The entity type to check. + /// true if implements ; otherwise false. + public static bool IsSoftDeletable() + { + return typeof(ISoftDelete).IsAssignableFrom(typeof(TEntity)); + } + + /// + /// Validates that the entity type implements . + /// Call this at the start of any soft-delete code path to fail fast with a clear error message. + /// + /// The entity type to validate. + /// + /// Thrown when does not implement . + /// + public static void EnsureSoftDeletable() + { + if (!typeof(ISoftDelete).IsAssignableFrom(typeof(TEntity))) + { + throw new InvalidOperationException( + $"Entity type '{typeof(TEntity).Name}' does not implement ISoftDelete. " + + $"Soft delete is only supported for entities that implement the ISoftDelete interface."); + } + } + + /// + /// Marks the entity as soft-deleted by setting to true. + /// The caller must have already validated that the entity implements + /// by calling beforehand. + /// + /// The entity to mark as deleted. Must implement . + public static void MarkAsDeleted(object entity) + { + ((ISoftDelete)entity).IsDeleted = true; + } + + /// + /// Returns an expression that filters out soft-deleted entities: e => !e.IsDeleted. + /// Only call this when returns true. + /// + /// The entity type, which must implement . + /// An expression representing e => !e.IsDeleted. + public static Expression> GetNotDeletedFilter() + { + var param = Expression.Parameter(typeof(TEntity), "e"); + var property = Expression.Property(param, nameof(ISoftDelete.IsDeleted)); + var notDeleted = Expression.Not(property); + return Expression.Lambda>(notDeleted, param); + } + + /// + /// Combines the given expression with a !IsDeleted filter using a logical AND. + /// If does not implement , + /// the original expression is returned unchanged. + /// + /// The entity type to filter. + /// The user-supplied filter expression. + /// + /// The original expression AND-combined with !IsDeleted when the entity + /// implements ; otherwise the original expression unchanged. + /// + public static Expression> CombineWithNotDeletedFilter( + Expression> expression) + { + if (!IsSoftDeletable()) + return expression; + + return expression.And(GetNotDeletedFilter()); + } + } +} diff --git a/Src/RCommon.Persistence/Crud/SqlRepositoryBase.cs b/Src/RCommon.Persistence/Crud/SqlRepositoryBase.cs index fe79638d..c8254ecc 100644 --- a/Src/RCommon.Persistence/Crud/SqlRepositoryBase.cs +++ b/Src/RCommon.Persistence/Crud/SqlRepositoryBase.cs @@ -79,12 +79,21 @@ public SqlRepositoryBase(IDataStoreFactory dataStoreFactory, /// public abstract Task DeleteAsync(TEntity entity, CancellationToken token = default); + /// + public abstract Task DeleteAsync(TEntity entity, bool isSoftDelete, CancellationToken token = default); + /// public abstract Task DeleteManyAsync(Expression> expression, CancellationToken token = default); + /// + public abstract Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default); + /// public abstract Task DeleteManyAsync(ISpecification specification, CancellationToken token = default); + /// + public abstract Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default); + /// public abstract Task UpdateAsync(TEntity entity, CancellationToken token = default); diff --git a/Tests/RCommon.Dapper.Tests/DapperRepositoryTests.cs b/Tests/RCommon.Dapper.Tests/DapperRepositoryTests.cs index faf00edd..84d9cd09 100644 --- a/Tests/RCommon.Dapper.Tests/DapperRepositoryTests.cs +++ b/Tests/RCommon.Dapper.Tests/DapperRepositoryTests.cs @@ -448,11 +448,11 @@ public void Repository_HasDeleteAsyncMethod() var repositoryType = typeof(DapperRepository); // Act - var method = repositoryType.GetMethod("DeleteAsync"); + var methods = repositoryType.GetMethods().Where(m => m.Name == "DeleteAsync").ToArray(); // Assert - method.Should().NotBeNull(); - method!.IsPublic.Should().BeTrue(); + methods.Should().NotBeEmpty(); + methods.Should().OnlyContain(m => m.IsPublic); } [Fact] diff --git a/Tests/RCommon.Persistence.Caching.RedisCache.Tests/IPersistenceBuilderExtensionsTests.cs b/Tests/RCommon.Persistence.Caching.RedisCache.Tests/IPersistenceBuilderExtensionsTests.cs index ca41aae3..9ed397c2 100644 --- a/Tests/RCommon.Persistence.Caching.RedisCache.Tests/IPersistenceBuilderExtensionsTests.cs +++ b/Tests/RCommon.Persistence.Caching.RedisCache.Tests/IPersistenceBuilderExtensionsTests.cs @@ -357,8 +357,11 @@ private class CustomCachingLinqRepository : ICachingLinqRepository AnyAsync(System.Linq.Expressions.Expression> expression, CancellationToken token = default) => throw new NotImplementedException(); public Task AnyAsync(ISpecification specification, CancellationToken token = default) => throw new NotImplementedException(); public Task DeleteAsync(TEntity entity, CancellationToken token = default) => throw new NotImplementedException(); + public Task DeleteAsync(TEntity entity, bool isSoftDelete, CancellationToken token = default) => throw new NotImplementedException(); public Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) => throw new NotImplementedException(); + public Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) => throw new NotImplementedException(); public Task DeleteManyAsync(System.Linq.Expressions.Expression> expression, CancellationToken token = default) => throw new NotImplementedException(); + public Task DeleteManyAsync(System.Linq.Expressions.Expression> expression, bool isSoftDelete, CancellationToken token = default) => throw new NotImplementedException(); public Task> FindAsync(System.Linq.Expressions.Expression> expression, System.Linq.Expressions.Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0, CancellationToken token = default) => throw new NotImplementedException(); public Task> FindAsync(IPagedSpecification specification, CancellationToken token = default) => throw new NotImplementedException(); public Task> FindAsync(ISpecification specification, CancellationToken token = default) => throw new NotImplementedException(); diff --git a/Tests/RCommon.Persistence.Caching.Tests/CachingSoftDeleteTests.cs b/Tests/RCommon.Persistence.Caching.Tests/CachingSoftDeleteTests.cs new file mode 100644 index 00000000..035f6177 --- /dev/null +++ b/Tests/RCommon.Persistence.Caching.Tests/CachingSoftDeleteTests.cs @@ -0,0 +1,193 @@ +using FluentAssertions; +using Moq; +using RCommon.Caching; +using RCommon.Entities; +using RCommon.Persistence.Caching.Crud; +using RCommon.Persistence.Crud; +using System.Linq.Expressions; +using Xunit; + +namespace RCommon.Persistence.Caching.Tests; + +/// +/// Verifies that caching decorator repositories correctly delegate soft-delete +/// calls to the inner repository without interfering with cache behavior. +/// +public class CachingSoftDeleteTests +{ + private readonly Mock> _mockGraphRepository; + private readonly Mock> _mockSqlRepository; + private readonly Mock> _mockCacheFactory; + private readonly Mock _mockCacheService; + + public CachingSoftDeleteTests() + { + _mockGraphRepository = new Mock>(); + _mockSqlRepository = new Mock>(); + _mockCacheFactory = new Mock>(); + _mockCacheService = new Mock(); + + _mockCacheFactory.Setup(f => f.Create(PersistenceCachingStrategy.Default)).Returns(_mockCacheService.Object); + } + + // --- CachingGraphRepository soft delete delegation --- + + [Fact] + public async Task CachingGraphRepository_DeleteAsync_WithSoftDelete_DelegatesToInnerRepository() + { + // Arrange + var cachingRepo = new CachingGraphRepository(_mockGraphRepository.Object, _mockCacheFactory.Object); + var entity = new TestCachingEntity(); + + // Act + await cachingRepo.DeleteAsync(entity, isSoftDelete: true); + + // Assert + _mockGraphRepository.Verify(r => r.DeleteAsync(entity, true, default), Times.Once); + } + + [Fact] + public async Task CachingGraphRepository_DeleteAsync_WithHardDelete_DelegatesToInnerRepository() + { + // Arrange + var cachingRepo = new CachingGraphRepository(_mockGraphRepository.Object, _mockCacheFactory.Object); + var entity = new TestCachingEntity(); + + // Act + await cachingRepo.DeleteAsync(entity, isSoftDelete: false); + + // Assert + _mockGraphRepository.Verify(r => r.DeleteAsync(entity, false, default), Times.Once); + } + + [Fact] + public async Task CachingGraphRepository_DeleteManyAsync_Spec_WithSoftDelete_DelegatesToInnerRepository() + { + // Arrange + var cachingRepo = new CachingGraphRepository(_mockGraphRepository.Object, _mockCacheFactory.Object); + var mockSpec = new Mock>(); + + // Act + await cachingRepo.DeleteManyAsync(mockSpec.Object, isSoftDelete: true); + + // Assert + _mockGraphRepository.Verify(r => r.DeleteManyAsync(mockSpec.Object, true, default), Times.Once); + } + + [Fact] + public async Task CachingGraphRepository_DeleteManyAsync_Expression_WithSoftDelete_DelegatesToInnerRepository() + { + // Arrange + var cachingRepo = new CachingGraphRepository(_mockGraphRepository.Object, _mockCacheFactory.Object); + Expression> expression = e => e.Name == "Test"; + + // Act + await cachingRepo.DeleteManyAsync(expression, isSoftDelete: true); + + // Assert + _mockGraphRepository.Verify(r => r.DeleteManyAsync(It.IsAny>>(), true, default), Times.Once); + } + + // --- CachingLinqRepository soft delete delegation --- + + [Fact] + public async Task CachingLinqRepository_DeleteAsync_WithSoftDelete_DelegatesToInnerRepository() + { + // Arrange + var cachingRepo = new CachingLinqRepository(_mockGraphRepository.Object, _mockCacheFactory.Object); + var entity = new TestCachingEntity(); + + // Act + await cachingRepo.DeleteAsync(entity, isSoftDelete: true); + + // Assert + _mockGraphRepository.Verify(r => r.DeleteAsync(entity, true, default), Times.Once); + } + + [Fact] + public async Task CachingLinqRepository_DeleteManyAsync_Spec_WithSoftDelete_DelegatesToInnerRepository() + { + // Arrange + var cachingRepo = new CachingLinqRepository(_mockGraphRepository.Object, _mockCacheFactory.Object); + var mockSpec = new Mock>(); + + // Act + await cachingRepo.DeleteManyAsync(mockSpec.Object, isSoftDelete: true); + + // Assert + _mockGraphRepository.Verify(r => r.DeleteManyAsync(mockSpec.Object, true, default), Times.Once); + } + + [Fact] + public async Task CachingLinqRepository_DeleteManyAsync_Expression_WithSoftDelete_DelegatesToInnerRepository() + { + // Arrange + var cachingRepo = new CachingLinqRepository(_mockGraphRepository.Object, _mockCacheFactory.Object); + Expression> expression = e => e.Name == "Test"; + + // Act + await cachingRepo.DeleteManyAsync(expression, isSoftDelete: true); + + // Assert + _mockGraphRepository.Verify(r => r.DeleteManyAsync(It.IsAny>>(), true, default), Times.Once); + } + + // --- CachingSqlMapperRepository soft delete delegation --- + + [Fact] + public async Task CachingSqlMapperRepository_DeleteAsync_WithSoftDelete_DelegatesToInnerRepository() + { + // Arrange + var cachingRepo = new CachingSqlMapperRepository(_mockSqlRepository.Object, _mockCacheFactory.Object); + var entity = new TestCachingEntity(); + + // Act + await cachingRepo.DeleteAsync(entity, isSoftDelete: true); + + // Assert + _mockSqlRepository.Verify(r => r.DeleteAsync(entity, true, default), Times.Once); + } + + [Fact] + public async Task CachingSqlMapperRepository_DeleteManyAsync_Spec_WithSoftDelete_DelegatesToInnerRepository() + { + // Arrange + var cachingRepo = new CachingSqlMapperRepository(_mockSqlRepository.Object, _mockCacheFactory.Object); + var mockSpec = new Mock>(); + + // Act + await cachingRepo.DeleteManyAsync(mockSpec.Object, isSoftDelete: true); + + // Assert + _mockSqlRepository.Verify(r => r.DeleteManyAsync(mockSpec.Object, true, default), Times.Once); + } + + [Fact] + public async Task CachingSqlMapperRepository_DeleteManyAsync_Expression_WithSoftDelete_DelegatesToInnerRepository() + { + // Arrange + var cachingRepo = new CachingSqlMapperRepository(_mockSqlRepository.Object, _mockCacheFactory.Object); + Expression> expression = e => e.Name == "Test"; + + // Act + await cachingRepo.DeleteManyAsync(expression, isSoftDelete: true); + + // Assert + _mockSqlRepository.Verify(r => r.DeleteManyAsync(It.IsAny>>(), true, default), Times.Once); + } +} + +/// +/// Test entity for caching soft-delete tests. Implements ISoftDelete so both +/// soft and hard delete paths can be tested. +/// +public class TestCachingEntity : BusinessEntity, ISoftDelete +{ + public string? Name { get; set; } + public bool IsDeleted { get; set; } + + public TestCachingEntity() : base() + { + Id = Guid.NewGuid(); + } +} diff --git a/Tests/RCommon.Persistence.Tests/GraphRepositoryBaseTests.cs b/Tests/RCommon.Persistence.Tests/GraphRepositoryBaseTests.cs index 52167d6a..b74f0770 100644 --- a/Tests/RCommon.Persistence.Tests/GraphRepositoryBaseTests.cs +++ b/Tests/RCommon.Persistence.Tests/GraphRepositoryBaseTests.cs @@ -285,12 +285,21 @@ public override Task DeleteAsync(TestGraphEntity entity, CancellationToken token return Task.CompletedTask; } + public override Task DeleteAsync(TestGraphEntity entity, bool isSoftDelete, CancellationToken token = default) + => throw new NotImplementedException(); + public override Task DeleteManyAsync(Expression> expression, CancellationToken token = default) => Task.FromResult(0); + public override Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + => throw new NotImplementedException(); + public override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) => Task.FromResult(0); + public override Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + => throw new NotImplementedException(); + public override Task UpdateAsync(TestGraphEntity entity, CancellationToken token = default) => Task.CompletedTask; diff --git a/Tests/RCommon.Persistence.Tests/LinqRepositoryBaseTests.cs b/Tests/RCommon.Persistence.Tests/LinqRepositoryBaseTests.cs index a1be6c77..e3a418a3 100644 --- a/Tests/RCommon.Persistence.Tests/LinqRepositoryBaseTests.cs +++ b/Tests/RCommon.Persistence.Tests/LinqRepositoryBaseTests.cs @@ -336,15 +336,60 @@ public override Task AddRangeAsync(IEnumerable entities, Cancellatio public override Task DeleteAsync(TestEntity entity, CancellationToken token = default) { + // Auto-detect: TestEntity does not implement ISoftDelete, so physical delete + if (SoftDeleteHelper.IsSoftDeletable()) + { + SoftDeleteHelper.MarkAsDeleted(entity); + return UpdateAsync(entity, token); + } + _entities.Remove(entity); return Task.CompletedTask; } + public override Task DeleteAsync(TestEntity entity, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + _entities.Remove(entity); + return Task.CompletedTask; + } + + SoftDeleteHelper.EnsureSoftDeletable(); + SoftDeleteHelper.MarkAsDeleted(entity); + return UpdateAsync(entity, token); + } + public override Task DeleteManyAsync(Expression> expression, CancellationToken token = default) - => Task.FromResult(0); + { + if (SoftDeleteHelper.IsSoftDeletable()) + { + return DeleteManyAsync(expression, isSoftDelete: true, token); + } + + var matches = _entities.Where(expression.Compile()).ToList(); + foreach (var e in matches) _entities.Remove(e); + return Task.FromResult(matches.Count); + } + + public override Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + var hardMatches = _entities.Where(expression.Compile()).ToList(); + foreach (var e in hardMatches) _entities.Remove(e); + return Task.FromResult(hardMatches.Count); + } + + SoftDeleteHelper.EnsureSoftDeletable(); + return Task.FromResult(0); + } public override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) - => Task.FromResult(0); + => DeleteManyAsync(specification.Predicate, token); + + public override Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + => DeleteManyAsync(specification.Predicate, isSoftDelete, token); public override Task UpdateAsync(TestEntity entity, CancellationToken token = default) => Task.CompletedTask; diff --git a/Tests/RCommon.Persistence.Tests/SoftDeleteHelperTests.cs b/Tests/RCommon.Persistence.Tests/SoftDeleteHelperTests.cs new file mode 100644 index 00000000..1a4fa0e0 --- /dev/null +++ b/Tests/RCommon.Persistence.Tests/SoftDeleteHelperTests.cs @@ -0,0 +1,172 @@ +using FluentAssertions; +using RCommon.Entities; +using RCommon.Persistence.Crud; +using System.Linq.Expressions; +using Xunit; + +namespace RCommon.Persistence.Tests; + +public class SoftDeleteHelperTests +{ + [Fact] + public void IsSoftDeletable_WithISoftDeleteEntity_ReturnsTrue() + { + // Act & Assert + SoftDeleteHelper.IsSoftDeletable().Should().BeTrue(); + } + + [Fact] + public void IsSoftDeletable_WithNonISoftDeleteEntity_ReturnsFalse() + { + // Act & Assert + SoftDeleteHelper.IsSoftDeletable().Should().BeFalse(); + } + + [Fact] + public void EnsureSoftDeletable_WithISoftDeleteEntity_DoesNotThrow() + { + // Arrange & Act + var action = SoftDeleteHelper.EnsureSoftDeletable; + + // Assert + action.Should().NotThrow(); + } + + [Fact] + public void EnsureSoftDeletable_WithNonISoftDeleteEntity_ThrowsInvalidOperationException() + { + // Arrange & Act + var action = SoftDeleteHelper.EnsureSoftDeletable; + + // Assert + action.Should().Throw() + .WithMessage("*NonSoftDeletableTestEntity*does not implement ISoftDelete*"); + } + + [Fact] + public void MarkAsDeleted_SetsIsDeletedToTrue() + { + // Arrange + var entity = new SoftDeletableTestEntity { IsDeleted = false }; + + // Act + SoftDeleteHelper.MarkAsDeleted(entity); + + // Assert + entity.IsDeleted.Should().BeTrue(); + } + + [Fact] + public void MarkAsDeleted_WhenAlreadyDeleted_RemainsTrue() + { + // Arrange + var entity = new SoftDeletableTestEntity { IsDeleted = true }; + + // Act + SoftDeleteHelper.MarkAsDeleted(entity); + + // Assert + entity.IsDeleted.Should().BeTrue(); + } + + [Fact] + public void MarkAsDeleted_WithNonISoftDeleteEntity_ThrowsInvalidCastException() + { + // Arrange + var entity = new NonSoftDeletableTestEntity(); + + // Act + var action = () => SoftDeleteHelper.MarkAsDeleted(entity); + + // Assert + action.Should().Throw(); + } + + [Fact] + public void GetNotDeletedFilter_ExcludesDeletedEntities() + { + // Arrange + var entities = new List + { + new() { Name = "Active", IsDeleted = false }, + new() { Name = "Deleted", IsDeleted = true } + }; + + // Act + var filter = SoftDeleteHelper.GetNotDeletedFilter(); + var filtered = entities.AsQueryable().Where(filter).ToList(); + + // Assert + filtered.Should().HaveCount(1); + filtered[0].Name.Should().Be("Active"); + } + + [Fact] + public void CombineWithNotDeletedFilter_OnSoftDeletableEntity_AddsNotDeletedClause() + { + // Arrange + var entities = new List + { + new() { Name = "Active-Match", IsDeleted = false }, + new() { Name = "Deleted-Match", IsDeleted = true }, + new() { Name = "Active-NoMatch", IsDeleted = false } + }; + Expression> expression = e => e.Name!.Contains("Match"); + + // Act + var combined = SoftDeleteHelper.CombineWithNotDeletedFilter(expression); + var filtered = entities.AsQueryable().Where(combined).ToList(); + + // Assert — only the active entities matching "Match" should be returned + filtered.Should().HaveCount(2); + filtered.Should().OnlyContain(e => !e.IsDeleted); + } + + [Fact] + public void CombineWithNotDeletedFilter_OnNonSoftDeletableEntity_ReturnsOriginalExpression() + { + // Arrange + Expression> expression = e => e.Name == "Test"; + + // Act + var result = SoftDeleteHelper.CombineWithNotDeletedFilter(expression); + + // Assert — should return the exact same expression (no filter added) + result.Should().BeSameAs(expression); + } +} + +/// +/// Test entity that implements ISoftDelete for soft-delete testing. +/// +public class SoftDeletableTestEntity : BusinessEntity, ISoftDelete +{ + public string? Name { get; set; } + public bool IsDeleted { get; set; } + + public SoftDeletableTestEntity() : base() + { + Id = Guid.NewGuid(); + } + + public SoftDeletableTestEntity(Guid id) : base(id) + { + } +} + +/// +/// Test entity that does NOT implement ISoftDelete for negative soft-delete testing. +/// +public class NonSoftDeletableTestEntity : BusinessEntity +{ + public string? Name { get; set; } + + public NonSoftDeletableTestEntity() : base() + { + Id = Guid.NewGuid(); + } + + public NonSoftDeletableTestEntity(Guid id) : base(id) + { + } +} diff --git a/Tests/RCommon.Persistence.Tests/SoftDeleteRepositoryTests.cs b/Tests/RCommon.Persistence.Tests/SoftDeleteRepositoryTests.cs new file mode 100644 index 00000000..0a129bb9 --- /dev/null +++ b/Tests/RCommon.Persistence.Tests/SoftDeleteRepositoryTests.cs @@ -0,0 +1,1384 @@ +using FluentAssertions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using RCommon.Collections; +using RCommon.Entities; +using RCommon.Persistence.Crud; +using System.Linq.Expressions; +using Xunit; + +namespace RCommon.Persistence.Tests; + +/// +/// Tests soft-delete behavior through concrete repository implementations that follow +/// the same pattern as EFCoreRepository, DapperRepository, and Linq2DbRepository. +/// +public class SoftDeleteLinqRepositoryTests +{ + private readonly Mock _mockDataStoreFactory; + private readonly Mock _mockEventTracker; + private readonly Mock> _mockDefaultDataStoreOptions; + private readonly DefaultDataStoreOptions _defaultOptions; + + public SoftDeleteLinqRepositoryTests() + { + _mockDataStoreFactory = new Mock(); + _mockEventTracker = new Mock(); + _mockDefaultDataStoreOptions = new Mock>(); + _defaultOptions = new DefaultDataStoreOptions(); + + _mockDefaultDataStoreOptions.Setup(x => x.Value).Returns(_defaultOptions); + } + + // --- Auto-detection tests (parameterless DeleteAsync/DeleteManyAsync) --- + + [Fact] + public async Task DeleteAsync_PlainCall_OnISoftDeleteEntity_AutoSoftDeletes() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity = new SoftDeletableTestEntity { Name = "Test", IsDeleted = false }; + await repository.AddAsync(entity); + + // Act — plain DeleteAsync without isSoftDelete parameter + await repository.DeleteAsync(entity); + + // Assert — entity is soft-deleted (IsDeleted=true) and hidden from filtered queries + entity.IsDeleted.Should().BeTrue(); + var found = await repository.AnyAsync(e => e.Id == entity.Id); + found.Should().BeFalse(); + } + + [Fact] + public async Task DeleteAsync_PlainCall_OnNonISoftDeleteEntity_PhysicallyDeletes() + { + // Arrange + var repository = CreateNonSoftDeletableRepository(); + var entity = new NonSoftDeletableTestEntity { Name = "Test" }; + await repository.AddAsync(entity); + + // Act — plain DeleteAsync on non-ISoftDelete entity + await repository.DeleteAsync(entity); + + // Assert — entity is physically removed + var found = await repository.AnyAsync(e => e.Id == entity.Id); + found.Should().BeFalse(); + } + + [Fact] + public async Task DeleteManyAsync_PlainCall_OnISoftDeleteEntity_AutoSoftDeletes() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity1 = new SoftDeletableTestEntity { Name = "Match", IsDeleted = false }; + var entity2 = new SoftDeletableTestEntity { Name = "NoMatch", IsDeleted = false }; + await repository.AddAsync(entity1); + await repository.AddAsync(entity2); + + // Act — plain DeleteManyAsync without isSoftDelete parameter + var count = await repository.DeleteManyAsync(e => e.Name == "Match"); + + // Assert — matching entity is soft-deleted and hidden from queries, non-matching is untouched + count.Should().Be(1); + entity1.IsDeleted.Should().BeTrue(); + entity2.IsDeleted.Should().BeFalse(); + } + + [Fact] + public async Task DeleteManyAsync_PlainCall_OnNonISoftDeleteEntity_PhysicallyDeletes() + { + // Arrange + var repository = CreateNonSoftDeletableRepository(); + var entity = new NonSoftDeletableTestEntity { Name = "Test" }; + await repository.AddAsync(entity); + + // Act — plain DeleteManyAsync on non-ISoftDelete entity + var count = await repository.DeleteManyAsync(e => e.Name == "Test"); + + // Assert — entity is physically removed + count.Should().Be(1); + var remaining = await repository.GetCountAsync(e => true); + remaining.Should().Be(0); + } + + [Fact] + public async Task DeleteAsync_ExplicitHardDelete_OnISoftDeleteEntity_ForcesPhysicalDelete() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity = new SoftDeletableTestEntity { Name = "Test", IsDeleted = false }; + await repository.AddAsync(entity); + + // Act — explicit isSoftDelete: false bypasses auto-detection + await repository.DeleteAsync(entity, isSoftDelete: false); + + // Assert — entity is physically removed despite implementing ISoftDelete + entity.IsDeleted.Should().BeFalse(); + var found = await repository.AnyAsync(e => e.Id == entity.Id); + found.Should().BeFalse(); + } + + [Fact] + public async Task DeleteManyAsync_ExplicitHardDelete_OnISoftDeleteEntity_ForcesPhysicalDelete() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity1 = new SoftDeletableTestEntity { Name = "Match", IsDeleted = false }; + var entity2 = new SoftDeletableTestEntity { Name = "NoMatch", IsDeleted = false }; + await repository.AddAsync(entity1); + await repository.AddAsync(entity2); + + // Act — explicit isSoftDelete: false bypasses auto-detection + var count = await repository.DeleteManyAsync(e => e.Name == "Match", isSoftDelete: false); + + // Assert — matching entity is physically removed + count.Should().Be(1); + entity1.IsDeleted.Should().BeFalse(); + var remaining = await repository.GetCountAsync(e => true); + remaining.Should().Be(1); + } + + // --- DeleteAsync(entity, isSoftDelete) tests --- + + [Fact] + public async Task DeleteAsync_WithSoftDelete_SetsIsDeletedTrue() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity = new SoftDeletableTestEntity { Name = "Test", IsDeleted = false }; + await repository.AddAsync(entity); + + // Act + await repository.DeleteAsync(entity, isSoftDelete: true); + + // Assert + entity.IsDeleted.Should().BeTrue(); + } + + [Fact] + public async Task DeleteAsync_WithSoftDelete_HidesEntityFromQueries() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity = new SoftDeletableTestEntity { Name = "Test", IsDeleted = false }; + await repository.AddAsync(entity); + + // Act + await repository.DeleteAsync(entity, isSoftDelete: true); + + // Assert — entity is soft-deleted and hidden from filtered queries + entity.IsDeleted.Should().BeTrue(); + var found = await repository.AnyAsync(e => e.Id == entity.Id); + found.Should().BeFalse(); + } + + [Fact] + public async Task DeleteAsync_WithHardDelete_RemovesEntityFromStore() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity = new SoftDeletableTestEntity { Name = "Test" }; + await repository.AddAsync(entity); + + // Act + await repository.DeleteAsync(entity, isSoftDelete: false); + + // Assert + var found = await repository.AnyAsync(e => e.Id == entity.Id); + found.Should().BeFalse(); + } + + [Fact] + public async Task DeleteAsync_WithSoftDelete_OnNonISoftDeleteEntity_ThrowsInvalidOperationException() + { + // Arrange + var repository = CreateNonSoftDeletableRepository(); + var entity = new NonSoftDeletableTestEntity { Name = "Test" }; + await repository.AddAsync(entity); + + // Act + var action = async () => await repository.DeleteAsync(entity, isSoftDelete: true); + + // Assert + await action.Should().ThrowAsync() + .WithMessage("*NonSoftDeletableTestEntity*does not implement ISoftDelete*"); + } + + [Fact] + public async Task DeleteAsync_WithHardDelete_OnNonISoftDeleteEntity_Succeeds() + { + // Arrange + var repository = CreateNonSoftDeletableRepository(); + var entity = new NonSoftDeletableTestEntity { Name = "Test" }; + await repository.AddAsync(entity); + + // Act + await repository.DeleteAsync(entity, isSoftDelete: false); + + // Assert — entity is physically removed + var found = await repository.AnyAsync(e => e.Id == entity.Id); + found.Should().BeFalse(); + } + + // --- DeleteManyAsync(expression, isSoftDelete) tests --- + + [Fact] + public async Task DeleteManyAsync_WithSoftDelete_MarksAllMatchingAsDeleted() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity1 = new SoftDeletableTestEntity { Name = "Match", IsDeleted = false }; + var entity2 = new SoftDeletableTestEntity { Name = "Match", IsDeleted = false }; + var entity3 = new SoftDeletableTestEntity { Name = "NoMatch", IsDeleted = false }; + await repository.AddAsync(entity1); + await repository.AddAsync(entity2); + await repository.AddAsync(entity3); + + // Act + var count = await repository.DeleteManyAsync(e => e.Name == "Match", isSoftDelete: true); + + // Assert + count.Should().Be(2); + entity1.IsDeleted.Should().BeTrue(); + entity2.IsDeleted.Should().BeTrue(); + entity3.IsDeleted.Should().BeFalse(); + } + + [Fact] + public async Task DeleteManyAsync_WithHardDelete_RemovesMatchingEntities() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity1 = new SoftDeletableTestEntity { Name = "Match" }; + var entity2 = new SoftDeletableTestEntity { Name = "Match" }; + var entity3 = new SoftDeletableTestEntity { Name = "NoMatch" }; + await repository.AddAsync(entity1); + await repository.AddAsync(entity2); + await repository.AddAsync(entity3); + + // Act + var count = await repository.DeleteManyAsync(e => e.Name == "Match", isSoftDelete: false); + + // Assert + count.Should().Be(2); + var remaining = await repository.GetCountAsync(e => true); + remaining.Should().Be(1); + } + + [Fact] + public async Task DeleteManyAsync_WithSoftDelete_OnNonISoftDeleteEntity_ThrowsInvalidOperationException() + { + // Arrange + var repository = CreateNonSoftDeletableRepository(); + var entity = new NonSoftDeletableTestEntity { Name = "Test" }; + await repository.AddAsync(entity); + + // Act + var action = async () => await repository.DeleteManyAsync(e => e.Name == "Test", isSoftDelete: true); + + // Assert + await action.Should().ThrowAsync() + .WithMessage("*NonSoftDeletableTestEntity*does not implement ISoftDelete*"); + } + + // --- DeleteManyAsync(specification, isSoftDelete) tests --- + + [Fact] + public async Task DeleteManyAsync_WithSpecAndSoftDelete_DelegatesToExpressionOverload() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity = new SoftDeletableTestEntity { Name = "Match", IsDeleted = false }; + await repository.AddAsync(entity); + + var mockSpec = new Mock>(); + mockSpec.Setup(s => s.Predicate).Returns(e => e.Name == "Match"); + + // Act + var count = await repository.DeleteManyAsync(mockSpec.Object, isSoftDelete: true); + + // Assert + count.Should().Be(1); + entity.IsDeleted.Should().BeTrue(); + } + + [Fact] + public async Task DeleteManyAsync_WithSpecAndHardDelete_RemovesEntities() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity = new SoftDeletableTestEntity { Name = "Match" }; + await repository.AddAsync(entity); + + var mockSpec = new Mock>(); + mockSpec.Setup(s => s.Predicate).Returns(e => e.Name == "Match"); + + // Act + var count = await repository.DeleteManyAsync(mockSpec.Object, isSoftDelete: false); + + // Assert + count.Should().Be(1); + var remaining = await repository.GetCountAsync(e => true); + remaining.Should().Be(0); + } + + // --- Query filtering tests --- + + [Fact] + public async Task FindAsync_ExcludesSoftDeletedEntities() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var active = new SoftDeletableTestEntity { Name = "Active", IsDeleted = false }; + var deleted = new SoftDeletableTestEntity { Name = "Deleted", IsDeleted = true }; + await repository.AddAsync(active); + await repository.AddAsync(deleted); + + // Act + var results = await repository.FindAsync(e => true); + + // Assert — only the active entity should be returned + results.Should().HaveCount(1); + results.Should().OnlyContain(e => e.Name == "Active"); + } + + [Fact] + public async Task AnyAsync_ExcludesSoftDeletedEntities() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var deleted = new SoftDeletableTestEntity { Name = "OnlyOne", IsDeleted = true }; + await repository.AddAsync(deleted); + + // Act + var any = await repository.AnyAsync(e => e.Name == "OnlyOne"); + + // Assert — soft-deleted entity should not be found + any.Should().BeFalse(); + } + + [Fact] + public async Task GetCountAsync_ExcludesSoftDeletedEntities() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var active1 = new SoftDeletableTestEntity { Name = "A", IsDeleted = false }; + var active2 = new SoftDeletableTestEntity { Name = "B", IsDeleted = false }; + var deleted = new SoftDeletableTestEntity { Name = "C", IsDeleted = true }; + await repository.AddAsync(active1); + await repository.AddAsync(active2); + await repository.AddAsync(deleted); + + // Act + var count = await repository.GetCountAsync(e => true); + + // Assert — only active entities counted + count.Should().Be(2); + } + + [Fact] + public async Task FindSingleOrDefaultAsync_ExcludesSoftDeletedEntities() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var deleted = new SoftDeletableTestEntity { Name = "Target", IsDeleted = true }; + await repository.AddAsync(deleted); + + // Act + var result = await repository.FindSingleOrDefaultAsync(e => e.Name == "Target"); + + // Assert — soft-deleted entity should not be found + result.Should().BeNull(); + } + + [Fact] + public async Task FindAsync_OnNonSoftDeletable_ReturnsAllEntities() + { + // Arrange + var repository = CreateNonSoftDeletableRepository(); + var entity1 = new NonSoftDeletableTestEntity { Name = "A" }; + var entity2 = new NonSoftDeletableTestEntity { Name = "B" }; + await repository.AddAsync(entity1); + await repository.AddAsync(entity2); + + // Act + var results = await repository.FindAsync(e => true); + + // Assert — non-ISoftDelete entities are never filtered + results.Should().HaveCount(2); + } + + [Fact] + public async Task FindQuery_ExcludesSoftDeletedEntities() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var active = new SoftDeletableTestEntity { Name = "Active", IsDeleted = false }; + var deleted = new SoftDeletableTestEntity { Name = "Deleted", IsDeleted = true }; + await repository.AddAsync(active); + await repository.AddAsync(deleted); + + // Act + var results = repository.FindQuery(e => true).ToList(); + + // Assert — only the active entity should be returned + results.Should().HaveCount(1); + results.Should().OnlyContain(e => e.Name == "Active"); + } + + // --- Helper methods --- + + private TestSoftDeletableLinqRepository CreateSoftDeletableRepository() + { + return new TestSoftDeletableLinqRepository( + _mockDataStoreFactory.Object, + _mockEventTracker.Object, + _mockDefaultDataStoreOptions.Object); + } + + private TestNonSoftDeletableLinqRepository CreateNonSoftDeletableRepository() + { + return new TestNonSoftDeletableLinqRepository( + _mockDataStoreFactory.Object, + _mockEventTracker.Object, + _mockDefaultDataStoreOptions.Object); + } +} + +/// +/// Tests soft-delete behavior through the SQL (micro-ORM) repository base class pattern. +/// +public class SoftDeleteSqlRepositoryTests +{ + private readonly Mock _mockDataStoreFactory; + private readonly Mock _mockLoggerFactory; + private readonly Mock _mockEventTracker; + private readonly Mock> _mockDefaultDataStoreOptions; + private readonly DefaultDataStoreOptions _defaultOptions; + + public SoftDeleteSqlRepositoryTests() + { + _mockDataStoreFactory = new Mock(); + _mockLoggerFactory = new Mock(); + _mockEventTracker = new Mock(); + _mockDefaultDataStoreOptions = new Mock>(); + _defaultOptions = new DefaultDataStoreOptions(); + + _mockDefaultDataStoreOptions.Setup(x => x.Value).Returns(_defaultOptions); + } + + // --- Auto-detection tests (parameterless DeleteAsync/DeleteManyAsync) --- + + [Fact] + public async Task DeleteAsync_PlainCall_OnISoftDeleteEntity_AutoSoftDeletes() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity = new SoftDeletableTestEntity { Name = "Test", IsDeleted = false }; + await repository.AddAsync(entity); + + // Act — plain DeleteAsync without isSoftDelete parameter + await repository.DeleteAsync(entity); + + // Assert — entity is soft-deleted (IsDeleted=true) and hidden from filtered queries + entity.IsDeleted.Should().BeTrue(); + var found = await repository.AnyAsync(e => e.Id == entity.Id); + found.Should().BeFalse(); + } + + [Fact] + public async Task DeleteAsync_PlainCall_OnNonISoftDeleteEntity_PhysicallyDeletes() + { + // Arrange + var repository = CreateNonSoftDeletableRepository(); + var entity = new NonSoftDeletableTestEntity { Name = "Test" }; + await repository.AddAsync(entity); + + // Act + await repository.DeleteAsync(entity); + + // Assert — entity is physically removed + var found = await repository.AnyAsync(e => e.Id == entity.Id); + found.Should().BeFalse(); + } + + [Fact] + public async Task DeleteManyAsync_PlainCall_OnISoftDeleteEntity_AutoSoftDeletes() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity1 = new SoftDeletableTestEntity { Name = "Match", IsDeleted = false }; + var entity2 = new SoftDeletableTestEntity { Name = "NoMatch", IsDeleted = false }; + await repository.AddAsync(entity1); + await repository.AddAsync(entity2); + + // Act — plain DeleteManyAsync without isSoftDelete parameter + var count = await repository.DeleteManyAsync(e => e.Name == "Match"); + + // Assert — matching entity is soft-deleted and hidden from queries, non-matching is untouched + count.Should().Be(1); + entity1.IsDeleted.Should().BeTrue(); + entity2.IsDeleted.Should().BeFalse(); + } + + [Fact] + public async Task DeleteManyAsync_PlainCall_OnNonISoftDeleteEntity_PhysicallyDeletes() + { + // Arrange + var repository = CreateNonSoftDeletableRepository(); + var entity = new NonSoftDeletableTestEntity { Name = "Test" }; + await repository.AddAsync(entity); + + // Act + var count = await repository.DeleteManyAsync(e => e.Name == "Test"); + + // Assert — entity is physically removed + count.Should().Be(1); + var remaining = await repository.GetCountAsync(e => true); + remaining.Should().Be(0); + } + + [Fact] + public async Task DeleteAsync_ExplicitHardDelete_OnISoftDeleteEntity_ForcesPhysicalDelete() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity = new SoftDeletableTestEntity { Name = "Test", IsDeleted = false }; + await repository.AddAsync(entity); + + // Act — explicit isSoftDelete: false bypasses auto-detection + await repository.DeleteAsync(entity, isSoftDelete: false); + + // Assert — entity is physically removed despite implementing ISoftDelete + entity.IsDeleted.Should().BeFalse(); + var found = await repository.AnyAsync(e => e.Id == entity.Id); + found.Should().BeFalse(); + } + + [Fact] + public async Task DeleteManyAsync_ExplicitHardDelete_OnISoftDeleteEntity_ForcesPhysicalDelete() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity1 = new SoftDeletableTestEntity { Name = "Match", IsDeleted = false }; + var entity2 = new SoftDeletableTestEntity { Name = "NoMatch", IsDeleted = false }; + await repository.AddAsync(entity1); + await repository.AddAsync(entity2); + + // Act — explicit isSoftDelete: false bypasses auto-detection + var count = await repository.DeleteManyAsync(e => e.Name == "Match", isSoftDelete: false); + + // Assert — matching entity is physically removed + count.Should().Be(1); + entity1.IsDeleted.Should().BeFalse(); + var remaining = await repository.GetCountAsync(e => true); + remaining.Should().Be(1); + } + + // --- DeleteAsync(entity, isSoftDelete) tests --- + + [Fact] + public async Task DeleteAsync_WithSoftDelete_SetsIsDeletedTrue() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity = new SoftDeletableTestEntity { Name = "Test", IsDeleted = false }; + await repository.AddAsync(entity); + + // Act + await repository.DeleteAsync(entity, isSoftDelete: true); + + // Assert + entity.IsDeleted.Should().BeTrue(); + } + + [Fact] + public async Task DeleteAsync_WithSoftDelete_OnNonISoftDeleteEntity_ThrowsInvalidOperationException() + { + // Arrange + var repository = CreateNonSoftDeletableRepository(); + var entity = new NonSoftDeletableTestEntity { Name = "Test" }; + await repository.AddAsync(entity); + + // Act + var action = async () => await repository.DeleteAsync(entity, isSoftDelete: true); + + // Assert + await action.Should().ThrowAsync() + .WithMessage("*NonSoftDeletableTestEntity*does not implement ISoftDelete*"); + } + + [Fact] + public async Task DeleteAsync_WithHardDelete_RemovesEntityFromStore() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity = new SoftDeletableTestEntity { Name = "Test" }; + await repository.AddAsync(entity); + + // Act + await repository.DeleteAsync(entity, isSoftDelete: false); + + // Assert + var found = await repository.AnyAsync(e => e.Id == entity.Id); + found.Should().BeFalse(); + } + + [Fact] + public async Task DeleteManyAsync_WithSoftDelete_MarksAllMatchingAsDeleted() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var entity1 = new SoftDeletableTestEntity { Name = "Match", IsDeleted = false }; + var entity2 = new SoftDeletableTestEntity { Name = "NoMatch", IsDeleted = false }; + await repository.AddAsync(entity1); + await repository.AddAsync(entity2); + + // Act + var count = await repository.DeleteManyAsync(e => e.Name == "Match", isSoftDelete: true); + + // Assert + count.Should().Be(1); + entity1.IsDeleted.Should().BeTrue(); + entity2.IsDeleted.Should().BeFalse(); + } + + [Fact] + public async Task DeleteManyAsync_WithSoftDelete_OnNonISoftDeleteEntity_ThrowsInvalidOperationException() + { + // Arrange + var repository = CreateNonSoftDeletableRepository(); + var entity = new NonSoftDeletableTestEntity { Name = "Test" }; + await repository.AddAsync(entity); + + // Act + var action = async () => await repository.DeleteManyAsync(e => e.Name == "Test", isSoftDelete: true); + + // Assert + await action.Should().ThrowAsync() + .WithMessage("*NonSoftDeletableTestEntity*does not implement ISoftDelete*"); + } + + // --- Query filtering tests --- + + [Fact] + public async Task FindAsync_ExcludesSoftDeletedEntities() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var active = new SoftDeletableTestEntity { Name = "Active", IsDeleted = false }; + var deleted = new SoftDeletableTestEntity { Name = "Deleted", IsDeleted = true }; + await repository.AddAsync(active); + await repository.AddAsync(deleted); + + // Act + var results = await repository.FindAsync(e => true); + + // Assert — only the active entity should be returned + results.Should().HaveCount(1); + results.Should().OnlyContain(e => e.Name == "Active"); + } + + [Fact] + public async Task AnyAsync_ExcludesSoftDeletedEntities() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var deleted = new SoftDeletableTestEntity { Name = "OnlyOne", IsDeleted = true }; + await repository.AddAsync(deleted); + + // Act + var any = await repository.AnyAsync(e => e.Name == "OnlyOne"); + + // Assert — soft-deleted entity should not be found + any.Should().BeFalse(); + } + + [Fact] + public async Task GetCountAsync_ExcludesSoftDeletedEntities() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var active1 = new SoftDeletableTestEntity { Name = "A", IsDeleted = false }; + var active2 = new SoftDeletableTestEntity { Name = "B", IsDeleted = false }; + var deleted = new SoftDeletableTestEntity { Name = "C", IsDeleted = true }; + await repository.AddAsync(active1); + await repository.AddAsync(active2); + await repository.AddAsync(deleted); + + // Act + var count = await repository.GetCountAsync(e => true); + + // Assert — only active entities counted + count.Should().Be(2); + } + + [Fact] + public async Task FindSingleOrDefaultAsync_ExcludesSoftDeletedEntities() + { + // Arrange + var repository = CreateSoftDeletableRepository(); + var deleted = new SoftDeletableTestEntity { Name = "Target", IsDeleted = true }; + await repository.AddAsync(deleted); + + // Act + var result = await repository.FindSingleOrDefaultAsync(e => e.Name == "Target"); + + // Assert — soft-deleted entity should not be found + result.Should().BeNull(); + } + + [Fact] + public async Task FindAsync_OnNonSoftDeletable_ReturnsAllEntities() + { + // Arrange + var repository = CreateNonSoftDeletableRepository(); + var entity1 = new NonSoftDeletableTestEntity { Name = "A" }; + var entity2 = new NonSoftDeletableTestEntity { Name = "B" }; + await repository.AddAsync(entity1); + await repository.AddAsync(entity2); + + // Act + var results = await repository.FindAsync(e => true); + + // Assert — non-ISoftDelete entities are never filtered + results.Should().HaveCount(2); + } + + // --- Helper methods --- + + private TestSoftDeletableSqlRepository CreateSoftDeletableRepository() + { + return new TestSoftDeletableSqlRepository( + _mockDataStoreFactory.Object, + _mockLoggerFactory.Object, + _mockEventTracker.Object, + _mockDefaultDataStoreOptions.Object); + } + + private TestNonSoftDeletableSqlRepository CreateNonSoftDeletableRepository() + { + return new TestNonSoftDeletableSqlRepository( + _mockDataStoreFactory.Object, + _mockLoggerFactory.Object, + _mockEventTracker.Object, + _mockDefaultDataStoreOptions.Object); + } +} + +// ============================================================================ +// Test repository implementations for SoftDeletableTestEntity (Linq-based) +// ============================================================================ + +/// +/// Concrete LinqRepositoryBase implementation for SoftDeletableTestEntity. +/// Mimics the soft-delete logic used by EFCoreRepository and Linq2DbRepository. +/// +public class TestSoftDeletableLinqRepository : LinqRepositoryBase +{ + private readonly List _entities = new(); + + public TestSoftDeletableLinqRepository( + IDataStoreFactory dataStoreFactory, + IEntityEventTracker eventTracker, + IOptions defaultDataStoreOptions) + : base(dataStoreFactory, eventTracker, defaultDataStoreOptions) + { + } + + protected override IQueryable RepositoryQuery => _entities.AsQueryable(); + + public override Task AddAsync(SoftDeletableTestEntity entity, CancellationToken token = default) + { + _entities.Add(entity); + return Task.CompletedTask; + } + + public override Task AddRangeAsync(IEnumerable entities, CancellationToken token = default) + { + _entities.AddRange(entities); + return Task.CompletedTask; + } + + public override Task DeleteAsync(SoftDeletableTestEntity entity, CancellationToken token = default) + { + // Auto-detect: if entity implements ISoftDelete, perform soft delete automatically + if (SoftDeleteHelper.IsSoftDeletable()) + { + SoftDeleteHelper.MarkAsDeleted(entity); + return UpdateAsync(entity, token); + } + + _entities.Remove(entity); + return Task.CompletedTask; + } + + public override Task DeleteAsync(SoftDeletableTestEntity entity, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + _entities.Remove(entity); + return Task.CompletedTask; + } + + SoftDeleteHelper.EnsureSoftDeletable(); + SoftDeleteHelper.MarkAsDeleted(entity); + return UpdateAsync(entity, token); + } + + public override Task DeleteManyAsync(Expression> expression, CancellationToken token = default) + { + // Auto-detect: if entity implements ISoftDelete, perform soft delete automatically + if (SoftDeleteHelper.IsSoftDeletable()) + { + return DeleteManyAsync(expression, isSoftDelete: true, token); + } + + var matches = _entities.Where(expression.Compile()).ToList(); + foreach (var e in matches) _entities.Remove(e); + return Task.FromResult(matches.Count); + } + + public override Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + var hardMatches = _entities.Where(expression.Compile()).ToList(); + foreach (var e in hardMatches) _entities.Remove(e); + return Task.FromResult(hardMatches.Count); + } + + SoftDeleteHelper.EnsureSoftDeletable(); + + var matches = _entities.Where(expression.Compile()).ToList(); + foreach (var entity in matches) + { + SoftDeleteHelper.MarkAsDeleted(entity); + } + return Task.FromResult(matches.Count); + } + + public override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) + => DeleteManyAsync(specification.Predicate, token); + + public override Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + => DeleteManyAsync(specification.Predicate, isSoftDelete, token); + + public override Task UpdateAsync(SoftDeletableTestEntity entity, CancellationToken token = default) + => Task.CompletedTask; + + // Read methods use FilteredRepositoryQuery (from LinqRepositoryBase) to automatically + // exclude soft-deleted entities, mirroring the real EFCoreRepository/Linq2DbRepository behavior. + + public override Task> FindAsync(ISpecification specification, CancellationToken token = default) + => Task.FromResult>(FilteredRepositoryQuery.Where(specification.Predicate).ToList()); + + public override Task> FindAsync(Expression> expression, CancellationToken token = default) + => Task.FromResult>(FilteredRepositoryQuery.Where(expression).ToList()); + + public override Task FindAsync(object primaryKey, CancellationToken token = default) + { + // Mimics EFCore FindAsync(pk) post-fetch soft-delete check + var entity = _entities.FirstOrDefault(); + if (entity != null && SoftDeleteHelper.IsSoftDeletable() && ((ISoftDelete)entity).IsDeleted) + return Task.FromResult(default!); + return Task.FromResult(entity!); + } + + public override Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) + => Task.FromResult((long)FilteredRepositoryQuery.Where(selectSpec.Predicate).Count()); + + public override Task GetCountAsync(Expression> expression, CancellationToken token = default) + => Task.FromResult((long)FilteredRepositoryQuery.Where(expression).Count()); + + public override Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) + => Task.FromResult(FilteredRepositoryQuery.Where(expression).SingleOrDefault()!); + + public override Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) + => Task.FromResult(FilteredRepositoryQuery.Where(specification.Predicate).SingleOrDefault()!); + + public override Task AnyAsync(Expression> expression, CancellationToken token = default) + => Task.FromResult(FilteredRepositoryQuery.Where(expression).Any()); + + public override Task AnyAsync(ISpecification specification, CancellationToken token = default) + => Task.FromResult(FilteredRepositoryQuery.Where(specification.Predicate).Any()); + + public override IQueryable FindQuery(ISpecification specification) + => FilteredRepositoryQuery.Where(specification.Predicate); + + public override IQueryable FindQuery(Expression> expression) + => FilteredRepositoryQuery.Where(expression); + + public override IQueryable FindQuery(Expression> expression, + Expression> orderByExpression, bool orderByAscending) + => FilteredRepositoryQuery.Where(expression); + + public override Task> FindAsync(Expression> expression, + Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, + int pageSize = 0, CancellationToken token = default) + => Task.FromResult>(null!); + + public override Task> FindAsync(IPagedSpecification specification, CancellationToken token = default) + => Task.FromResult>(null!); + + public override IQueryable FindQuery(Expression> expression, + Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0) + => FilteredRepositoryQuery.Where(expression); + + public override IQueryable FindQuery(IPagedSpecification specification) + => FilteredRepositoryQuery; + + public override IEagerLoadableQueryable Include(Expression> path) + => null!; + + public override IEagerLoadableQueryable ThenInclude(Expression> path) + => null!; +} + +// ============================================================================ +// Test repository implementations for NonSoftDeletableTestEntity (Linq-based) +// ============================================================================ + +/// +/// Concrete LinqRepositoryBase implementation for NonSoftDeletableTestEntity. +/// Used to verify that soft delete throws InvalidOperationException when the entity +/// does not implement ISoftDelete. +/// +public class TestNonSoftDeletableLinqRepository : LinqRepositoryBase +{ + private readonly List _entities = new(); + + public TestNonSoftDeletableLinqRepository( + IDataStoreFactory dataStoreFactory, + IEntityEventTracker eventTracker, + IOptions defaultDataStoreOptions) + : base(dataStoreFactory, eventTracker, defaultDataStoreOptions) + { + } + + protected override IQueryable RepositoryQuery => _entities.AsQueryable(); + + public override Task AddAsync(NonSoftDeletableTestEntity entity, CancellationToken token = default) + { + _entities.Add(entity); + return Task.CompletedTask; + } + + public override Task AddRangeAsync(IEnumerable entities, CancellationToken token = default) + { + _entities.AddRange(entities); + return Task.CompletedTask; + } + + public override Task DeleteAsync(NonSoftDeletableTestEntity entity, CancellationToken token = default) + { + // Auto-detect: ISoftDelete check returns false, so physical delete + if (SoftDeleteHelper.IsSoftDeletable()) + { + SoftDeleteHelper.MarkAsDeleted(entity); + return UpdateAsync(entity, token); + } + + _entities.Remove(entity); + return Task.CompletedTask; + } + + public override Task DeleteAsync(NonSoftDeletableTestEntity entity, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + _entities.Remove(entity); + return Task.CompletedTask; + } + + SoftDeleteHelper.EnsureSoftDeletable(); + SoftDeleteHelper.MarkAsDeleted(entity); + return UpdateAsync(entity, token); + } + + public override Task DeleteManyAsync(Expression> expression, CancellationToken token = default) + { + // Auto-detect: ISoftDelete check returns false, so physical delete + if (SoftDeleteHelper.IsSoftDeletable()) + { + return DeleteManyAsync(expression, isSoftDelete: true, token); + } + + var matches = _entities.Where(expression.Compile()).ToList(); + foreach (var e in matches) _entities.Remove(e); + return Task.FromResult(matches.Count); + } + + public override Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + var hardMatches = _entities.Where(expression.Compile()).ToList(); + foreach (var e in hardMatches) _entities.Remove(e); + return Task.FromResult(hardMatches.Count); + } + + SoftDeleteHelper.EnsureSoftDeletable(); + return Task.FromResult(0); + } + + public override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) + => DeleteManyAsync(specification.Predicate, token); + + public override Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + => DeleteManyAsync(specification.Predicate, isSoftDelete, token); + + public override Task UpdateAsync(NonSoftDeletableTestEntity entity, CancellationToken token = default) + => Task.CompletedTask; + + public override Task> FindAsync(ISpecification specification, CancellationToken token = default) + => Task.FromResult>(_entities.Where(specification.Predicate.Compile()).ToList()); + + public override Task> FindAsync(Expression> expression, CancellationToken token = default) + => Task.FromResult>(_entities.Where(expression.Compile()).ToList()); + + public override Task FindAsync(object primaryKey, CancellationToken token = default) + => Task.FromResult(_entities.FirstOrDefault()!); + + public override Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) + => Task.FromResult((long)_entities.Count(selectSpec.Predicate.Compile())); + + public override Task GetCountAsync(Expression> expression, CancellationToken token = default) + => Task.FromResult((long)_entities.Count(expression.Compile())); + + public override Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) + => Task.FromResult(_entities.SingleOrDefault(expression.Compile())!); + + public override Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) + => Task.FromResult(_entities.SingleOrDefault(specification.Predicate.Compile())!); + + public override Task AnyAsync(Expression> expression, CancellationToken token = default) + => Task.FromResult(_entities.Any(expression.Compile())); + + public override Task AnyAsync(ISpecification specification, CancellationToken token = default) + => Task.FromResult(_entities.Any(specification.Predicate.Compile())); + + public override IQueryable FindQuery(ISpecification specification) + => _entities.AsQueryable().Where(specification.Predicate); + + public override IQueryable FindQuery(Expression> expression) + => _entities.AsQueryable().Where(expression); + + public override IQueryable FindQuery(Expression> expression, + Expression> orderByExpression, bool orderByAscending) + => _entities.AsQueryable().Where(expression); + + public override Task> FindAsync(Expression> expression, + Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, + int pageSize = 0, CancellationToken token = default) + => Task.FromResult>(null!); + + public override Task> FindAsync(IPagedSpecification specification, CancellationToken token = default) + => Task.FromResult>(null!); + + public override IQueryable FindQuery(Expression> expression, + Expression> orderByExpression, bool orderByAscending, int pageNumber = 1, int pageSize = 0) + => _entities.AsQueryable().Where(expression); + + public override IQueryable FindQuery(IPagedSpecification specification) + => _entities.AsQueryable(); + + public override IEagerLoadableQueryable Include(Expression> path) + => null!; + + public override IEagerLoadableQueryable ThenInclude(Expression> path) + => null!; +} + +// ============================================================================ +// Test repository implementations for SqlRepositoryBase (SoftDeletable) +// ============================================================================ + +/// +/// Concrete SqlRepositoryBase implementation for SoftDeletableTestEntity. +/// Mimics the soft-delete logic used by DapperRepository. +/// +public class TestSoftDeletableSqlRepository : SqlRepositoryBase +{ + private readonly List _entities = new(); + + public TestSoftDeletableSqlRepository( + IDataStoreFactory dataStoreFactory, + ILoggerFactory loggerFactory, + IEntityEventTracker eventTracker, + IOptions defaultDataStoreOptions) + : base(dataStoreFactory, loggerFactory, eventTracker, defaultDataStoreOptions) + { + } + + public override Task AddAsync(SoftDeletableTestEntity entity, CancellationToken token = default) + { + _entities.Add(entity); + return Task.CompletedTask; + } + + public override Task AddRangeAsync(IEnumerable entities, CancellationToken token = default) + { + _entities.AddRange(entities); + return Task.CompletedTask; + } + + public override Task DeleteAsync(SoftDeletableTestEntity entity, CancellationToken token = default) + { + // Auto-detect: if entity implements ISoftDelete, perform soft delete automatically + if (SoftDeleteHelper.IsSoftDeletable()) + { + SoftDeleteHelper.MarkAsDeleted(entity); + return UpdateAsync(entity, token); + } + + _entities.Remove(entity); + return Task.CompletedTask; + } + + public override Task DeleteAsync(SoftDeletableTestEntity entity, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + _entities.Remove(entity); + return Task.CompletedTask; + } + + SoftDeleteHelper.EnsureSoftDeletable(); + SoftDeleteHelper.MarkAsDeleted(entity); + return UpdateAsync(entity, token); + } + + public override Task DeleteManyAsync(Expression> expression, CancellationToken token = default) + { + // Auto-detect: if entity implements ISoftDelete, perform soft delete automatically + if (SoftDeleteHelper.IsSoftDeletable()) + { + return DeleteManyAsync(expression, isSoftDelete: true, token); + } + + var matches = _entities.Where(expression.Compile()).ToList(); + foreach (var e in matches) _entities.Remove(e); + return Task.FromResult(matches.Count); + } + + public override Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + var hardMatches = _entities.Where(expression.Compile()).ToList(); + foreach (var e in hardMatches) _entities.Remove(e); + return Task.FromResult(hardMatches.Count); + } + + SoftDeleteHelper.EnsureSoftDeletable(); + + var matches = _entities.Where(expression.Compile()).ToList(); + foreach (var entity in matches) + { + SoftDeleteHelper.MarkAsDeleted(entity); + } + return Task.FromResult(matches.Count); + } + + public override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) + => DeleteManyAsync(specification.Predicate, token); + + public override Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + => DeleteManyAsync(specification.Predicate, isSoftDelete, token); + + public override Task UpdateAsync(SoftDeletableTestEntity entity, CancellationToken token = default) + => Task.CompletedTask; + + // Read methods wrap expressions with CombineWithNotDeletedFilter to automatically + // exclude soft-deleted entities, mirroring the real DapperRepository behavior. + + public override Task> FindAsync(ISpecification specification, CancellationToken token = default) + { + var filtered = SoftDeleteHelper.CombineWithNotDeletedFilter(specification.Predicate); + return Task.FromResult>(_entities.Where(filtered.Compile()).ToList()); + } + + public override Task> FindAsync(Expression> expression, CancellationToken token = default) + { + var filtered = SoftDeleteHelper.CombineWithNotDeletedFilter(expression); + return Task.FromResult>(_entities.Where(filtered.Compile()).ToList()); + } + + public override Task FindAsync(object primaryKey, CancellationToken token = default) + { + // Mimics DapperRepository FindAsync(pk) post-fetch soft-delete check + var entity = _entities.FirstOrDefault(); + if (entity != null && SoftDeleteHelper.IsSoftDeletable() && ((ISoftDelete)entity).IsDeleted) + return Task.FromResult(default!); + return Task.FromResult(entity!); + } + + public override Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) + { + var filtered = SoftDeleteHelper.CombineWithNotDeletedFilter(selectSpec.Predicate); + return Task.FromResult((long)_entities.Count(filtered.Compile())); + } + + public override Task GetCountAsync(Expression> expression, CancellationToken token = default) + { + var filtered = SoftDeleteHelper.CombineWithNotDeletedFilter(expression); + return Task.FromResult((long)_entities.Count(filtered.Compile())); + } + + public override Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) + { + var filtered = SoftDeleteHelper.CombineWithNotDeletedFilter(expression); + return Task.FromResult(_entities.SingleOrDefault(filtered.Compile())!); + } + + public override Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) + { + var filtered = SoftDeleteHelper.CombineWithNotDeletedFilter(specification.Predicate); + return Task.FromResult(_entities.SingleOrDefault(filtered.Compile())!); + } + + public override Task AnyAsync(Expression> expression, CancellationToken token = default) + { + var filtered = SoftDeleteHelper.CombineWithNotDeletedFilter(expression); + return Task.FromResult(_entities.Any(filtered.Compile())); + } + + public override Task AnyAsync(ISpecification specification, CancellationToken token = default) + { + var filtered = SoftDeleteHelper.CombineWithNotDeletedFilter(specification.Predicate); + return Task.FromResult(_entities.Any(filtered.Compile())); + } +} + +// ============================================================================ +// Test repository implementations for SqlRepositoryBase (NonSoftDeletable) +// ============================================================================ + +/// +/// Concrete SqlRepositoryBase implementation for NonSoftDeletableTestEntity. +/// Used to verify that soft delete throws InvalidOperationException when the entity +/// does not implement ISoftDelete. +/// +public class TestNonSoftDeletableSqlRepository : SqlRepositoryBase +{ + private readonly List _entities = new(); + + public TestNonSoftDeletableSqlRepository( + IDataStoreFactory dataStoreFactory, + ILoggerFactory loggerFactory, + IEntityEventTracker eventTracker, + IOptions defaultDataStoreOptions) + : base(dataStoreFactory, loggerFactory, eventTracker, defaultDataStoreOptions) + { + } + + public override Task AddAsync(NonSoftDeletableTestEntity entity, CancellationToken token = default) + { + _entities.Add(entity); + return Task.CompletedTask; + } + + public override Task AddRangeAsync(IEnumerable entities, CancellationToken token = default) + { + _entities.AddRange(entities); + return Task.CompletedTask; + } + + public override Task DeleteAsync(NonSoftDeletableTestEntity entity, CancellationToken token = default) + { + // Auto-detect: ISoftDelete check returns false, so physical delete + if (SoftDeleteHelper.IsSoftDeletable()) + { + SoftDeleteHelper.MarkAsDeleted(entity); + return UpdateAsync(entity, token); + } + + _entities.Remove(entity); + return Task.CompletedTask; + } + + public override Task DeleteAsync(NonSoftDeletableTestEntity entity, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + _entities.Remove(entity); + return Task.CompletedTask; + } + + SoftDeleteHelper.EnsureSoftDeletable(); + SoftDeleteHelper.MarkAsDeleted(entity); + return UpdateAsync(entity, token); + } + + public override Task DeleteManyAsync(Expression> expression, CancellationToken token = default) + { + // Auto-detect: ISoftDelete check returns false, so physical delete + if (SoftDeleteHelper.IsSoftDeletable()) + { + return DeleteManyAsync(expression, isSoftDelete: true, token); + } + + var matches = _entities.Where(expression.Compile()).ToList(); + foreach (var e in matches) _entities.Remove(e); + return Task.FromResult(matches.Count); + } + + public override Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + { + if (!isSoftDelete) + { + // Bypass auto-detection — force a physical delete + var hardMatches = _entities.Where(expression.Compile()).ToList(); + foreach (var e in hardMatches) _entities.Remove(e); + return Task.FromResult(hardMatches.Count); + } + + SoftDeleteHelper.EnsureSoftDeletable(); + return Task.FromResult(0); + } + + public override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) + => DeleteManyAsync(specification.Predicate, token); + + public override Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + => DeleteManyAsync(specification.Predicate, isSoftDelete, token); + + public override Task UpdateAsync(NonSoftDeletableTestEntity entity, CancellationToken token = default) + => Task.CompletedTask; + + public override Task> FindAsync(ISpecification specification, CancellationToken token = default) + => Task.FromResult>(_entities.Where(specification.Predicate.Compile()).ToList()); + + public override Task> FindAsync(Expression> expression, CancellationToken token = default) + => Task.FromResult>(_entities.Where(expression.Compile()).ToList()); + + public override Task FindAsync(object primaryKey, CancellationToken token = default) + => Task.FromResult(_entities.FirstOrDefault()!); + + public override Task GetCountAsync(ISpecification selectSpec, CancellationToken token = default) + => Task.FromResult((long)_entities.Count(selectSpec.Predicate.Compile())); + + public override Task GetCountAsync(Expression> expression, CancellationToken token = default) + => Task.FromResult((long)_entities.Count(expression.Compile())); + + public override Task FindSingleOrDefaultAsync(Expression> expression, CancellationToken token = default) + => Task.FromResult(_entities.SingleOrDefault(expression.Compile())!); + + public override Task FindSingleOrDefaultAsync(ISpecification specification, CancellationToken token = default) + => Task.FromResult(_entities.SingleOrDefault(specification.Predicate.Compile())!); + + public override Task AnyAsync(Expression> expression, CancellationToken token = default) + => Task.FromResult(_entities.Any(expression.Compile())); + + public override Task AnyAsync(ISpecification specification, CancellationToken token = default) + => Task.FromResult(_entities.Any(specification.Predicate.Compile())); +} diff --git a/Tests/RCommon.Persistence.Tests/SqlRepositoryBaseTests.cs b/Tests/RCommon.Persistence.Tests/SqlRepositoryBaseTests.cs index 86e8ee6c..a1d4dc7b 100644 --- a/Tests/RCommon.Persistence.Tests/SqlRepositoryBaseTests.cs +++ b/Tests/RCommon.Persistence.Tests/SqlRepositoryBaseTests.cs @@ -298,12 +298,21 @@ public override Task DeleteAsync(TestSqlEntity entity, CancellationToken token = return Task.CompletedTask; } + public override Task DeleteAsync(TestSqlEntity entity, bool isSoftDelete, CancellationToken token = default) + => throw new NotImplementedException(); + public override Task DeleteManyAsync(Expression> expression, CancellationToken token = default) => Task.FromResult(0); + public override Task DeleteManyAsync(Expression> expression, bool isSoftDelete, CancellationToken token = default) + => throw new NotImplementedException(); + public override Task DeleteManyAsync(ISpecification specification, CancellationToken token = default) => Task.FromResult(0); + public override Task DeleteManyAsync(ISpecification specification, bool isSoftDelete, CancellationToken token = default) + => throw new NotImplementedException(); + public override Task UpdateAsync(TestSqlEntity entity, CancellationToken token = default) => Task.CompletedTask;