diff --git a/JsonApiToolkit.Tests/Extensions/Filtering/NestedPropertyNavigatorTests.cs b/JsonApiToolkit.Tests/Extensions/Filtering/FilterCompositionTests.cs
similarity index 96%
rename from JsonApiToolkit.Tests/Extensions/Filtering/NestedPropertyNavigatorTests.cs
rename to JsonApiToolkit.Tests/Extensions/Filtering/FilterCompositionTests.cs
index bfad588..9a4822f 100644
--- a/JsonApiToolkit.Tests/Extensions/Filtering/NestedPropertyNavigatorTests.cs
+++ b/JsonApiToolkit.Tests/Extensions/Filtering/FilterCompositionTests.cs
@@ -5,7 +5,7 @@
namespace JsonApiToolkit.Tests.Extensions.Filtering;
-public class NestedPropertyNavigatorTests
+public class FilterCompositionTests
{
#region Test Data
diff --git a/JsonApiToolkit.Tests/Extensions/Filtering/FilterExpressionComposerEfCoreTests.cs b/JsonApiToolkit.Tests/Extensions/Filtering/FilterExpressionComposerEfCoreTests.cs
new file mode 100644
index 0000000..718aa35
--- /dev/null
+++ b/JsonApiToolkit.Tests/Extensions/Filtering/FilterExpressionComposerEfCoreTests.cs
@@ -0,0 +1,269 @@
+using JsonApiToolkit.Extensions.Querying;
+using JsonApiToolkit.Models.Querying.Filtering;
+using Microsoft.EntityFrameworkCore;
+
+namespace JsonApiToolkit.Tests.Extensions.Filtering;
+
+///
+/// Applies composed filter expressions through the EF Core InMemory provider to catch
+/// IQueryable translation issues that plain LINQ-to-Objects tests mask.
+///
+public class FilterExpressionComposerEfCoreTests
+{
+ private static readonly FilterExpressionComposer Composer = new();
+
+ private class Author
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ public int Age { get; set; }
+ public Address? Address { get; set; }
+ public List Posts { get; set; } = [];
+ }
+
+ private class Address
+ {
+ public int Id { get; set; }
+ public string City { get; set; } = "";
+ }
+
+ private class Post
+ {
+ public int Id { get; set; }
+ public string Title { get; set; } = "";
+ public List Comments { get; set; } = [];
+ }
+
+ private class Comment
+ {
+ public int Id { get; set; }
+ public string Text { get; set; } = "";
+ }
+
+ private class BlogContext(DbContextOptions options) : DbContext(options)
+ {
+ public DbSet Authors => Set();
+ }
+
+ private static BlogContext CreateSeededContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseInMemoryDatabase(Guid.NewGuid().ToString())
+ .Options;
+
+ var context = new BlogContext(options);
+ context.Authors.AddRange(
+ new Author
+ {
+ Id = 1,
+ Name = "Ada",
+ Age = 30,
+ Address = new Address { Id = 1, City = "Oslo" },
+ Posts =
+ [
+ new Post
+ {
+ Id = 1,
+ Title = "Hello World",
+ Comments = [new Comment { Id = 1, Text = "Nice" }],
+ },
+ new Post { Id = 2, Title = "Second Post" },
+ ],
+ },
+ new Author
+ {
+ Id = 2,
+ Name = "Bob",
+ Age = 40,
+ Address = new Address { Id = 2, City = "Bergen" },
+ Posts = [new Post { Id = 3, Title = "Draft" }],
+ },
+ new Author
+ {
+ Id = 3,
+ Name = "Cleo",
+ Age = 50,
+ Address = null,
+ Posts = [],
+ }
+ );
+ context.SaveChanges();
+ return context;
+ }
+
+ private static List Run(FilterGroup group)
+ {
+ using var context = CreateSeededContext();
+ var lambda = Composer.Compose(group);
+ Assert.NotNull(lambda);
+ return context.Authors.Where(lambda).OrderBy(a => a.Id).ToList();
+ }
+
+ private static FilterGroup Single(string field, FilterOperator op, string value)
+ {
+ return new FilterGroup
+ {
+ Filters =
+ [
+ new FilterParameter
+ {
+ Field = field,
+ Operator = op,
+ Value = value,
+ },
+ ],
+ };
+ }
+
+ [Theory]
+ [InlineData(FilterOperator.Eq, "40", new[] { 2 })]
+ [InlineData(FilterOperator.Ne, "40", new[] { 1, 3 })]
+ [InlineData(FilterOperator.Gt, "30", new[] { 2, 3 })]
+ [InlineData(FilterOperator.Ge, "40", new[] { 2, 3 })]
+ [InlineData(FilterOperator.Lt, "50", new[] { 1, 2 })]
+ [InlineData(FilterOperator.Le, "40", new[] { 1, 2 })]
+ [InlineData(FilterOperator.In, "30,50", new[] { 1, 3 })]
+ [InlineData(FilterOperator.Nin, "30,50", new[] { 2 })]
+ public void ScalarOperators_TranslateAndFilter(FilterOperator op, string value, int[] expected)
+ {
+ var result = Run(Single("age", op, value));
+
+ Assert.Equal(expected, result.Select(a => a.Id));
+ }
+
+ [Fact]
+ public void LikeOperator_TranslatesToContains()
+ {
+ var result = Run(Single("name", FilterOperator.Like, "%o%"));
+
+ Assert.Equal([2, 3], result.Select(a => a.Id));
+ }
+
+ [Fact]
+ public void DotPath_NavigatesReferenceAndGuardsNull()
+ {
+ // Cleo has a null Address; the composed null guard must not throw
+ var result = Run(Single("address.city", FilterOperator.Eq, "Oslo"));
+
+ Assert.Equal([1], result.Select(a => a.Id));
+ }
+
+ [Fact]
+ public void DotPath_NeTreatsNullChainAsNotEqual()
+ {
+ var result = Run(Single("address.city", FilterOperator.Ne, "Oslo"));
+
+ Assert.Equal([2, 3], result.Select(a => a.Id));
+ }
+
+ [Fact]
+ public void CollectionNavigation_TranslatesToAny()
+ {
+ var result = Run(Single("posts.title", FilterOperator.Eq, "Draft"));
+
+ Assert.Equal([2], result.Select(a => a.Id));
+ }
+
+ [Fact]
+ public void CollectionOfCollection_TranslatesToChainedAny()
+ {
+ var result = Run(Single("posts.comments.text", FilterOperator.Eq, "Nice"));
+
+ Assert.Equal([1], result.Select(a => a.Id));
+ }
+
+ [Fact]
+ public void OrGroup_CombinesConditions()
+ {
+ var group = new FilterGroup
+ {
+ LogicalOperator = LogicalOperator.Or,
+ Filters =
+ [
+ new FilterParameter
+ {
+ Field = "name",
+ Operator = FilterOperator.Eq,
+ Value = "Ada",
+ },
+ new FilterParameter
+ {
+ Field = "age",
+ Operator = FilterOperator.Eq,
+ Value = "50",
+ },
+ ],
+ };
+
+ var result = Run(group);
+
+ Assert.Equal([1, 3], result.Select(a => a.Id));
+ }
+
+ [Fact]
+ public void NotGroup_NegatesCondition()
+ {
+ var group = new FilterGroup
+ {
+ LogicalOperator = LogicalOperator.Not,
+ Filters =
+ [
+ new FilterParameter
+ {
+ Field = "name",
+ Operator = FilterOperator.Eq,
+ Value = "Ada",
+ },
+ ],
+ };
+
+ var result = Run(group);
+
+ Assert.Equal([2, 3], result.Select(a => a.Id));
+ }
+
+ [Fact]
+ public void NestedGroups_ComposeWithParent()
+ {
+ // age > 25 AND (name == Ada OR name == Bob)
+ var group = new FilterGroup
+ {
+ LogicalOperator = LogicalOperator.And,
+ Filters =
+ [
+ new FilterParameter
+ {
+ Field = "age",
+ Operator = FilterOperator.Gt,
+ Value = "25",
+ },
+ ],
+ Groups =
+ [
+ new FilterGroup
+ {
+ LogicalOperator = LogicalOperator.Or,
+ Filters =
+ [
+ new FilterParameter
+ {
+ Field = "name",
+ Operator = FilterOperator.Eq,
+ Value = "Ada",
+ },
+ new FilterParameter
+ {
+ Field = "name",
+ Operator = FilterOperator.Eq,
+ Value = "Bob",
+ },
+ ],
+ },
+ ],
+ };
+
+ var result = Run(group);
+
+ Assert.Equal([1, 2], result.Select(a => a.Id));
+ }
+}
diff --git a/JsonApiToolkit.Tests/Extensions/FilterExpressionBuilderTests.cs b/JsonApiToolkit.Tests/Extensions/Filtering/FilterExpressionComposerTests.cs
similarity index 58%
rename from JsonApiToolkit.Tests/Extensions/FilterExpressionBuilderTests.cs
rename to JsonApiToolkit.Tests/Extensions/Filtering/FilterExpressionComposerTests.cs
index add10b3..8de91e2 100644
--- a/JsonApiToolkit.Tests/Extensions/FilterExpressionBuilderTests.cs
+++ b/JsonApiToolkit.Tests/Extensions/Filtering/FilterExpressionComposerTests.cs
@@ -1,392 +1,379 @@
-using System.Linq.Expressions;
-using JsonApiToolkit.Extensions.Querying;
-using JsonApiToolkit.Models.Querying.Filtering;
-using JsonApiToolkit.Tests.Models;
-
-namespace JsonApiToolkit.Tests.Extensions;
-
-public class FilterExpressionBuilderTests
-{
- private IQueryable GetTestData()
- {
- return new List
- {
- new TestEntity
- {
- Id = 1,
- Name = "Alpha",
- IsActive = true,
- Status = TestStatus.Published,
- },
- new TestEntity
- {
- Id = 2,
- Name = "Beta",
- IsActive = false,
- Status = TestStatus.Draft,
- },
- new TestEntity
- {
- Id = 3,
- Name = "Gamma",
- IsActive = true,
- Status = TestStatus.Archived,
- },
- new TestEntity
- {
- Id = 4,
- Name = "Delta",
- IsActive = false,
- Status = TestStatus.Published,
- },
- }.AsQueryable();
- }
-
- [Fact]
- public void BuildFilterExpression_WithNotOperator_AppliesCorrectLogic()
- {
- var filterGroup = new FilterGroup
- {
- LogicalOperator = LogicalOperator.Not,
- Filters = new List
- {
- new FilterParameter
- {
- Field = "IsActive",
- Operator = FilterOperator.Eq,
- Value = "true",
- },
- },
- };
- var parameter = Expression.Parameter(typeof(TestEntity), "x");
-
- var expression = FilterExpressionBuilder.BuildFilterExpression(
- filterGroup,
- parameter
- );
-
- Assert.NotNull(expression);
- var compiledExpression = Expression
- .Lambda>(expression, parameter)
- .Compile();
-
- var testData = GetTestData();
- var result = testData.Where(compiledExpression).ToList();
-
- Assert.Equal(2, result.Count);
- Assert.All(result, entity => Assert.False(entity.IsActive));
- }
-
- [Fact]
- public void BuildFilterExpression_WithMultipleFiltersAndNotOperator_AppliesCorrectLogic()
- {
- var filterGroup = new FilterGroup
- {
- LogicalOperator = LogicalOperator.Not,
- Filters = new List
- {
- new FilterParameter
- {
- Field = "IsActive",
- Operator = FilterOperator.Eq,
- Value = "true",
- },
- new FilterParameter
- {
- Field = "Id",
- Operator = FilterOperator.Gt,
- Value = "2",
- },
- },
- };
- var parameter = Expression.Parameter(typeof(TestEntity), "x");
-
- var expression = FilterExpressionBuilder.BuildFilterExpression(
- filterGroup,
- parameter
- );
-
- Assert.NotNull(expression);
- var compiledExpression = Expression
- .Lambda>(expression, parameter)
- .Compile();
-
- var testData = GetTestData();
- var result = testData.Where(compiledExpression).ToList();
-
- Assert.Equal(3, result.Count);
- Assert.DoesNotContain(result, e => e.Id == 3);
- }
-
- [Fact]
- public void BuildFilterExpression_WithOrOperator_AppliesCorrectLogic()
- {
- var filterGroup = new FilterGroup
- {
- LogicalOperator = LogicalOperator.Or,
- Filters = new List
- {
- new FilterParameter
- {
- Field = "Name",
- Operator = FilterOperator.Eq,
- Value = "Alpha",
- },
- new FilterParameter
- {
- Field = "Name",
- Operator = FilterOperator.Eq,
- Value = "Gamma",
- },
- },
- };
- var parameter = Expression.Parameter(typeof(TestEntity), "x");
-
- var expression = FilterExpressionBuilder.BuildFilterExpression(
- filterGroup,
- parameter
- );
-
- Assert.NotNull(expression);
- var compiledExpression = Expression
- .Lambda>(expression, parameter)
- .Compile();
-
- var testData = GetTestData();
- var result = testData.Where(compiledExpression).ToList();
-
- Assert.Equal(2, result.Count);
- Assert.Contains(result, e => e.Name == "Alpha");
- Assert.Contains(result, e => e.Name == "Gamma");
- }
-
- [Fact]
- public void BuildFilterExpression_WithAndOperator_AppliesCorrectLogic()
- {
- var filterGroup = new FilterGroup
- {
- LogicalOperator = LogicalOperator.And,
- Filters = new List
- {
- new FilterParameter
- {
- Field = "IsActive",
- Operator = FilterOperator.Eq,
- Value = "true",
- },
- new FilterParameter
- {
- Field = "Id",
- Operator = FilterOperator.Gt,
- Value = "1",
- },
- },
- };
- var parameter = Expression.Parameter(typeof(TestEntity), "x");
-
- var expression = FilterExpressionBuilder.BuildFilterExpression(
- filterGroup,
- parameter
- );
-
- Assert.NotNull(expression);
- var compiledExpression = Expression
- .Lambda>(expression, parameter)
- .Compile();
-
- var testData = GetTestData();
- var result = testData.Where(compiledExpression).ToList();
-
- Assert.Single(result);
- Assert.Equal(3, result[0].Id);
- Assert.Equal("Gamma", result[0].Name);
- }
-
- [Fact]
- public void BuildFilterExpression_WithEmptyGroup_ReturnsNull()
- {
- var filterGroup = new FilterGroup();
- var parameter = Expression.Parameter(typeof(TestEntity), "x");
-
- var expression = FilterExpressionBuilder.BuildFilterExpression(
- filterGroup,
- parameter
- );
-
- Assert.Null(expression);
- }
-
- [Fact]
- public void BuildFilterExpression_WithInvalidProperty_IgnoresFilter()
- {
- var filterGroup = new FilterGroup
- {
- Filters = new List
- {
- new FilterParameter
- {
- Field = "NonExistentProperty",
- Operator = FilterOperator.Eq,
- Value = "test",
- },
- new FilterParameter
- {
- Field = "Name",
- Operator = FilterOperator.Eq,
- Value = "Alpha",
- },
- },
- };
- var parameter = Expression.Parameter(typeof(TestEntity), "x");
-
- var expression = FilterExpressionBuilder.BuildFilterExpression(
- filterGroup,
- parameter
- );
-
- Assert.NotNull(expression);
- var compiledExpression = Expression
- .Lambda>(expression, parameter)
- .Compile();
-
- var testData = GetTestData();
- var result = testData.Where(compiledExpression).ToList();
-
- Assert.Single(result);
- Assert.Equal("Alpha", result[0].Name);
- }
-
- [Fact]
- public void BuildFilterExpression_WithNestedProperty_FiltersCorrectly()
- {
- var testData = new List
- {
- new TestEntity
- {
- Id = 1,
- Name = "Alpha",
- RelatedEntity = new TestRelatedEntity
- {
- Id = 10,
- Name = "Related1",
- NestedEntity = new TestNestedEntity { Id = 100, Value = "NestedValue1" },
- },
- },
- new TestEntity
- {
- Id = 2,
- Name = "Beta",
- RelatedEntity = new TestRelatedEntity
- {
- Id = 20,
- Name = "Related2",
- NestedEntity = new TestNestedEntity { Id = 200, Value = "NestedValue2" },
- },
- },
- }.AsQueryable();
-
- var filterGroup = new FilterGroup
- {
- Filters = new List
- {
- new FilterParameter
- {
- Field = "RelatedEntity.NestedEntity.Value",
- Operator = FilterOperator.Eq,
- Value = "NestedValue1",
- },
- },
- };
- var parameter = Expression.Parameter(typeof(TestEntity), "x");
-
- var expression = FilterExpressionBuilder.BuildFilterExpression(
- filterGroup,
- parameter
- );
-
- Assert.NotNull(expression);
- var compiledExpression = Expression
- .Lambda>(expression, parameter)
- .Compile();
-
- var result = testData.Where(compiledExpression).ToList();
-
- Assert.Single(result);
- Assert.Equal("Alpha", result[0].Name);
- }
-
- [Fact]
- public void BuildFilterExpression_WithInvalidNestedProperty_IgnoresFilter()
- {
- var filterGroup = new FilterGroup
- {
- Filters = new List
- {
- new FilterParameter
- {
- Field = "RelatedEntity.NonExistent.Property",
- Operator = FilterOperator.Eq,
- Value = "test",
- },
- },
- };
- var parameter = Expression.Parameter(typeof(TestEntity), "x");
-
- var expression = FilterExpressionBuilder.BuildFilterExpression(
- filterGroup,
- parameter
- );
-
- Assert.Null(expression);
- }
-
- [Fact]
- public void BuildFilterExpression_WithNullNestedProperty_HandlesGracefully()
- {
- var testData = new List
- {
- new TestEntity
- {
- Id = 1,
- Name = "Alpha",
- RelatedEntity = null,
- },
- new TestEntity
- {
- Id = 2,
- Name = "Beta",
- RelatedEntity = new TestRelatedEntity { Id = 20, Name = "Related2" },
- },
- }.AsQueryable();
-
- var filterGroup = new FilterGroup
- {
- Filters = new List
- {
- new FilterParameter
- {
- Field = "RelatedEntity.Name",
- Operator = FilterOperator.Eq,
- Value = "Related2",
- },
- },
- };
- var parameter = Expression.Parameter(typeof(TestEntity), "x");
-
- var expression = FilterExpressionBuilder.BuildFilterExpression(
- filterGroup,
- parameter
- );
-
- Assert.NotNull(expression);
- var compiledExpression = Expression
- .Lambda>(expression, parameter)
- .Compile();
-
- // Should safely handle null references and only return matching entities
- var result = testData.Where(compiledExpression).ToList();
-
- Assert.Single(result);
- Assert.Equal("Beta", result[0].Name);
- }
-}
+using System.Linq.Expressions;
+using JsonApiToolkit.Extensions.Querying;
+using JsonApiToolkit.Models.Querying.Filtering;
+using JsonApiToolkit.Tests.Models;
+
+namespace JsonApiToolkit.Tests.Extensions.Filtering;
+
+public class FilterExpressionComposerTests
+{
+ private static readonly FilterExpressionComposer Composer = new();
+
+ private IQueryable GetTestData()
+ {
+ return new List
+ {
+ new TestEntity
+ {
+ Id = 1,
+ Name = "Alpha",
+ IsActive = true,
+ Status = TestStatus.Published,
+ },
+ new TestEntity
+ {
+ Id = 2,
+ Name = "Beta",
+ IsActive = false,
+ Status = TestStatus.Draft,
+ },
+ new TestEntity
+ {
+ Id = 3,
+ Name = "Gamma",
+ IsActive = true,
+ Status = TestStatus.Archived,
+ },
+ new TestEntity
+ {
+ Id = 4,
+ Name = "Delta",
+ IsActive = false,
+ Status = TestStatus.Published,
+ },
+ }.AsQueryable();
+ }
+
+ [Fact]
+ public void Compose_WithNotOperator_AppliesCorrectLogic()
+ {
+ var filterGroup = new FilterGroup
+ {
+ LogicalOperator = LogicalOperator.Not,
+ Filters = new List
+ {
+ new FilterParameter
+ {
+ Field = "IsActive",
+ Operator = FilterOperator.Eq,
+ Value = "true",
+ },
+ },
+ };
+
+ var lambda = Composer.Compose(filterGroup);
+
+ Assert.NotNull(lambda);
+ var result = GetTestData().Where(lambda.Compile()).ToList();
+
+ Assert.Equal(2, result.Count);
+ Assert.All(result, entity => Assert.False(entity.IsActive));
+ }
+
+ [Fact]
+ public void Compose_WithMultipleFiltersAndNotOperator_AppliesCorrectLogic()
+ {
+ var filterGroup = new FilterGroup
+ {
+ LogicalOperator = LogicalOperator.Not,
+ Filters = new List
+ {
+ new FilterParameter
+ {
+ Field = "IsActive",
+ Operator = FilterOperator.Eq,
+ Value = "true",
+ },
+ new FilterParameter
+ {
+ Field = "Id",
+ Operator = FilterOperator.Gt,
+ Value = "2",
+ },
+ },
+ };
+
+ var lambda = Composer.Compose(filterGroup);
+
+ Assert.NotNull(lambda);
+ var result = GetTestData().Where(lambda.Compile()).ToList();
+
+ Assert.Equal(3, result.Count);
+ Assert.DoesNotContain(result, e => e.Id == 3);
+ }
+
+ [Fact]
+ public void Compose_WithOrOperator_AppliesCorrectLogic()
+ {
+ var filterGroup = new FilterGroup
+ {
+ LogicalOperator = LogicalOperator.Or,
+ Filters = new List
+ {
+ new FilterParameter
+ {
+ Field = "Name",
+ Operator = FilterOperator.Eq,
+ Value = "Alpha",
+ },
+ new FilterParameter
+ {
+ Field = "Name",
+ Operator = FilterOperator.Eq,
+ Value = "Gamma",
+ },
+ },
+ };
+
+ var lambda = Composer.Compose(filterGroup);
+
+ Assert.NotNull(lambda);
+ var result = GetTestData().Where(lambda.Compile()).ToList();
+
+ Assert.Equal(2, result.Count);
+ Assert.Contains(result, e => e.Name == "Alpha");
+ Assert.Contains(result, e => e.Name == "Gamma");
+ }
+
+ [Fact]
+ public void Compose_WithAndOperator_AppliesCorrectLogic()
+ {
+ var filterGroup = new FilterGroup
+ {
+ LogicalOperator = LogicalOperator.And,
+ Filters = new List
+ {
+ new FilterParameter
+ {
+ Field = "IsActive",
+ Operator = FilterOperator.Eq,
+ Value = "true",
+ },
+ new FilterParameter
+ {
+ Field = "Id",
+ Operator = FilterOperator.Gt,
+ Value = "1",
+ },
+ },
+ };
+
+ var lambda = Composer.Compose(filterGroup);
+
+ Assert.NotNull(lambda);
+ var result = GetTestData().Where(lambda.Compile()).ToList();
+
+ Assert.Single(result);
+ Assert.Equal(3, result[0].Id);
+ Assert.Equal("Gamma", result[0].Name);
+ }
+
+ [Fact]
+ public void Compose_WithEmptyGroup_ReturnsNull()
+ {
+ var lambda = Composer.Compose(new FilterGroup());
+
+ Assert.Null(lambda);
+ }
+
+ [Fact]
+ public void Compose_WithInvalidProperty_IgnoresFilter()
+ {
+ var filterGroup = new FilterGroup
+ {
+ Filters = new List
+ {
+ new FilterParameter
+ {
+ Field = "NonExistentProperty",
+ Operator = FilterOperator.Eq,
+ Value = "test",
+ },
+ new FilterParameter
+ {
+ Field = "Name",
+ Operator = FilterOperator.Eq,
+ Value = "Alpha",
+ },
+ },
+ };
+
+ var lambda = Composer.Compose(filterGroup);
+
+ Assert.NotNull(lambda);
+ var result = GetTestData().Where(lambda.Compile()).ToList();
+
+ Assert.Single(result);
+ Assert.Equal("Alpha", result[0].Name);
+ }
+
+ [Fact]
+ public void Compose_WithNestedProperty_FiltersCorrectly()
+ {
+ var testData = new List
+ {
+ new TestEntity
+ {
+ Id = 1,
+ Name = "Alpha",
+ RelatedEntity = new TestRelatedEntity
+ {
+ Id = 10,
+ Name = "Related1",
+ NestedEntity = new TestNestedEntity { Id = 100, Value = "NestedValue1" },
+ },
+ },
+ new TestEntity
+ {
+ Id = 2,
+ Name = "Beta",
+ RelatedEntity = new TestRelatedEntity
+ {
+ Id = 20,
+ Name = "Related2",
+ NestedEntity = new TestNestedEntity { Id = 200, Value = "NestedValue2" },
+ },
+ },
+ }.AsQueryable();
+
+ var filterGroup = new FilterGroup
+ {
+ Filters = new List
+ {
+ new FilterParameter
+ {
+ Field = "RelatedEntity.NestedEntity.Value",
+ Operator = FilterOperator.Eq,
+ Value = "NestedValue1",
+ },
+ },
+ };
+
+ var lambda = Composer.Compose(filterGroup);
+
+ Assert.NotNull(lambda);
+ var result = testData.Where(lambda.Compile()).ToList();
+
+ Assert.Single(result);
+ Assert.Equal("Alpha", result[0].Name);
+ }
+
+ [Fact]
+ public void Compose_WithInvalidNestedProperty_IgnoresFilter()
+ {
+ var filterGroup = new FilterGroup
+ {
+ Filters = new List
+ {
+ new FilterParameter
+ {
+ Field = "RelatedEntity.NonExistent.Property",
+ Operator = FilterOperator.Eq,
+ Value = "test",
+ },
+ },
+ };
+
+ var lambda = Composer.Compose(filterGroup);
+
+ Assert.Null(lambda);
+ }
+
+ [Fact]
+ public void Compose_WithNullNestedProperty_HandlesGracefully()
+ {
+ var testData = new List
+ {
+ new TestEntity
+ {
+ Id = 1,
+ Name = "Alpha",
+ RelatedEntity = null,
+ },
+ new TestEntity
+ {
+ Id = 2,
+ Name = "Beta",
+ RelatedEntity = new TestRelatedEntity { Id = 20, Name = "Related2" },
+ },
+ }.AsQueryable();
+
+ var filterGroup = new FilterGroup
+ {
+ Filters = new List
+ {
+ new FilterParameter
+ {
+ Field = "RelatedEntity.Name",
+ Operator = FilterOperator.Eq,
+ Value = "Related2",
+ },
+ },
+ };
+
+ var lambda = Composer.Compose(filterGroup);
+
+ Assert.NotNull(lambda);
+
+ // Should safely handle null references and only return matching entities
+ var result = testData.Where(lambda.Compile()).ToList();
+
+ Assert.Single(result);
+ Assert.Equal("Beta", result[0].Name);
+ }
+
+ [Fact]
+ public void Compose_NonGenericOverload_ProducesSamePredicate()
+ {
+ var filterGroup = new FilterGroup
+ {
+ Filters = new List
+ {
+ new FilterParameter
+ {
+ Field = "Name",
+ Operator = FilterOperator.Eq,
+ Value = "Alpha",
+ },
+ },
+ };
+
+ var lambda = Composer.Compose(filterGroup, typeof(TestEntity));
+
+ Assert.NotNull(lambda);
+ var typed = Assert.IsAssignableFrom>>(lambda);
+ var result = GetTestData().Where(typed.Compile()).ToList();
+
+ Assert.Single(result);
+ Assert.Equal("Alpha", result[0].Name);
+ }
+
+ [Fact]
+ public void Compose_WithCustomPropertyResolver_UsesResolver()
+ {
+ var composer = new FilterExpressionComposer(
+ propertyResolver: (type, name) => type.GetProperty(name == "alias" ? "Name" : name)
+ );
+
+ var filterGroup = new FilterGroup
+ {
+ Filters = new List
+ {
+ new FilterParameter
+ {
+ Field = "alias",
+ Operator = FilterOperator.Eq,
+ Value = "Alpha",
+ },
+ },
+ };
+
+ var lambda = composer.Compose(filterGroup);
+
+ Assert.NotNull(lambda);
+ var result = GetTestData().Where(lambda.Compile()).ToList();
+
+ Assert.Single(result);
+ Assert.Equal("Alpha", result[0].Name);
+ }
+}
diff --git a/JsonApiToolkit.Tests/Extensions/Filtering/RecursionDepthGuardTests.cs b/JsonApiToolkit.Tests/Extensions/Filtering/RecursionDepthGuardTests.cs
new file mode 100644
index 0000000..3c61341
--- /dev/null
+++ b/JsonApiToolkit.Tests/Extensions/Filtering/RecursionDepthGuardTests.cs
@@ -0,0 +1,131 @@
+using JsonApiToolkit.Extensions.Querying;
+using JsonApiToolkit.Models.Errors;
+using JsonApiToolkit.Models.Querying.Filtering;
+
+namespace JsonApiToolkit.Tests.Extensions.Filtering;
+
+public class RecursionDepthGuardTests
+{
+ private static readonly FilterExpressionComposer Composer = new();
+
+ private static FilterGroup GroupFor(string field, string value = "1")
+ {
+ return new FilterGroup
+ {
+ Filters = new List
+ {
+ new FilterParameter
+ {
+ Field = field,
+ Value = value,
+ Operator = FilterOperator.Eq,
+ },
+ },
+ };
+ }
+
+ // Test entity with nested collections to trigger recursion
+ private class Level0
+ {
+ public int Id { get; set; }
+ public List Items { get; set; } = [];
+ }
+
+ private class Level1
+ {
+ public int Id { get; set; }
+ public List Items { get; set; } = [];
+ }
+
+ private class Level2
+ {
+ public int Id { get; set; }
+ public List Items { get; set; } = [];
+ }
+
+ private class Level3
+ {
+ public int Id { get; set; }
+ public List Items { get; set; } = [];
+ }
+
+ private class Level4
+ {
+ public int Id { get; set; }
+ public List Items { get; set; } = [];
+ }
+
+ private class Level5
+ {
+ public int Id { get; set; }
+ public List Items { get; set; } = [];
+ }
+
+ private class Level6
+ {
+ public int Id { get; set; }
+ public string Name { get; set; } = "";
+ }
+
+ [Fact]
+ public void Compose_WithShallowNesting_Succeeds()
+ {
+ // 2 levels of collection nesting should work fine
+ var lambda = Composer.Compose(GroupFor("items.items.id"));
+
+ Assert.NotNull(lambda);
+ }
+
+ [Fact]
+ public void Compose_WithDeeplyNestedCollections_ThrowsBadRequest()
+ {
+ // 6 levels of collection nesting should exceed the limit (MaxRecursionDepth = 5)
+ var group = GroupFor("items.items.items.items.items.items.name", "test");
+
+ var exception = Assert.Throws(() =>
+ Composer.Compose(group)
+ );
+
+ Assert.Contains("recursion depth", exception.Message.ToLower());
+ Assert.Contains("5", exception.Message); // MaxRecursionDepth
+ Assert.Equal(JsonApiErrorCodes.QueryTooComplex, exception.Code);
+ }
+
+ [Fact]
+ public void Compose_AtExactLimit_Succeeds()
+ {
+ // 5 levels should be exactly at the limit and work
+ var lambda = Composer.Compose(GroupFor("items.items.items.items.items.id"));
+
+ Assert.NotNull(lambda);
+ }
+
+ [Fact]
+ public void Compose_JustOverLimit_ThrowsBadRequest()
+ {
+ // 6 levels should be just over the limit
+ var group = GroupFor("items.items.items.items.items.items.id");
+
+ var exception = Assert.Throws(() =>
+ Composer.Compose(group)
+ );
+
+ Assert.Contains("recursion depth", exception.Message.ToLower());
+ }
+
+ [Fact]
+ public void Compose_ErrorMetadata_ContainsFieldInfo()
+ {
+ var group = GroupFor("items.items.items.items.items.items.name", "test");
+
+ var exception = Assert.Throws(() =>
+ Composer.Compose(group)
+ );
+
+ Assert.NotNull(exception.ErrorSource);
+ Assert.StartsWith("filter[", exception.ErrorSource.Parameter);
+ Assert.NotNull(exception.Meta);
+ Assert.Equal(5, exception.Meta["maxDepth"]);
+ Assert.True((int)exception.Meta["actualDepth"] > 5); // Should exceed the limit
+ }
+}
diff --git a/JsonApiToolkit.Tests/Extensions/RecursionDepthGuardTests.cs b/JsonApiToolkit.Tests/Extensions/RecursionDepthGuardTests.cs
deleted file mode 100644
index a9ce4e3..0000000
--- a/JsonApiToolkit.Tests/Extensions/RecursionDepthGuardTests.cs
+++ /dev/null
@@ -1,155 +0,0 @@
-using System.Linq.Expressions;
-using JsonApiToolkit.Extensions.Querying;
-using JsonApiToolkit.Models.Errors;
-using JsonApiToolkit.Models.Querying.Filtering;
-
-namespace JsonApiToolkit.Tests.Extensions;
-
-public class RecursionDepthGuardTests
-{
- // Test entity with nested collections to trigger recursion
- private class Level0
- {
- public int Id { get; set; }
- public List Items { get; set; } = [];
- }
-
- private class Level1
- {
- public int Id { get; set; }
- public List Items { get; set; } = [];
- }
-
- private class Level2
- {
- public int Id { get; set; }
- public List Items { get; set; } = [];
- }
-
- private class Level3
- {
- public int Id { get; set; }
- public List Items { get; set; } = [];
- }
-
- private class Level4
- {
- public int Id { get; set; }
- public List Items { get; set; } = [];
- }
-
- private class Level5
- {
- public int Id { get; set; }
- public List Items { get; set; } = [];
- }
-
- private class Level6
- {
- public int Id { get; set; }
- public string Name { get; set; } = "";
- }
-
- [Fact]
- public void BuildFilterExpression_WithShallowNesting_Succeeds()
- {
- // 2 levels of collection nesting should work fine
- var filter = new FilterParameter
- {
- Field = "items.items.id",
- Value = "1",
- Operator = FilterOperator.Eq,
- };
-
- var parameter = Expression.Parameter(typeof(Level0), "x");
-
- // This should not throw
- var expression = PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter);
-
- Assert.NotNull(expression);
- }
-
- [Fact]
- public void BuildFilterExpression_WithDeeplyNestedCollections_ThrowsBadRequest()
- {
- // 7 levels of collection nesting should exceed the limit (MaxRecursionDepth = 5)
- var filter = new FilterParameter
- {
- Field = "items.items.items.items.items.items.name",
- Value = "test",
- Operator = FilterOperator.Eq,
- };
-
- var parameter = Expression.Parameter(typeof(Level0), "x");
-
- var exception = Assert.Throws(() =>
- PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter)
- );
-
- Assert.Contains("recursion depth", exception.Message.ToLower());
- Assert.Contains("5", exception.Message); // MaxRecursionDepth
- Assert.Equal(JsonApiErrorCodes.QueryTooComplex, exception.Code);
- }
-
- [Fact]
- public void BuildFilterExpression_AtExactLimit_Succeeds()
- {
- // 5 levels should be exactly at the limit and work
- var filter = new FilterParameter
- {
- Field = "items.items.items.items.items.id",
- Value = "1",
- Operator = FilterOperator.Eq,
- };
-
- var parameter = Expression.Parameter(typeof(Level0), "x");
-
- // This should not throw - exactly at limit
- var expression = PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter);
-
- Assert.NotNull(expression);
- }
-
- [Fact]
- public void BuildFilterExpression_JustOverLimit_ThrowsBadRequest()
- {
- // 6 levels should be just over the limit
- var filter = new FilterParameter
- {
- Field = "items.items.items.items.items.items.id",
- Value = "1",
- Operator = FilterOperator.Eq,
- };
-
- var parameter = Expression.Parameter(typeof(Level0), "x");
-
- var exception = Assert.Throws(() =>
- PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter)
- );
-
- Assert.Contains("recursion depth", exception.Message.ToLower());
- }
-
- [Fact]
- public void BuildFilterExpression_ErrorMetadata_ContainsFieldInfo()
- {
- var filter = new FilterParameter
- {
- Field = "items.items.items.items.items.items.name",
- Value = "test",
- Operator = FilterOperator.Eq,
- };
-
- var parameter = Expression.Parameter(typeof(Level0), "x");
-
- var exception = Assert.Throws(() =>
- PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter)
- );
-
- Assert.NotNull(exception.ErrorSource);
- Assert.StartsWith("filter[", exception.ErrorSource.Parameter);
- Assert.NotNull(exception.Meta);
- Assert.Equal(5, exception.Meta["maxDepth"]);
- Assert.True((int)exception.Meta["actualDepth"] > 5); // Should exceed the limit
- }
-}
diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/CollectionFilterBuilder.cs b/JsonApiToolkit/Extensions/Querying/Filtering/CollectionFilterBuilder.cs
deleted file mode 100644
index ff03ad0..0000000
--- a/JsonApiToolkit/Extensions/Querying/Filtering/CollectionFilterBuilder.cs
+++ /dev/null
@@ -1,216 +0,0 @@
-using System.Linq.Expressions;
-using System.Reflection;
-using JsonApiToolkit.Helpers;
-using JsonApiToolkit.Models.Errors;
-using JsonApiToolkit.Models.Querying.Filtering;
-using Microsoft.Extensions.Logging;
-
-namespace JsonApiToolkit.Extensions.Querying;
-
-///
-/// Builds LINQ expressions for collection navigations: Any(item => predicate)
-/// for nested paths (e.g. tags.name) and Contains(value) for primitive
-/// collections used as filter targets (e.g. filter[tags][in]=value).
-///
-internal static class CollectionFilterBuilder
-{
- ///
- /// Builds a filter expression for collection navigation using Any().
- /// e.g., collection.Any(item => item.Property == value)
- ///
- internal static Expression? BuildCollectionFilterExpression(
- Expression collectionAccess,
- Type elementType,
- string[] remainingParts,
- FilterParameter filter,
- ILogger? logger,
- int depth
- )
- {
- if (depth > PropertyNavigator.MaxRecursionDepth)
- {
- throw new JsonApiBadRequestException(
- $"Filter path recursion depth exceeds maximum of {PropertyNavigator.MaxRecursionDepth}. "
- + "Simplify the filter expression or reduce collection nesting.",
- JsonApiErrorCodes.QueryTooComplex,
- new ErrorSource { Parameter = $"filter[{filter.Field}]" },
- new Dictionary
- {
- ["field"] = filter.Field,
- ["maxDepth"] = PropertyNavigator.MaxRecursionDepth,
- ["actualDepth"] = depth,
- }
- );
- }
- // Create parameter for the lambda: item =>
- ParameterExpression itemParam = Expression.Parameter(elementType, "item");
-
- // Build the inner filter expression for the remaining path
- FilterParameter innerFilter = new FilterParameter
- {
- Field = string.Join(".", remainingParts),
- Value = filter.Value,
- Operator = filter.Operator,
- IsIncludeFilter = filter.IsIncludeFilter,
- };
-
- Expression? innerExpression;
- if (remainingParts.Length == 1)
- {
- // Simple property access on the element
- PropertyInfo? prop = QueryHelpers.GetPropertyByJsonName(elementType, remainingParts[0]);
- if (prop == null)
- {
- logger?.LogWarning(
- "Property '{PropertyName}' not found on {Type}",
- remainingParts[0],
- elementType.Name
- );
- return null;
- }
-
- Expression propertyAccess = Expression.Property(itemParam, prop);
- innerExpression = PropertyFilterBuilder.BuildPropertyFilterExpression(
- propertyAccess,
- filter,
- logger
- );
- }
- else
- {
- // Nested property access - recursively build
- innerExpression = PropertyNavigator.BuildSafeNestedFilterExpression(
- itemParam,
- innerFilter,
- logger,
- depth
- );
- }
-
- if (innerExpression == null)
- return null;
-
- // Create lambda: item => innerExpression
- LambdaExpression predicate = Expression.Lambda(innerExpression, itemParam);
-
- // Get the Enumerable.Any(IEnumerable, Func) method
- MethodInfo anyMethod = ReflectionMethodCache.GetEnumerableAnyWithPredicate(elementType);
-
- // Build: collection.Any(item => predicate)
- return Expression.Call(anyMethod, collectionAccess, predicate);
- }
-
- ///
- /// Builds a filter expression when the property itself is a collection.
- /// e.g., entity.Tags.Contains("value") for filter[tags][in]=value
- ///
- internal static Expression? BuildCollectionPropertyFilterExpression(
- Expression collectionAccess,
- Type elementType,
- FilterParameter filter,
- ILogger? logger
- )
- {
- // For In/Eq operators: check if collection contains the value
- // e.g., tags.Contains("important")
- if (
- filter.Operator == FilterOperator.In
- || filter.Operator == FilterOperator.Eq
- || filter.Operator == FilterOperator.Like
- )
- {
- // For Like operator on collection, use Any() with Contains
- if (filter.Operator == FilterOperator.Like)
- {
- // collection.Any(item => item.Contains(value))
- ParameterExpression itemParam = Expression.Parameter(elementType, "item");
-
- // Only strip % if value has both leading AND trailing %
- string cleanValue =
- filter.Value.StartsWith('%')
- && filter.Value.EndsWith('%')
- && filter.Value.Length > 2
- ? filter.Value[1..^1]
- : filter.Value;
-
- MethodInfo? containsMethod = typeof(string).GetMethod("Contains", [typeof(string)]);
- Expression containsCall = Expression.Call(
- itemParam,
- containsMethod!,
- Expression.Constant(cleanValue)
- );
-
- LambdaExpression predicate = Expression.Lambda(containsCall, itemParam);
-
- MethodInfo anyMethod = ReflectionMethodCache.GetEnumerableAnyWithPredicate(
- elementType
- );
-
- return Expression.Call(anyMethod, collectionAccess, predicate);
- }
-
- // For In/Eq: collection.Contains(value)
- object? filterValue = QueryHelpers.ConvertToPropertyType(filter.Value, elementType);
- if (filterValue == null)
- {
- logger?.LogWarning(
- "Failed to convert '{Value}' to {ElementType} for collection filter",
- FilterLogSanitizer.SanitizeForLog(filter.Value),
- elementType.Name
- );
- return null;
- }
-
- // Get Contains method on IEnumerable (via Enumerable.Contains)
- MethodInfo containsMethodInfo = ReflectionMethodCache.GetEnumerableContains(
- elementType
- );
-
- return Expression.Call(
- containsMethodInfo,
- collectionAccess,
- Expression.Constant(filterValue, elementType)
- );
- }
-
- // For Nin/Ne operators: check if collection does NOT contain the value
- if (filter.Operator == FilterOperator.Nin || filter.Operator == FilterOperator.Ne)
- {
- object? filterValue = QueryHelpers.ConvertToPropertyType(filter.Value, elementType);
- if (filterValue == null)
- {
- logger?.LogWarning(
- "Failed to convert '{Value}' to {ElementType} for collection filter",
- FilterLogSanitizer.SanitizeForLog(filter.Value),
- elementType.Name
- );
- return null;
- }
-
- MethodInfo containsMethodInfo = ReflectionMethodCache.GetEnumerableContains(
- elementType
- );
-
- return Expression.Not(
- Expression.Call(
- containsMethodInfo,
- collectionAccess,
- Expression.Constant(filterValue, elementType)
- )
- );
- }
-
- // For IsNull/IsNotNull: check if collection is null
- if (filter.Operator == FilterOperator.IsNull)
- return Expression.Equal(collectionAccess, Expression.Constant(null));
-
- if (filter.Operator == FilterOperator.IsNotNull)
- return Expression.NotEqual(collectionAccess, Expression.Constant(null));
-
- logger?.LogWarning(
- "Operator '{Operator}' is not supported for collection properties",
- filter.Operator
- );
- return null;
- }
-}
diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionBuilder.cs b/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionBuilder.cs
deleted file mode 100644
index b6095c5..0000000
--- a/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionBuilder.cs
+++ /dev/null
@@ -1,142 +0,0 @@
-using System.Linq.Expressions;
-using System.Reflection;
-using JsonApiToolkit.Models.Querying.Filtering;
-using Microsoft.Extensions.Logging;
-
-namespace JsonApiToolkit.Extensions.Querying;
-
-///
-/// Builds LINQ expressions for JSON:API filter parameters.
-///
-public static class FilterExpressionBuilder
-{
- ///
- /// Builds a composite filter expression from filter conditions and nested groups.
- ///
- public static Expression? BuildFilterExpression(
- FilterGroup group,
- ParameterExpression parameter,
- ILogger? logger = null
- )
- {
- return BuildFilterExpression(group, parameter, typeof(T), logger);
- }
-
- ///
- /// Builds a composite filter expression from filter conditions and nested groups (non-generic overload).
- ///
- public static Expression? BuildFilterExpression(
- FilterGroup group,
- ParameterExpression parameter,
- Type entityType,
- ILogger? logger = null
- )
- {
- var expressions = new List();
-
- foreach (FilterParameter filter in group.Filters)
- {
- Expression? expr;
- if (filter.Field.Contains('.'))
- {
- expr = BuildSingleFilterExpression(parameter, filter, logger);
- }
- else
- {
- PropertyInfo? property = QueryHelpers.GetPropertyByJsonName(
- entityType,
- filter.Field
- );
- if (property == null)
- {
- logger?.LogWarning(
- "Property '{Field}' not found on {Type}, skipping filter",
- filter.Field,
- entityType.Name
- );
- continue;
- }
- expr = BuildSingleFilterExpression(parameter, filter, logger);
- }
-
- if (expr != null)
- {
- expressions.Add(expr);
- }
- else
- {
- logger?.LogWarning("Failed to build filter for '{Field}'", filter.Field);
- }
- }
-
- expressions.AddRange(
- group
- .Groups.Select(g => BuildFilterExpression(g, parameter, entityType, logger))
- .OfType()
- );
-
- if (expressions.Count == 0)
- return null;
-
- if (expressions.Count == 1)
- {
- Expression singleExpression = expressions[0];
- if (group.LogicalOperator == LogicalOperator.Not)
- return Expression.Not(singleExpression);
- return singleExpression;
- }
-
- Expression? combinedExpression = null;
-
- if (group.LogicalOperator == LogicalOperator.Not)
- {
- combinedExpression = expressions
- .Select(e => (Expression)Expression.Not(e))
- .Aggregate((acc, next) => Expression.OrElse(acc, next));
- }
- else
- {
- foreach (Expression expr in expressions)
- {
- combinedExpression =
- combinedExpression == null
- ? expr
- : group.LogicalOperator switch
- {
- LogicalOperator.And => Expression.AndAlso(combinedExpression, expr),
- LogicalOperator.Or => Expression.OrElse(combinedExpression, expr),
- _ => Expression.AndAlso(combinedExpression, expr),
- };
- }
- }
-
- return combinedExpression;
- }
-
- ///
- /// Builds a filter expression for a single FilterParameter.
- ///
- public static Expression? BuildSingleFilterExpression(
- ParameterExpression parameter,
- FilterParameter filter,
- ILogger? logger = null
- )
- {
- if (filter.Field.Contains('.'))
- return PropertyNavigator.BuildSafeNestedFilterExpression(parameter, filter, logger);
-
- PropertyInfo? property = QueryHelpers.GetPropertyByJsonName(parameter.Type, filter.Field);
- if (property == null)
- {
- logger?.LogWarning(
- "Property '{Field}' not found on {EntityType}",
- filter.Field,
- parameter.Type.Name
- );
- return null;
- }
-
- Expression propertyAccess = Expression.Property(parameter, property);
- return PropertyFilterBuilder.BuildPropertyFilterExpression(propertyAccess, filter, logger);
- }
-}
diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionComposer.cs b/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionComposer.cs
new file mode 100644
index 0000000..4f1ee7b
--- /dev/null
+++ b/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionComposer.cs
@@ -0,0 +1,549 @@
+using System.Collections;
+using System.Linq.Expressions;
+using System.Reflection;
+using JsonApiToolkit.Helpers;
+using JsonApiToolkit.Models.Errors;
+using JsonApiToolkit.Models.Querying.Filtering;
+using Microsoft.Extensions.Logging;
+
+namespace JsonApiToolkit.Extensions.Querying;
+
+///
+/// Composes LINQ predicates from JSON:API trees.
+/// Owns the full pipeline in one place: group combination (And/Or/Not),
+/// path walking (scalar, dot-path navigation, collection Any(),
+/// nested collections), operator semantics, null-safety guards, and
+/// recursion-depth limiting.
+///
+public sealed class FilterExpressionComposer
+{
+ ///
+ /// Maximum recursion depth for nested collection navigations.
+ /// Prevents stack overflow from malicious deeply nested filter paths.
+ ///
+ internal const int MaxRecursionDepth = 5;
+
+ private readonly ILogger? _logger;
+ private readonly Func _resolveProperty;
+
+ ///
+ /// Creates a composer. The optional maps a JSON
+ /// field name to a CLR property; defaults to .
+ ///
+ public FilterExpressionComposer(
+ ILogger? logger = null,
+ Func? propertyResolver = null
+ )
+ {
+ _logger = logger;
+ _resolveProperty = propertyResolver ?? QueryHelpers.GetPropertyByJsonName;
+ }
+
+ ///
+ /// Composes a predicate for , or null when the group
+ /// yields no usable filters.
+ ///
+ public Expression>? Compose(FilterGroup group)
+ {
+ return (Expression>?)Compose(group, typeof(T));
+ }
+
+ ///
+ /// Composes a predicate lambda for (non-generic
+ /// overload for Type-erased callers), or null when the group yields no usable filters.
+ ///
+ public LambdaExpression? Compose(FilterGroup group, Type entityType)
+ {
+ ParameterExpression parameter = Expression.Parameter(entityType, "x");
+ Expression? body = BuildGroup(group, parameter);
+ if (body == null)
+ return null;
+
+ Type delegateType = typeof(Func<,>).MakeGenericType(entityType, typeof(bool));
+ return Expression.Lambda(delegateType, body, parameter);
+ }
+
+ private Expression? BuildGroup(FilterGroup group, ParameterExpression parameter)
+ {
+ var expressions = new List();
+
+ foreach (FilterParameter filter in group.Filters)
+ {
+ Expression? expr = BuildFilter(parameter, filter);
+ if (expr != null)
+ expressions.Add(expr);
+ else
+ _logger?.LogWarning("Failed to build filter for '{Field}'", filter.Field);
+ }
+
+ expressions.AddRange(
+ group.Groups.Select(g => BuildGroup(g, parameter)).OfType()
+ );
+
+ if (expressions.Count == 0)
+ return null;
+
+ if (group.LogicalOperator == LogicalOperator.Not)
+ {
+ return expressions
+ .Select(e => (Expression)Expression.Not(e))
+ .Aggregate((acc, next) => Expression.OrElse(acc, next));
+ }
+
+ return expressions.Aggregate(
+ (acc, next) =>
+ group.LogicalOperator == LogicalOperator.Or
+ ? Expression.OrElse(acc, next)
+ : Expression.AndAlso(acc, next)
+ );
+ }
+
+ private Expression? BuildFilter(ParameterExpression parameter, FilterParameter filter)
+ {
+ if (filter.Field.Contains('.'))
+ return BuildNestedPath(parameter, filter, depth: 0);
+
+ PropertyInfo? property = _resolveProperty(parameter.Type, filter.Field);
+ if (property == null)
+ {
+ _logger?.LogWarning(
+ "Property '{Field}' not found on {EntityType}",
+ filter.Field,
+ parameter.Type.Name
+ );
+ return null;
+ }
+
+ return BuildLeaf(Expression.Property(parameter, property), filter);
+ }
+
+ ///
+ /// Walks a dot-notation path (e.g. "author.address.city"), emitting a
+ /// != null guard per nullable navigation step and delegating to
+ /// when a step is a collection.
+ ///
+ private Expression? BuildNestedPath(
+ ParameterExpression parameter,
+ FilterParameter filter,
+ int depth
+ )
+ {
+ ThrowIfTooDeep(depth, filter);
+
+ string[] parts = filter.Field.Split('.');
+ Expression current = parameter;
+ var nullChecks = new List();
+
+ for (int i = 0; i < parts.Length - 1; i++)
+ {
+ PropertyInfo? prop = _resolveProperty(current.Type, parts[i]);
+ if (prop == null)
+ {
+ _logger?.LogWarning(
+ "Property '{PropertyName}' not found on {Type} during navigation",
+ parts[i],
+ current.Type.Name
+ );
+ return null;
+ }
+
+ current = Expression.Property(current, prop);
+
+ Type? elementType = TypeHelpers.GetCollectionElementType(prop.PropertyType);
+ if (elementType != null)
+ {
+ string[] remainingParts = parts.Skip(i + 1).ToArray();
+ Expression? collectionFilter = BuildCollectionAny(
+ current,
+ elementType,
+ remainingParts,
+ filter,
+ depth + 1
+ );
+
+ if (collectionFilter == null)
+ return null;
+
+ // No null check for collection navigations:
+ // 1. Collection navigations in EF Core are never truly null in SQL
+ // 2. A null check forces MaterializeCollectionNavigation() which breaks many-to-many translation
+ // 3. The Any() predicate handles empty collections correctly (returns false)
+ Expression result = collectionFilter;
+ for (int j = nullChecks.Count - 1; j >= 0; j--)
+ result = Expression.AndAlso(nullChecks[j], result);
+
+ return result;
+ }
+
+ if (
+ !prop.PropertyType.IsValueType
+ || Nullable.GetUnderlyingType(prop.PropertyType) != null
+ )
+ {
+ nullChecks.Add(Expression.NotEqual(current, Expression.Constant(null)));
+ }
+ }
+
+ PropertyInfo? finalProp = _resolveProperty(current.Type, parts[^1]);
+ if (finalProp == null)
+ {
+ _logger?.LogWarning(
+ "Property '{PropertyName}' not found on {Type}",
+ parts[^1],
+ current.Type.Name
+ );
+ return null;
+ }
+
+ Expression? filterExpression = BuildLeaf(Expression.Property(current, finalProp), filter);
+ if (filterExpression == null)
+ return null;
+
+ if (
+ (filter.Operator == FilterOperator.Ne || filter.Operator == FilterOperator.Nin)
+ && nullChecks.Count > 0
+ )
+ {
+ // Ne/Nin semantics: a null anywhere in the chain counts as "not equal"
+ Expression allNotNull = nullChecks.Aggregate(Expression.AndAlso);
+ return Expression.OrElse(
+ Expression.Not(allNotNull),
+ Expression.AndAlso(allNotNull, filterExpression)
+ );
+ }
+
+ // Outer null checks first: e.A != null && e.A.B != null && filterExpression
+ Expression guarded = filterExpression;
+ for (int i = nullChecks.Count - 1; i >= 0; i--)
+ guarded = Expression.AndAlso(nullChecks[i], guarded);
+
+ return guarded;
+ }
+
+ ///
+ /// Builds collection.Any(item => predicate) for the remaining path
+ /// segments of a collection navigation (e.g. posts.title).
+ ///
+ private Expression? BuildCollectionAny(
+ Expression collectionAccess,
+ Type elementType,
+ string[] remainingParts,
+ FilterParameter filter,
+ int depth
+ )
+ {
+ ThrowIfTooDeep(depth, filter);
+
+ ParameterExpression itemParam = Expression.Parameter(elementType, "item");
+
+ Expression? innerExpression;
+ if (remainingParts.Length == 1)
+ {
+ PropertyInfo? prop = _resolveProperty(elementType, remainingParts[0]);
+ if (prop == null)
+ {
+ _logger?.LogWarning(
+ "Property '{PropertyName}' not found on {Type}",
+ remainingParts[0],
+ elementType.Name
+ );
+ return null;
+ }
+
+ innerExpression = BuildLeaf(Expression.Property(itemParam, prop), filter);
+ }
+ else
+ {
+ var innerFilter = new FilterParameter
+ {
+ Field = string.Join(".", remainingParts),
+ Value = filter.Value,
+ Operator = filter.Operator,
+ IsIncludeFilter = filter.IsIncludeFilter,
+ };
+ innerExpression = BuildNestedPath(itemParam, innerFilter, depth);
+ }
+
+ if (innerExpression == null)
+ return null;
+
+ LambdaExpression predicate = Expression.Lambda(innerExpression, itemParam);
+ MethodInfo anyMethod = ReflectionMethodCache.GetEnumerableAnyWithPredicate(elementType);
+ return Expression.Call(anyMethod, collectionAccess, predicate);
+ }
+
+ ///
+ /// Builds the operator-specific expression for a resolved property access.
+ /// Collection-typed properties (e.g. List<string> Tags) get
+ /// Contains/Any semantics; everything else gets the scalar operator table.
+ ///
+ private Expression? BuildLeaf(Expression propertyAccess, FilterParameter filter)
+ {
+ Type targetType = propertyAccess.Type;
+
+ Type? collectionElementType = TypeHelpers.GetCollectionElementType(targetType);
+ if (collectionElementType != null)
+ return BuildCollectionLeaf(propertyAccess, collectionElementType, filter);
+
+ if (filter.Operator == FilterOperator.IsNull)
+ return Expression.Equal(propertyAccess, Expression.Constant(null));
+
+ if (filter.Operator == FilterOperator.IsNotNull)
+ return Expression.NotEqual(propertyAccess, Expression.Constant(null));
+
+ if (filter.Operator == FilterOperator.In || filter.Operator == FilterOperator.Nin)
+ {
+ Expression contains;
+ Type? underlying = Nullable.GetUnderlyingType(targetType);
+ if (underlying != null)
+ {
+ Expression notNull = Expression.NotEqual(
+ propertyAccess,
+ Expression.Constant(null, targetType)
+ );
+ contains = Expression.AndAlso(
+ notNull,
+ BuildInExpression(
+ Expression.Property(propertyAccess, "Value"),
+ filter.Value,
+ underlying
+ )
+ );
+ }
+ else
+ {
+ contains = BuildInExpression(propertyAccess, filter.Value, targetType);
+ }
+
+ // Nin: null values count as "not in"
+ return filter.Operator == FilterOperator.In ? contains : Expression.Not(contains);
+ }
+
+ if (filter.Operator == FilterOperator.Like)
+ return BuildLikeExpression(propertyAccess, filter.Value);
+
+ object? filterValue = QueryHelpers.ConvertToPropertyType(filter.Value, targetType);
+ if (
+ filterValue == null
+ && filter.Operator != FilterOperator.Eq
+ && filter.Operator != FilterOperator.Ne
+ )
+ {
+ _logger?.LogWarning(
+ "Failed to convert '{Value}' to {PropertyType}",
+ FilterLogSanitizer.SanitizeForLog(filter.Value),
+ targetType.Name
+ );
+ return null;
+ }
+
+ ConstantExpression constant = Expression.Constant(filterValue, targetType);
+
+ return filter.Operator switch
+ {
+ FilterOperator.Eq => Expression.Equal(propertyAccess, constant),
+ FilterOperator.Ne => Expression.NotEqual(propertyAccess, constant),
+ FilterOperator.Gt => Expression.GreaterThan(propertyAccess, constant),
+ FilterOperator.Ge => Expression.GreaterThanOrEqual(propertyAccess, constant),
+ FilterOperator.Lt => Expression.LessThan(propertyAccess, constant),
+ FilterOperator.Le => Expression.LessThanOrEqual(propertyAccess, constant),
+ _ => Expression.Equal(propertyAccess, constant),
+ };
+ }
+
+ ///
+ /// Builds a filter expression when the property itself is a collection,
+ /// e.g. entity.Tags.Contains("value") for filter[tags][in]=value.
+ ///
+ private Expression? BuildCollectionLeaf(
+ Expression collectionAccess,
+ Type elementType,
+ FilterParameter filter
+ )
+ {
+ if (filter.Operator == FilterOperator.Like)
+ {
+ // collection.Any(item => item.Contains(value))
+ ParameterExpression itemParam = Expression.Parameter(elementType, "item");
+ Expression containsCall = Expression.Call(
+ itemParam,
+ StringContainsMethod,
+ Expression.Constant(StripLikeWildcards(filter.Value))
+ );
+ LambdaExpression predicate = Expression.Lambda(containsCall, itemParam);
+ MethodInfo anyMethod = ReflectionMethodCache.GetEnumerableAnyWithPredicate(elementType);
+ return Expression.Call(anyMethod, collectionAccess, predicate);
+ }
+
+ if (
+ filter.Operator
+ is FilterOperator.In
+ or FilterOperator.Eq
+ or FilterOperator.Nin
+ or FilterOperator.Ne
+ )
+ {
+ object? filterValue = QueryHelpers.ConvertToPropertyType(filter.Value, elementType);
+ if (filterValue == null)
+ {
+ _logger?.LogWarning(
+ "Failed to convert '{Value}' to {ElementType} for collection filter",
+ FilterLogSanitizer.SanitizeForLog(filter.Value),
+ elementType.Name
+ );
+ return null;
+ }
+
+ Expression contains = Expression.Call(
+ ReflectionMethodCache.GetEnumerableContains(elementType),
+ collectionAccess,
+ Expression.Constant(filterValue, elementType)
+ );
+
+ return filter.Operator is FilterOperator.In or FilterOperator.Eq
+ ? contains
+ : Expression.Not(contains);
+ }
+
+ if (filter.Operator == FilterOperator.IsNull)
+ return Expression.Equal(collectionAccess, Expression.Constant(null));
+
+ if (filter.Operator == FilterOperator.IsNotNull)
+ return Expression.NotEqual(collectionAccess, Expression.Constant(null));
+
+ _logger?.LogWarning(
+ "Operator '{Operator}' is not supported for collection properties",
+ filter.Operator
+ );
+ return null;
+ }
+
+ private static readonly MethodInfo StringContainsMethod = typeof(string).GetMethod(
+ nameof(string.Contains),
+ [typeof(string)]
+ )!;
+
+ ///
+ /// Strips % only when the value has both leading AND trailing % (wildcard intent),
+ /// preserving literal % in values like "100%" or "%discount".
+ ///
+ private static string StripLikeWildcards(string value)
+ {
+ return value.StartsWith('%') && value.EndsWith('%') && value.Length > 2
+ ? value[1..^1]
+ : value;
+ }
+
+ private static Expression BuildLikeExpression(Expression property, string value)
+ {
+ string cleanValue = StripLikeWildcards(value);
+
+ if (property.Type == typeof(string))
+ return Expression.Call(property, StringContainsMethod, Expression.Constant(cleanValue));
+
+ Type? underlyingType = Nullable.GetUnderlyingType(property.Type);
+ if (underlyingType != null || !property.Type.IsValueType)
+ {
+ Expression notNullCheck = Expression.NotEqual(
+ property,
+ Expression.Constant(null, property.Type)
+ );
+
+ MethodInfo? toStringMethod = property.Type.GetMethod("ToString", Type.EmptyTypes);
+ if (toStringMethod == null)
+ {
+ toStringMethod = typeof(object).GetMethod("ToString", Type.EmptyTypes);
+ property = Expression.Convert(property, typeof(object));
+ }
+
+ Expression containsCall = Expression.Call(
+ Expression.Call(property, toStringMethod!),
+ StringContainsMethod,
+ Expression.Constant(cleanValue)
+ );
+
+ return Expression.AndAlso(notNullCheck, containsCall);
+ }
+
+ MethodInfo valueToString = property.Type.GetMethod("ToString", Type.EmptyTypes)!;
+ return Expression.Call(
+ Expression.Call(property, valueToString),
+ StringContainsMethod,
+ Expression.Constant(cleanValue)
+ );
+ }
+
+ private static Expression BuildInExpression(
+ Expression property,
+ string value,
+ Type propertyType
+ )
+ {
+ var rawValues = value
+ .Split(',')
+ .Select(v => v.Trim())
+ .Where(v => !string.IsNullOrEmpty(v))
+ .ToList();
+
+ var convertedValues = new List