Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestEntity>
{
new TestEntity
{
Id = 1,
Name = "Entity1",
Children = new List<TestChildEntity>
{
new TestChildEntity
{
Id = 10,
Name = "Child1",
Tags = new List<string> { "important", "urgent" },
},
new TestChildEntity
{
Id = 11,
Name = "Child2",
Tags = new List<string> { "normal" },
},
},
},
new TestEntity
{
Id = 2,
Name = "Entity2",
Children = new List<TestChildEntity>
{
new TestChildEntity
{
Id = 20,
Name = "Child3",
Tags = new List<string> { "low-priority" },
},
},
},
new TestEntity
{
Id = 3,
Name = "Entity3",
Children = new List<TestChildEntity>(), // Empty collection
},
}.AsQueryable();

var filterGroup = new FilterGroup
{
Filters = new List<FilterParameter>
{
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
Expand Down
1 change: 1 addition & 0 deletions JsonApiToolkit.Tests/Models/TestEntity.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> Tags { get; set; } = new();
}
Original file line number Diff line number Diff line change
Expand Up @@ -129,8 +129,7 @@ internal static class NestedPropertyNavigator
return type.GetGenericArguments()[0];

// Check interfaces for IEnumerable<T>
Type? enumerableInterface = type
.GetInterfaces()
Type? enumerableInterface = type.GetInterfaces()
.FirstOrDefault(i =>
i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)
);
Expand Down Expand Up @@ -193,21 +192,141 @@ internal static class NestedPropertyNavigator
LambdaExpression predicate = Expression.Lambda(innerExpression, itemParam);

// Get the Enumerable.Any<T>(IEnumerable<T>, Func<T, bool>) 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);
}

/// <summary>
/// Builds a filter expression when the property itself is a collection.
/// e.g., entity.Tags.Contains("value") for filter[tags][in]=value
/// </summary>
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<T> (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,
Expand All @@ -216,6 +335,18 @@ internal static class NestedPropertyNavigator
{
Type targetType = propertyAccess.Type;

// Check if the property itself is a collection (e.g., List<string> 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));

Expand Down