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
162 changes: 162 additions & 0 deletions JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,59 @@ public void ApplyFilters_WithLikeFilter_FiltersCorrectly()
Assert.Contains(result, e => e.Name == "Beta");
}

[Fact]
public void ApplyFilters_WithLikeFilter_StripsPercentWildcards()
{
var query = GetTestData();
var filterGroup = new FilterGroup
{
Filters = new List<FilterParameter>
{
new FilterParameter
{
Field = "Name",
Operator = FilterOperator.Like,
Value = "%lph%", // Should match "Alpha" after stripping %
},
},
};

var result = query.ApplyFilters(filterGroup).ToList();

Assert.Single(result);
Assert.Equal("Alpha", result[0].Name);
}

[Fact]
public void ApplyFilters_WithLikeFilter_PreservesLiteralPercent()
{
// Test that literal % in values is preserved when not in wildcard pattern
var testData = new List<TestEntity>
{
new TestEntity { Id = 1, Name = "100% Complete" },
new TestEntity { Id = 2, Name = "50% Done" },
new TestEntity { Id = 3, Name = "Finished" },
}.AsQueryable();

var filterGroup = new FilterGroup
{
Filters = new List<FilterParameter>
{
new FilterParameter
{
Field = "Name",
Operator = FilterOperator.Like,
Value = "100%", // Should match literal "100%", not be stripped
},
},
};

var result = testData.ApplyFilters(filterGroup).ToList();

Assert.Single(result);
Assert.Equal("100% Complete", result[0].Name);
}

[Fact]
public void ApplyFilters_WithLogicalAndGroup_FiltersCorrectly()
{
Expand Down Expand Up @@ -496,6 +549,115 @@ public void ApplyFilters_WithLogicalNotGroup_FiltersCorrectly()
Assert.Equal(2, result.Count);
Assert.All(result, e => Assert.False(e.IsActive));
}

[Fact]
public void ApplyFilters_WithThreeLevelNestedFilter_FiltersCorrectly()
{
// Test three-level nesting: Entity.RelatedEntity.NestedEntity.Value
var testData = new List<TestEntity>
{
new TestEntity
{
Id = 1,
Name = "Entity1",
RelatedEntity = new TestRelatedEntity
{
Id = 10,
Name = "Related1",
NestedEntity = new TestNestedEntity { Id = 100, Value = "TargetValue" },
},
},
new TestEntity
{
Id = 2,
Name = "Entity2",
RelatedEntity = new TestRelatedEntity
{
Id = 20,
Name = "Related2",
NestedEntity = new TestNestedEntity { Id = 200, Value = "OtherValue" },
},
},
new TestEntity
{
Id = 3,
Name = "Entity3",
RelatedEntity = null, // No related entity
},
}.AsQueryable();

var filterGroup = new FilterGroup
{
Filters = new List<FilterParameter>
{
new FilterParameter
{
Field = "relatedEntity.nestedEntity.value",
Operator = FilterOperator.Eq,
Value = "TargetValue",
},
},
};

var result = testData.ApplyFilters(filterGroup).ToList();

Assert.Single(result);
Assert.Equal(1, result[0].Id);
Assert.Equal("TargetValue", result[0].RelatedEntity?.NestedEntity?.Value);
}

[Fact]
public void ApplyFilters_WithCollectionNestedFilter_FiltersCorrectly()
{
// Test filtering through a collection: Entity.Children.Name
var testData = new List<TestEntity>
{
new TestEntity
{
Id = 1,
Name = "Entity1",
Children = new List<TestChildEntity>
{
new TestChildEntity { Id = 10, Name = "TargetChild" },
new TestChildEntity { Id = 11, Name = "OtherChild" },
},
},
new TestEntity
{
Id = 2,
Name = "Entity2",
Children = new List<TestChildEntity>
{
new TestChildEntity { Id = 20, Name = "DifferentChild" },
},
},
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.name",
Operator = FilterOperator.Eq,
Value = "TargetChild",
},
},
};

var result = testData.ApplyFilters(filterGroup).ToList();

Assert.Single(result);
Assert.Equal(1, result[0].Id);
Assert.Contains(result[0].Children, c => c.Name == "TargetChild");
}
}

public static class TaskExtensions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,16 @@ 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(value));
return Expression.Call(property, method!, Expression.Constant(cleanValue));
}

Type? underlyingType = Nullable.GetUnderlyingType(property.Type);
Expand All @@ -34,7 +40,7 @@ internal static Expression BuildLikeExpression(Expression property, string value
Expression containsCall = Expression.Call(
toStringCall,
containsMethod!,
Expression.Constant(value)
Expression.Constant(cleanValue)
);

return Expression.AndAlso(notNullCheck, containsCall);
Expand All @@ -44,7 +50,7 @@ internal static Expression BuildLikeExpression(Expression property, string value
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(value));
return Expression.Call(toStringCall, containsMethod!, Expression.Constant(cleanValue));
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,34 @@ internal static class NestedPropertyNavigator

current = Expression.Property(current, prop);

// Check if this property is a collection (but not a string)
Type? elementType = GetCollectionElementType(prop.PropertyType);
if (elementType != null)
{
// Build collection filter using Any() for remaining path
string[] remainingParts = parts.Skip(i + 1).ToArray();
Expression? collectionFilter = BuildCollectionFilterExpression(
current,
elementType,
remainingParts,
filter,
logger
);

if (collectionFilter == null)
return null;

// Combine with null checks for the path so far
Expression result = collectionFilter;
// Add null check for the collection itself
nullChecks.Add(Expression.NotEqual(current, Expression.Constant(null)));

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
Expand All @@ -57,7 +85,7 @@ internal static class NestedPropertyNavigator
if (filterExpression == null)
return null;

Expression result;
Expression result2;
if (filter.Operator == FilterOperator.Ne || filter.Operator == FilterOperator.Nin)
{
if (nullChecks.Count > 0)
Expand All @@ -68,21 +96,116 @@ internal static class NestedPropertyNavigator

Expression anyNull = Expression.Not(allNotNull);
Expression notNullAndFilter = Expression.AndAlso(allNotNull, filterExpression);
result = Expression.OrElse(anyNull, notNullAndFilter);
result2 = Expression.OrElse(anyNull, notNullAndFilter);
}
else
{
result = filterExpression;
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;
}

/// <summary>
/// Gets the element type if the type is a collection (but not string).
/// Returns null if not a collection.
/// </summary>
private static Type? GetCollectionElementType(Type type)
{
if (type == typeof(string))
return null;

// Check for IEnumerable<T>
if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>))
return type.GetGenericArguments()[0];

// Check interfaces for IEnumerable<T>
Type? enumerableInterface = type
.GetInterfaces()
.FirstOrDefault(i =>
i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)
);

return enumerableInterface?.GetGenericArguments()[0];
}

/// <summary>
/// Builds a filter expression for collection navigation using Any().
/// e.g., collection.Any(item => item.Property == value)
/// </summary>
private static Expression? BuildCollectionFilterExpression(
Expression collectionAccess,
Type elementType,
string[] remainingParts,
FilterParameter filter,
ILogger? logger
)
{
// 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 = BuildPropertyFilterExpression(propertyAccess, filter, logger);
}
else
{
result = filterExpression;
foreach (Expression nullCheck in nullChecks)
result = Expression.AndAlso(nullCheck, result);
// Nested property access - recursively build
innerExpression = BuildSafeNestedFilterExpression(itemParam, innerFilter, logger);
}

return result;
if (innerExpression == null)
return null;

// Create lambda: item => innerExpression
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);

// Build: collection.Any(item => predicate)
return Expression.Call(anyMethod, collectionAccess, predicate);
}

internal static Expression? BuildPropertyFilterExpression(
Expand Down