diff --git a/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs b/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs index 0737813..9ffeff4 100644 --- a/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs +++ b/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs @@ -658,6 +658,75 @@ public void ApplyFilters_WithCollectionNestedFilter_FiltersCorrectly() Assert.Equal(1, result[0].Id); Assert.Contains(result[0].Children, c => c.Name == "TargetChild"); } + + [Fact] + public void ApplyFilters_WithCollectionPropertyFilter_FiltersCorrectly() + { + // Test filtering where the final property is a collection: Entity.Children.Tags contains value + // This simulates: filter[children.tags][in]=important + var testData = new List + { + new TestEntity + { + Id = 1, + Name = "Entity1", + Children = new List + { + new TestChildEntity + { + Id = 10, + Name = "Child1", + Tags = new List { "important", "urgent" }, + }, + new TestChildEntity + { + Id = 11, + Name = "Child2", + Tags = new List { "normal" }, + }, + }, + }, + new TestEntity + { + Id = 2, + Name = "Entity2", + Children = new List + { + new TestChildEntity + { + Id = 20, + Name = "Child3", + Tags = new List { "low-priority" }, + }, + }, + }, + new TestEntity + { + Id = 3, + Name = "Entity3", + Children = new List(), // Empty collection + }, + }.AsQueryable(); + + var filterGroup = new FilterGroup + { + Filters = new List + { + new FilterParameter + { + Field = "children.tags", + Operator = FilterOperator.In, + Value = "important", + }, + }, + }; + + var result = testData.ApplyFilters(filterGroup).ToList(); + + Assert.Single(result); + Assert.Equal(1, result[0].Id); + Assert.Contains(result[0].Children, c => c.Tags.Contains("important")); + } } public static class TaskExtensions diff --git a/JsonApiToolkit.Tests/Models/TestEntity.cs b/JsonApiToolkit.Tests/Models/TestEntity.cs index 82e8e9a..158943e 100644 --- a/JsonApiToolkit.Tests/Models/TestEntity.cs +++ b/JsonApiToolkit.Tests/Models/TestEntity.cs @@ -39,4 +39,5 @@ public class TestChildEntity public string Name { get; set; } = string.Empty; public int TestEntityId { get; set; } public TestEntity? TestEntity { get; set; } + public List Tags { get; set; } = new(); } diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs b/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs index 2a4d24c..8f78a3a 100644 --- a/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs +++ b/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs @@ -129,8 +129,7 @@ internal static class NestedPropertyNavigator return type.GetGenericArguments()[0]; // Check interfaces for IEnumerable - Type? enumerableInterface = type - .GetInterfaces() + Type? enumerableInterface = type.GetInterfaces() .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>) ); @@ -193,21 +192,141 @@ internal static class NestedPropertyNavigator LambdaExpression predicate = Expression.Lambda(innerExpression, itemParam); // Get the Enumerable.Any(IEnumerable, Func) method - MethodInfo anyMethod = - typeof(Enumerable) - .GetMethods() - .First(m => - m.Name == "Any" - && m.GetParameters().Length == 2 - && m.GetParameters()[1].ParameterType.GetGenericTypeDefinition() - == typeof(Func<,>) - ) - .MakeGenericMethod(elementType); + MethodInfo anyMethod = typeof(Enumerable) + .GetMethods() + .First(m => + m.Name == "Any" + && m.GetParameters().Length == 2 + && m.GetParameters()[1].ParameterType.GetGenericTypeDefinition() == typeof(Func<,>) + ) + .MakeGenericMethod(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 + /// + private 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 = typeof(Enumerable) + .GetMethods() + .First(m => + m.Name == "Any" + && m.GetParameters().Length == 2 + && m.GetParameters()[1].ParameterType.GetGenericTypeDefinition() + == typeof(Func<,>) + ) + .MakeGenericMethod(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", + filter.Value, + elementType.Name + ); + return null; + } + + // Get Contains method on IEnumerable (via Enumerable.Contains) + MethodInfo containsMethodInfo = typeof(Enumerable) + .GetMethods() + .First(m => m.Name == "Contains" && m.GetParameters().Length == 2) + .MakeGenericMethod(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", + filter.Value, + elementType.Name + ); + return null; + } + + MethodInfo containsMethodInfo = typeof(Enumerable) + .GetMethods() + .First(m => m.Name == "Contains" && m.GetParameters().Length == 2) + .MakeGenericMethod(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; + } + internal static Expression? BuildPropertyFilterExpression( Expression propertyAccess, FilterParameter filter, @@ -216,6 +335,18 @@ internal static class NestedPropertyNavigator { Type targetType = propertyAccess.Type; + // Check if the property itself is a collection (e.g., List for CVEs/Tags) + Type? collectionElementType = GetCollectionElementType(targetType); + if (collectionElementType != null) + { + return BuildCollectionPropertyFilterExpression( + propertyAccess, + collectionElementType, + filter, + logger + ); + } + if (filter.Operator == FilterOperator.IsNull) return Expression.Equal(propertyAccess, Expression.Constant(null));