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