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(); + var failedValues = new List(); + + foreach (var rawValue in rawValues) + { + try + { + var converted = QueryHelpers.ConvertToPropertyType(rawValue, propertyType); + if (converted != null) + convertedValues.Add(converted); + } + catch (FormatException) + { + failedValues.Add(rawValue); + } + } + + if (failedValues.Count > 0) + { + throw new ArgumentException( + $"Failed to convert the following values to type '{propertyType.Name}' for IN operator: {string.Join(", ", failedValues)}" + ); + } + + if (convertedValues.Count == 0) + return Expression.Constant(false); + + Type listElementType = Nullable.GetUnderlyingType(propertyType) ?? propertyType; + Type listType = typeof(List<>).MakeGenericType(listElementType); + var typedList = (IList)Activator.CreateInstance(listType)!; + foreach (object? item in convertedValues) + typedList.Add(item); + + MethodInfo containsMethod = + listType.GetMethod("Contains", [listElementType]) + ?? throw new InvalidOperationException("Cannot find 'Contains' method on list type."); + + if (property.Type != listElementType) + property = Expression.Convert(property, listElementType); + + return Expression.Call(Expression.Constant(typedList, listType), containsMethod, property); + } + + private static void ThrowIfTooDeep(int depth, FilterParameter filter) + { + if (depth <= MaxRecursionDepth) + return; + + throw new JsonApiBadRequestException( + $"Filter path recursion depth exceeds maximum of {MaxRecursionDepth}. " + + "Simplify the filter expression or reduce collection nesting.", + JsonApiErrorCodes.QueryTooComplex, + new ErrorSource { Parameter = $"filter[{filter.Field}]" }, + new Dictionary + { + ["field"] = filter.Field, + ["maxDepth"] = MaxRecursionDepth, + ["actualDepth"] = depth, + } + ); + } +} diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/FilterHandler.cs b/JsonApiToolkit/Extensions/Querying/Filtering/FilterHandler.cs index 2540549..8804b03 100644 --- a/JsonApiToolkit/Extensions/Querying/Filtering/FilterHandler.cs +++ b/JsonApiToolkit/Extensions/Querying/Filtering/FilterHandler.cs @@ -24,18 +24,12 @@ public static IQueryable ApplyFilters( ) return query; - ParameterExpression parameter = Expression.Parameter(typeof(T), "x"); - Expression? expression = FilterExpressionBuilder.BuildFilterExpression( - filterGroup, - parameter, - logger + Expression>? lambda = new FilterExpressionComposer(logger).Compose( + filterGroup ); - if (expression != null) - { - var lambda = Expression.Lambda>(expression, parameter); + if (lambda != null) return query.Where(lambda); - } logger?.LogWarning("Filter expression returned null for {Type}", typeof(T).Name); return query; diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/FilterOperatorExpressions.cs b/JsonApiToolkit/Extensions/Querying/Filtering/FilterOperatorExpressions.cs deleted file mode 100644 index c4f2fd6..0000000 --- a/JsonApiToolkit/Extensions/Querying/Filtering/FilterOperatorExpressions.cs +++ /dev/null @@ -1,122 +0,0 @@ -using System.Collections; -using System.Linq.Expressions; -using System.Reflection; - -namespace JsonApiToolkit.Extensions.Querying; - -internal static class FilterOperatorExpressions -{ - internal static Expression BuildLikeExpression(Expression property, string value) - { - // Only strip % if value has both leading AND trailing % (indicating wildcard intent) - // This preserves literal % in values like "100%" or "%discount" - string cleanValue = - value.StartsWith('%') && value.EndsWith('%') && value.Length > 2 ? value[1..^1] : value; - - if (property.Type == typeof(string)) - { - MethodInfo? method = typeof(string).GetMethod("Contains", [typeof(string)]); - return Expression.Call(property, method!, 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)); - } - - MethodCallExpression toStringCall = Expression.Call(property, toStringMethod!); - MethodInfo? containsMethod = typeof(string).GetMethod("Contains", [typeof(string)]); - Expression containsCall = Expression.Call( - toStringCall, - containsMethod!, - Expression.Constant(cleanValue) - ); - - return Expression.AndAlso(notNullCheck, containsCall); - } - else - { - MethodInfo? toStringMethod = property.Type.GetMethod("ToString", Type.EmptyTypes); - MethodCallExpression toStringCall = Expression.Call(property, toStringMethod!); - MethodInfo? containsMethod = typeof(string).GetMethod("Contains", [typeof(string)]); - return Expression.Call(toStringCall, containsMethod!, Expression.Constant(cleanValue)); - } - } - - internal 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(); - var failedValues = new List(); - - foreach (var rawValue in rawValues) - { - try - { - var converted = QueryHelpers.ConvertToPropertyType(rawValue, propertyType); - if (converted != null) - convertedValues.Add(converted); - } - catch (FormatException) - { - failedValues.Add(rawValue); - } - } - - if (failedValues.Count > 0) - { - throw new ArgumentException( - $"Failed to convert the following values to type '{propertyType.Name}' for IN operator: {string.Join(", ", failedValues)}" - ); - } - - if (convertedValues.Count == 0) - return Expression.Constant(false); - - Type listElementType = propertyType; - if ( - propertyType.IsGenericType - && propertyType.GetGenericTypeDefinition() == typeof(Nullable<>) - ) - { - listElementType = Nullable.GetUnderlyingType(propertyType)!; - } - - Type listType = typeof(List<>).MakeGenericType(listElementType); - - var typedList = (IList)Activator.CreateInstance(listType)!; - - foreach (object? item in convertedValues) - typedList.Add(item); - - ConstantExpression listConstant = Expression.Constant(typedList, listType); - - MethodInfo containsMethod = - listType.GetMethod("Contains", [listElementType]) - ?? throw new InvalidOperationException("Cannot find 'Contains' method on list type."); - - if (property.Type != listElementType) - property = Expression.Convert(property, listElementType); - - return Expression.Call(listConstant, containsMethod, property); - } -} diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/PropertyFilterBuilder.cs b/JsonApiToolkit/Extensions/Querying/Filtering/PropertyFilterBuilder.cs deleted file mode 100644 index 59bd273..0000000 --- a/JsonApiToolkit/Extensions/Querying/Filtering/PropertyFilterBuilder.cs +++ /dev/null @@ -1,120 +0,0 @@ -using System.Linq.Expressions; -using JsonApiToolkit.Models.Querying.Filtering; -using Microsoft.Extensions.Logging; - -namespace JsonApiToolkit.Extensions.Querying; - -/// -/// Builds the operator-specific LINQ expression for a single property access -/// (Eq, Ne, Gt, Lt, Like, In, Nin, IsNull, IsNotNull). Delegates to -/// when the property itself is a collection. -/// -internal static class PropertyFilterBuilder -{ - internal static Expression? BuildPropertyFilterExpression( - Expression propertyAccess, - FilterParameter filter, - ILogger? logger = null - ) - { - Type targetType = propertyAccess.Type; - - // Check if the property itself is a collection (e.g., List for CVEs/Tags) - Type? collectionElementType = TypeHelpers.GetCollectionElementType(targetType); - if (collectionElementType != null) - { - return CollectionFilterBuilder.BuildCollectionPropertyFilterExpression( - propertyAccess, - collectionElementType, - filter, - logger - ); - } - - 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) - { - Type? underlying = Nullable.GetUnderlyingType(targetType); - if (underlying != null) - { - BinaryExpression notNullExpr = Expression.NotEqual( - propertyAccess, - Expression.Constant(null, propertyAccess.Type) - ); - Expression containsExpr = FilterOperatorExpressions.BuildInExpression( - Expression.Property(propertyAccess, "Value"), - filter.Value, - underlying - ); - return Expression.AndAlso(notNullExpr, containsExpr); - } - return FilterOperatorExpressions.BuildInExpression( - propertyAccess, - filter.Value, - targetType - ); - } - - if (filter.Operator == FilterOperator.Nin) - { - Type? underlying = Nullable.GetUnderlyingType(targetType); - if (underlying != null) - { - BinaryExpression isNullExpr = Expression.Equal( - propertyAccess, - Expression.Constant(null, propertyAccess.Type) - ); - Expression containsExpr = FilterOperatorExpressions.BuildInExpression( - Expression.Property(propertyAccess, "Value"), - filter.Value, - underlying - ); - return Expression.OrElse(isNullExpr, Expression.Not(containsExpr)); - } - return Expression.Not( - FilterOperatorExpressions.BuildInExpression( - propertyAccess, - filter.Value, - targetType - ) - ); - } - - 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), - FilterOperator.Like => FilterOperatorExpressions.BuildLikeExpression( - propertyAccess, - filter.Value - ), - _ => Expression.Equal(propertyAccess, constant), - }; - } -} diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/PropertyNavigator.cs b/JsonApiToolkit/Extensions/Querying/Filtering/PropertyNavigator.cs deleted file mode 100644 index 2133514..0000000 --- a/JsonApiToolkit/Extensions/Querying/Filtering/PropertyNavigator.cs +++ /dev/null @@ -1,153 +0,0 @@ -using System.Linq.Expressions; -using System.Reflection; -using JsonApiToolkit.Models.Errors; -using JsonApiToolkit.Models.Querying.Filtering; -using Microsoft.Extensions.Logging; - -namespace JsonApiToolkit.Extensions.Querying; - -/// -/// Walks dot-notation filter paths (e.g. "author.address.city") to build LINQ -/// expressions, with null-safety chains and recursion-depth guarding. -/// -internal static class PropertyNavigator -{ - /// - /// Maximum recursion depth for nested collection navigations. - /// Prevents stack overflow from malicious deeply nested filter paths. - /// - internal const int MaxRecursionDepth = 5; - - internal static Expression? BuildSafeNestedFilterExpression( - ParameterExpression parameter, - FilterParameter filter, - ILogger? logger = null, - int depth = 0 - ) - { - if (depth > MaxRecursionDepth) - { - throw new JsonApiBadRequestException( - $"Filter path recursion depth exceeds maximum of {MaxRecursionDepth}. " - + "Simplify the filter expression or reduce collection nesting.", - JsonApiErrorCodes.QueryTooComplex, - new ErrorSource { Parameter = $"filter[{filter.Field}]" }, - new Dictionary - { - ["field"] = filter.Field, - ["maxDepth"] = MaxRecursionDepth, - ["actualDepth"] = depth, - } - ); - } - - string[] parts = filter.Field.Split('.'); - Expression current = parameter; - var nullChecks = new List(); - - for (int i = 0; i < parts.Length - 1; i++) - { - PropertyInfo? prop = QueryHelpers.GetPropertyByJsonName(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); - - // Check if this property is a collection (but not a string) - Type? elementType = TypeHelpers.GetCollectionElementType(prop.PropertyType); - if (elementType != null) - { - // Build collection filter using Any() for remaining path - string[] remainingParts = parts.Skip(i + 1).ToArray(); - Expression? collectionFilter = - CollectionFilterBuilder.BuildCollectionFilterExpression( - current, - elementType, - remainingParts, - filter, - logger, - depth + 1 - ); - - if (collectionFilter == null) - return null; - - // Combine with null checks for the path so far - Expression result = collectionFilter; - // Note: We don't add a null check for collection navigations because: - // 1. Collection navigations in EF Core are never truly null in SQL - // 2. Adding a null check forces MaterializeCollectionNavigation() which breaks many-to-many translation - // 3. The Any() predicate handles empty collections correctly (returns false) - - 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 = QueryHelpers.GetPropertyByJsonName(current.Type, parts[^1]); - if (finalProp == null) - { - logger?.LogWarning( - "Property '{PropertyName}' not found on {Type}", - parts[^1], - current.Type.Name - ); - return null; - } - - Expression finalProperty = Expression.Property(current, finalProp); - Expression? filterExpression = PropertyFilterBuilder.BuildPropertyFilterExpression( - finalProperty, - filter, - logger - ); - if (filterExpression == null) - return null; - - Expression result2; - if (filter.Operator == FilterOperator.Ne || filter.Operator == FilterOperator.Nin) - { - if (nullChecks.Count > 0) - { - Expression allNotNull = nullChecks[0]; - for (int i = 1; i < nullChecks.Count; i++) - allNotNull = Expression.AndAlso(allNotNull, nullChecks[i]); - - Expression anyNull = Expression.Not(allNotNull); - Expression notNullAndFilter = Expression.AndAlso(allNotNull, filterExpression); - result2 = Expression.OrElse(anyNull, notNullAndFilter); - } - else - { - result2 = filterExpression; - } - } - else - { - result2 = filterExpression; - // Iterate in reverse to ensure outer null checks are evaluated first - // e.g., e.A != null && e.A.B != null && filterExpression - for (int i = nullChecks.Count - 1; i >= 0; i--) - result2 = Expression.AndAlso(nullChecks[i], result2); - } - - return result2; - } -} diff --git a/JsonApiToolkit/Extensions/Querying/Includes/FilteredIncludeBuilder.cs b/JsonApiToolkit/Extensions/Querying/Includes/FilteredIncludeBuilder.cs index 8c30435..87b6450 100644 --- a/JsonApiToolkit/Extensions/Querying/Includes/FilteredIncludeBuilder.cs +++ b/JsonApiToolkit/Extensions/Querying/Includes/FilteredIncludeBuilder.cs @@ -132,20 +132,13 @@ private static IQueryable ApplyTwoLevelFilteredInclude( var navParam = Expression.Parameter(firstNavType, "nav"); var secondNavAccess = Expression.Property(navParam, secondProperty); - var filterParam = Expression.Parameter(elementType, "item"); - - // Use FilterExpressionBuilder to build the filter expression with proper logical operators - var filterExpr = FilterExpressionBuilder.BuildFilterExpression( + var whereLambda = new FilterExpressionComposer(logger).Compose( filterGroup, - filterParam, - elementType, - logger + elementType ); - if (filterExpr != null) + if (whereLambda != null) { - var whereLambda = Expression.Lambda(filterExpr, filterParam); - var whereMethod = ReflectionMethodCache.GetEnumerableWhere(elementType); var filteredCollection = Expression.Call(whereMethod, secondNavAccess, whereLambda); @@ -259,21 +252,11 @@ private static IQueryable ApplyFilteredIncludeWithFilters( { var entityParameter = Expression.Parameter(entityType, "e"); var navigationAccess = Expression.Property(entityParameter, navigationProperty); - var elementParameter = Expression.Parameter(elementType, "x"); - - // Use FilterExpressionBuilder to build the filter expression with proper logical operators - var filterExpression = FilterExpressionBuilder.BuildFilterExpression( - filterGroup, - elementParameter, - elementType, - logger - ); - if (filterExpression == null) + var whereLambda = new FilterExpressionComposer(logger).Compose(filterGroup, elementType); + if (whereLambda == null) return null; - var whereLambda = Expression.Lambda(filterExpression, elementParameter); - var whereMethod = ReflectionMethodCache.GetEnumerableWhere(elementType); var filteredCollection = Expression.Call(whereMethod, navigationAccess, whereLambda); @@ -282,30 +265,4 @@ private static IQueryable ApplyFilteredIncludeWithFilters( return includeLambda; } - - private static MemberExpression? GetPropertyExpression( - Expression parameter, - string propertyPath, - Type entityType - ) - { - if (string.IsNullOrEmpty(propertyPath)) - return null; - - var parts = propertyPath.Split('.'); - Expression current = parameter; - Type currentType = entityType; - - foreach (var part in parts) - { - var property = QueryHelpers.GetPropertyByJsonName(currentType, part); - if (property == null) - return null; - - current = Expression.Property(current, property); - currentType = property.PropertyType; - } - - return current as MemberExpression; - } }