diff --git a/TinyHelpers.slnx b/TinyHelpers.slnx
index 34ca223..da10b11 100644
--- a/TinyHelpers.slnx
+++ b/TinyHelpers.slnx
@@ -13,6 +13,7 @@
+
diff --git a/src/TinyHelpers.EntityFrameworkCore/Extensions/ModelBuilderExtensions.cs b/src/TinyHelpers.EntityFrameworkCore/Extensions/ModelBuilderExtensions.cs
index 28133a0..81be186 100644
--- a/src/TinyHelpers.EntityFrameworkCore/Extensions/ModelBuilderExtensions.cs
+++ b/src/TinyHelpers.EntityFrameworkCore/Extensions/ModelBuilderExtensions.cs
@@ -4,22 +4,88 @@
namespace TinyHelpers.EntityFrameworkCore.Extensions;
+///
+/// Provides extension methods for to apply query filters and retrieve entity types.
+///
public static class ModelBuilderExtensions
{
+ ///
+ /// Applies a global query filter to all entity types assignable to .
+ ///
+ /// The base type or interface to match entity types against.
+ /// The to apply the filter to.
+ /// The filter expression to apply.
public static void ApplyQueryFilter(this ModelBuilder modelBuilder, Expression> expression)
+ {
+ foreach (var clrType in modelBuilder.GetEntityTypes())
+ {
+ var parameter = Expression.Parameter(clrType);
+ var body = ReplacingExpressionVisitor.Replace(expression.Parameters.Single(), parameter, expression.Body);
+ modelBuilder.Entity(clrType).HasQueryFilter(Expression.Lambda(body, parameter));
+ }
+ }
+
+ ///
+ /// Applies a global query filter to all entity types that have a property with the specified name and type.
+ ///
+ /// The type of the property to filter on.
+ /// The to apply the filter to.
+ /// The name of the property to filter on.
+ /// The value to compare the property against.
+ public static void ApplyQueryFilter(this ModelBuilder modelBuilder, string propertyName, TType value)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
- if (typeof(TEntity).IsAssignableFrom(entityType.ClrType))
+ var property = entityType.FindProperty(propertyName);
+ if (property?.ClrType == typeof(TType))
{
var parameter = Expression.Parameter(entityType.ClrType);
- var body = ReplacingExpressionVisitor.Replace(expression.Parameters.Single(), parameter, expression.Body);
- modelBuilder.Entity(entityType.ClrType).HasQueryFilter(Expression.Lambda(body, parameter));
+ var propertyAccess = Expression.Call(typeof(EF), nameof(EF.Property), [typeof(TType)], parameter, Expression.Constant(propertyName));
+ var filter = Expression.Lambda(Expression.Equal(propertyAccess, Expression.Constant(value, typeof(TType))), parameter);
+ modelBuilder.Entity(entityType.ClrType).HasQueryFilter(filter);
}
}
}
- public static void ApplyQueryFilter(this ModelBuilder modelBuilder, string propertyName, TType value)
+#if NET10_0_OR_GREATER
+ ///
+ /// Applies a named query filter to all entity types assignable to .
+ /// Named query filters can be selectively disabled at query time using IgnoreQueryFilters.
+ ///
+ /// The base type or interface to match entity types against.
+ /// The to apply the filter to.
+ /// The name to assign to the query filter.
+ /// The filter expression to apply.
+ ///
+ /// This feature requires .NET 10 or greater. Named query filters allow multiple filters per entity type
+ /// and selective disabling via IgnoreQueryFilters(["filterName"]).
+ /// See EF Core Query Filters for more information.
+ ///
+ public static void ApplyQueryFilter(this ModelBuilder modelBuilder, string filterName, Expression> expression)
+ {
+ foreach (var clrType in modelBuilder.GetEntityTypes())
+ {
+ var parameter = Expression.Parameter(clrType);
+ var body = ReplacingExpressionVisitor.Replace(expression.Parameters.Single(), parameter, expression.Body);
+ modelBuilder.Entity(clrType).HasQueryFilter(filterName, Expression.Lambda(body, parameter));
+ }
+ }
+
+ ///
+ /// Applies a named query filter to all entity types that have a property with the specified name and type.
+ /// Named query filters can be selectively disabled at query time using IgnoreQueryFilters.
+ ///
+ /// The type of the property to filter on.
+ /// The to apply the filter to.
+ /// The name to assign to the query filter.
+ /// The name of the property to filter on.
+ /// The value to compare the property against.
+ ///
+ /// This feature requires .NET 10 or greater. Named query filters allow multiple filters per entity type
+ /// and selective disabling via IgnoreQueryFilters(["filterName"]).
+ /// See EF Core Query Filters for more information.
+ ///
+ public static void ApplyQueryFilter(this ModelBuilder modelBuilder, string filterName, string propertyName, TType value)
{
foreach (var entityType in modelBuilder.Model.GetEntityTypes())
{
@@ -27,19 +93,33 @@ public static void ApplyQueryFilter(this ModelBuilder modelBuilder, strin
if (property?.ClrType == typeof(TType))
{
var parameter = Expression.Parameter(entityType.ClrType);
- var filter = Expression.Lambda(Expression.Equal(Expression.Property(parameter, propertyName), Expression.Constant(value)), parameter);
- modelBuilder.Entity(entityType.ClrType).HasQueryFilter(filter);
+ var propertyAccess = Expression.Call(typeof(EF), nameof(EF.Property), [typeof(TType)], parameter, Expression.Constant(propertyName));
+ var filter = Expression.Lambda(Expression.Equal(propertyAccess, Expression.Constant(value, typeof(TType))), parameter);
+ modelBuilder.Entity(entityType.ClrType).HasQueryFilter(filterName, filter);
}
}
}
+#endif
+ ///
+ /// Gets all entity types in the model that are assignable to .
+ ///
+ /// The base type or interface to match entity types against.
+ /// The to query.
+ /// An enumerable of CLR types that are assignable to .
public static IEnumerable GetEntityTypes(this ModelBuilder modelBuilder)
=> GetEntityTypes(modelBuilder, typeof(TType));
+ ///
+ /// Gets all entity types in the model that are assignable to the specified .
+ ///
+ /// The to query.
+ /// The base type or interface to match entity types against.
+ /// An enumerable of CLR types that are assignable to .
public static IEnumerable GetEntityTypes(this ModelBuilder modelBuilder, Type baseType)
{
var entityTypes = modelBuilder.Model.GetEntityTypes()
- .Where(t => baseType.IsAssignableFrom(t.ClrType))
+ .Where(t => t.ClrType.IsAssignableTo(baseType))
.ToList();
return entityTypes.Select(t => t.ClrType);
diff --git a/src/TinyHelpers.EntityFrameworkCore/README.md b/src/TinyHelpers.EntityFrameworkCore/README.md
index 828ac38..5f4f94c 100644
--- a/src/TinyHelpers.EntityFrameworkCore/README.md
+++ b/src/TinyHelpers.EntityFrameworkCore/README.md
@@ -82,6 +82,55 @@ protected override void OnModelCreating(ModelBuilder modelBuilder)
}
```
+It also works with interfaces:
+
+```csharp
+public interface ISoftDeletable
+{
+ bool IsDeleted { get; set; }
+}
+
+public class Person : ISoftDeletable
+{
+ public int Id { get; set; }
+ public bool IsDeleted { get; set; }
+}
+
+public class City : ISoftDeletable
+{
+ public int Id { get; set; }
+ public bool IsDeleted { get; set; }
+}
+
+// using TinyHelpers.EntityFrameworkCore.Extensions;
+protected override void OnModelCreating(ModelBuilder modelBuilder)
+{
+ // Apply a filter to all the entities that implement ISoftDeletable.
+ modelBuilder.ApplyQueryFilter(e => !e.IsDeleted);
+}
+```
+
+#### Named Query Filters (.NET 10+)
+
+Starting from .NET 10, the library supports [named query filters](https://learn.microsoft.com/ef/core/querying/filters), which allow attaching names to query filters and managing each one separately. This is useful when you need multiple filters per entity type and want to selectively disable specific filters at query time:
+
+```csharp
+// using TinyHelpers.EntityFrameworkCore.Extensions;
+protected override void OnModelCreating(ModelBuilder modelBuilder)
+{
+ // Apply named filters to all entities that implement the corresponding interfaces.
+ modelBuilder.ApplyQueryFilter("SoftDelete", e => !e.IsDeleted);
+ modelBuilder.ApplyQueryFilter("TenantFilter", e => e.TenantId == currentTenantId);
+}
+```
+
+Named filters can then be selectively disabled in specific queries:
+
+```csharp
+// Disable only the soft-delete filter, keeping the tenant filter active.
+var allItems = await context.People.IgnoreQueryFilters(["SoftDelete"]).ToListAsync();
+```
+
### Contributing
The project is constantly evolving. Contributions are welcome. Feel free to file issues and pull requests on the repo and we'll address them as we can.
diff --git a/tests/TinyHelpers.EntityFrameworkCore.Tests/Extensions/ModelBuilderExtensionsTests.cs b/tests/TinyHelpers.EntityFrameworkCore.Tests/Extensions/ModelBuilderExtensionsTests.cs
new file mode 100644
index 0000000..1c2ad43
--- /dev/null
+++ b/tests/TinyHelpers.EntityFrameworkCore.Tests/Extensions/ModelBuilderExtensionsTests.cs
@@ -0,0 +1,261 @@
+using Microsoft.EntityFrameworkCore;
+using TinyHelpers.EntityFrameworkCore.Extensions;
+
+namespace TinyHelpers.EntityFrameworkCore.Tests.Extensions;
+
+public interface ISoftDeletable
+{
+ bool IsDeleted { get; set; }
+}
+
+public interface ITenantEntity
+{
+ int TenantId { get; set; }
+}
+
+public abstract class DeletableEntity
+{
+ public bool IsDeleted { get; set; }
+}
+
+public class Person : DeletableEntity, ISoftDeletable, ITenantEntity
+{
+ public int Id { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+
+ public int TenantId { get; set; }
+}
+
+public class City : DeletableEntity, ISoftDeletable
+{
+ public int Id { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+}
+
+public class Country
+{
+ public int Id { get; set; }
+
+ public string Name { get; set; } = string.Empty;
+}
+
+public class ModelBuilderExtensionsTests
+{
+ [Fact]
+ public void ApplyQueryFilter_WithBaseClass_AppliesFilterToInheritingEntities()
+ {
+ var modelBuilder = CreateModelBuilder();
+
+ modelBuilder.ApplyQueryFilter(e => !e.IsDeleted);
+
+ var personFilters = modelBuilder.Model.FindEntityType(typeof(Person))!.GetDeclaredQueryFilters();
+ var cityFilters = modelBuilder.Model.FindEntityType(typeof(City))!.GetDeclaredQueryFilters();
+ var countryFilters = modelBuilder.Model.FindEntityType(typeof(Country))!.GetDeclaredQueryFilters();
+
+ Assert.Single(personFilters);
+ Assert.Single(cityFilters);
+ Assert.Empty(countryFilters);
+ }
+
+ [Fact]
+ public void ApplyQueryFilter_WithInterface_AppliesFilterToImplementingEntities()
+ {
+ var modelBuilder = CreateModelBuilder();
+
+ modelBuilder.ApplyQueryFilter(e => !e.IsDeleted);
+
+ var personFilters = modelBuilder.Model.FindEntityType(typeof(Person))!.GetDeclaredQueryFilters();
+ var cityFilters = modelBuilder.Model.FindEntityType(typeof(City))!.GetDeclaredQueryFilters();
+ var countryFilters = modelBuilder.Model.FindEntityType(typeof(Country))!.GetDeclaredQueryFilters();
+
+ Assert.Single(personFilters);
+ Assert.Single(cityFilters);
+ Assert.Empty(countryFilters);
+ }
+
+ [Fact]
+ public void ApplyQueryFilter_WithInterface_OnlyAppliesFilterToEntitiesThatImplementInterface()
+ {
+ var modelBuilder = CreateModelBuilder();
+
+ modelBuilder.ApplyQueryFilter(e => e.TenantId == 1);
+
+ var personFilters = modelBuilder.Model.FindEntityType(typeof(Person))!.GetDeclaredQueryFilters();
+ var cityFilters = modelBuilder.Model.FindEntityType(typeof(City))!.GetDeclaredQueryFilters();
+ var countryFilters = modelBuilder.Model.FindEntityType(typeof(Country))!.GetDeclaredQueryFilters();
+
+ Assert.Single(personFilters);
+ Assert.Empty(cityFilters);
+ Assert.Empty(countryFilters);
+ }
+
+ [Fact]
+ public void ApplyQueryFilter_ByPropertyName_AppliesFilterToEntitiesWithMatchingProperty()
+ {
+ var modelBuilder = CreateModelBuilder();
+
+ modelBuilder.ApplyQueryFilter("IsDeleted", false);
+
+ var personFilters = modelBuilder.Model.FindEntityType(typeof(Person))!.GetDeclaredQueryFilters();
+ var cityFilters = modelBuilder.Model.FindEntityType(typeof(City))!.GetDeclaredQueryFilters();
+ var countryFilters = modelBuilder.Model.FindEntityType(typeof(Country))!.GetDeclaredQueryFilters();
+
+ Assert.Single(personFilters);
+ Assert.Single(cityFilters);
+ Assert.Empty(countryFilters);
+ }
+
+ [Fact]
+ public void ApplyNamedQueryFilter_WithBaseClass_AppliesNamedFilterToInheritingEntities()
+ {
+ var modelBuilder = CreateModelBuilder();
+
+ modelBuilder.ApplyQueryFilter("SoftDelete", e => !e.IsDeleted);
+
+ var personFilters = modelBuilder.Model.FindEntityType(typeof(Person))!.GetDeclaredQueryFilters();
+ var cityFilters = modelBuilder.Model.FindEntityType(typeof(City))!.GetDeclaredQueryFilters();
+ var countryFilters = modelBuilder.Model.FindEntityType(typeof(Country))!.GetDeclaredQueryFilters();
+
+ Assert.Single(personFilters);
+ Assert.Single(cityFilters);
+ Assert.Empty(countryFilters);
+ }
+
+ [Fact]
+ public void ApplyNamedQueryFilter_WithInterface_AppliesNamedFilterToImplementingEntities()
+ {
+ var modelBuilder = CreateModelBuilder();
+
+ modelBuilder.ApplyQueryFilter("SoftDelete", e => !e.IsDeleted);
+
+ var personFilters = modelBuilder.Model.FindEntityType(typeof(Person))!.GetDeclaredQueryFilters();
+ var cityFilters = modelBuilder.Model.FindEntityType(typeof(City))!.GetDeclaredQueryFilters();
+ var countryFilters = modelBuilder.Model.FindEntityType(typeof(Country))!.GetDeclaredQueryFilters();
+
+ Assert.Single(personFilters);
+ Assert.Single(cityFilters);
+ Assert.Empty(countryFilters);
+ }
+
+ [Fact]
+ public void ApplyNamedQueryFilter_WithInterface_OnlyAppliesFilterToEntitiesThatImplementInterface()
+ {
+ var modelBuilder = CreateModelBuilder();
+
+ modelBuilder.ApplyQueryFilter("TenantFilter", e => e.TenantId == 1);
+
+ var personFilters = modelBuilder.Model.FindEntityType(typeof(Person))!.GetDeclaredQueryFilters();
+ var cityFilters = modelBuilder.Model.FindEntityType(typeof(City))!.GetDeclaredQueryFilters();
+ var countryFilters = modelBuilder.Model.FindEntityType(typeof(Country))!.GetDeclaredQueryFilters();
+
+ Assert.Single(personFilters);
+ Assert.Empty(cityFilters);
+ Assert.Empty(countryFilters);
+ }
+
+ [Fact]
+ public void ApplyNamedQueryFilter_MultipleFilters_AllFiltersAreApplied()
+ {
+ var modelBuilder = CreateModelBuilder();
+
+ modelBuilder.ApplyQueryFilter("SoftDelete", e => !e.IsDeleted);
+ modelBuilder.ApplyQueryFilter("TenantFilter", e => e.TenantId == 1);
+
+ var personFilters = modelBuilder.Model.FindEntityType(typeof(Person))!.GetDeclaredQueryFilters();
+ var cityFilters = modelBuilder.Model.FindEntityType(typeof(City))!.GetDeclaredQueryFilters();
+ var countryFilters = modelBuilder.Model.FindEntityType(typeof(Country))!.GetDeclaredQueryFilters();
+
+ Assert.Equal(2, personFilters.Count);
+ Assert.Single(cityFilters);
+ Assert.Empty(countryFilters);
+ }
+
+ [Fact]
+ public void ApplyNamedQueryFilter_ByPropertyName_AppliesNamedFilterToEntitiesWithMatchingProperty()
+ {
+ var modelBuilder = CreateModelBuilder();
+
+ modelBuilder.ApplyQueryFilter("SoftDelete", "IsDeleted", false);
+
+ var personFilters = modelBuilder.Model.FindEntityType(typeof(Person))!.GetDeclaredQueryFilters();
+ var cityFilters = modelBuilder.Model.FindEntityType(typeof(City))!.GetDeclaredQueryFilters();
+ var countryFilters = modelBuilder.Model.FindEntityType(typeof(Country))!.GetDeclaredQueryFilters();
+
+ Assert.Single(personFilters);
+ Assert.Single(cityFilters);
+ Assert.Empty(countryFilters);
+ }
+
+ [Fact]
+ public void ApplyNamedQueryFilter_FindByName_ReturnsCorrectFilter()
+ {
+ var modelBuilder = CreateModelBuilder();
+
+ modelBuilder.ApplyQueryFilter("SoftDelete", e => !e.IsDeleted);
+ modelBuilder.ApplyQueryFilter("TenantFilter", e => e.TenantId == 1);
+
+ var personType = modelBuilder.Model.FindEntityType(typeof(Person))!;
+
+ var softDeleteFilter = personType.FindDeclaredQueryFilter("SoftDelete");
+ var tenantFilter = personType.FindDeclaredQueryFilter("TenantFilter");
+ var nonExistentFilter = personType.FindDeclaredQueryFilter("NonExistent");
+
+ Assert.NotNull(softDeleteFilter);
+ Assert.NotNull(tenantFilter);
+ Assert.Null(nonExistentFilter);
+ }
+
+ [Fact]
+ public void GetEntityTypes_WithBaseClass_ReturnsInheritingTypes()
+ {
+ var modelBuilder = CreateModelBuilder();
+
+ var types = modelBuilder.GetEntityTypes().ToList();
+
+ Assert.Contains(typeof(Person), types);
+ Assert.Contains(typeof(City), types);
+ Assert.DoesNotContain(typeof(Country), types);
+ }
+
+ [Fact]
+ public void GetEntityTypes_WithInterface_ReturnsImplementingTypes()
+ {
+ var modelBuilder = CreateModelBuilder();
+
+ var types = modelBuilder.GetEntityTypes().ToList();
+
+ Assert.Contains(typeof(Person), types);
+ Assert.Contains(typeof(City), types);
+ Assert.DoesNotContain(typeof(Country), types);
+ }
+
+ private static ModelBuilder CreateModelBuilder()
+ {
+ var modelBuilder = new ModelBuilder();
+
+ modelBuilder.Entity(b =>
+ {
+ b.Property(p => p.Id);
+ b.Property(p => p.Name);
+ b.Property(p => p.IsDeleted);
+ b.Property(p => p.TenantId);
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.Property(c => c.Id);
+ b.Property(c => c.Name);
+ b.Property(c => c.IsDeleted);
+ });
+
+ modelBuilder.Entity(b =>
+ {
+ b.Property(c => c.Id);
+ b.Property(c => c.Name);
+ });
+
+ return modelBuilder;
+ }
+}
diff --git a/tests/TinyHelpers.EntityFrameworkCore.Tests/TinyHelpers.EntityFrameworkCore.Tests.csproj b/tests/TinyHelpers.EntityFrameworkCore.Tests/TinyHelpers.EntityFrameworkCore.Tests.csproj
new file mode 100644
index 0000000..d9a4996
--- /dev/null
+++ b/tests/TinyHelpers.EntityFrameworkCore.Tests/TinyHelpers.EntityFrameworkCore.Tests.csproj
@@ -0,0 +1,27 @@
+
+
+
+ net10.0
+ enable
+ enable
+ false
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
+
+
diff --git a/tests/TinyHelpers.EntityFrameworkCore.Tests/Usings.cs b/tests/TinyHelpers.EntityFrameworkCore.Tests/Usings.cs
new file mode 100644
index 0000000..c802f44
--- /dev/null
+++ b/tests/TinyHelpers.EntityFrameworkCore.Tests/Usings.cs
@@ -0,0 +1 @@
+global using Xunit;