diff --git a/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs b/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs index 324c4a9..0737813 100644 --- a/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs +++ b/JsonApiToolkit.Tests/Extensions/QueryableExtensionTests.cs @@ -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 + { + 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 + { + 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 + { + 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() { @@ -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 + { + 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 + { + 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 + { + new TestEntity + { + Id = 1, + Name = "Entity1", + Children = new List + { + new TestChildEntity { Id = 10, Name = "TargetChild" }, + new TestChildEntity { Id = 11, Name = "OtherChild" }, + }, + }, + new TestEntity + { + Id = 2, + Name = "Entity2", + Children = new List + { + new TestChildEntity { Id = 20, Name = "DifferentChild" }, + }, + }, + new TestEntity + { + Id = 3, + Name = "Entity3", + Children = new List(), // Empty collection + }, + }.AsQueryable(); + + var filterGroup = new FilterGroup + { + Filters = new List + { + 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 diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/FilterOperatorExpressions.cs b/JsonApiToolkit/Extensions/Querying/Filtering/FilterOperatorExpressions.cs index 69801ea..d118225 100644 --- a/JsonApiToolkit/Extensions/Querying/Filtering/FilterOperatorExpressions.cs +++ b/JsonApiToolkit/Extensions/Querying/Filtering/FilterOperatorExpressions.cs @@ -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); @@ -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); @@ -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)); } } diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs b/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs index 83eac1c..2a4d24c 100644 --- a/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs +++ b/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs @@ -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 @@ -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) @@ -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; + } + + /// + /// Gets the element type if the type is a collection (but not string). + /// Returns null if not a collection. + /// + private static Type? GetCollectionElementType(Type type) + { + if (type == typeof(string)) + return null; + + // Check for IEnumerable + if (type.IsGenericType && type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + return type.GetGenericArguments()[0]; + + // Check interfaces for IEnumerable + Type? enumerableInterface = type + .GetInterfaces() + .FirstOrDefault(i => + i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>) + ); + + return enumerableInterface?.GetGenericArguments()[0]; + } + + /// + /// Builds a filter expression for collection navigation using Any(). + /// e.g., collection.Any(item => item.Property == value) + /// + 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(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); + + // Build: collection.Any(item => predicate) + return Expression.Call(anyMethod, collectionAccess, predicate); } internal static Expression? BuildPropertyFilterExpression(