diff --git a/JsonApiToolkit/Controllers/JsonApiController.cs b/JsonApiToolkit/Controllers/JsonApiController.cs index 01ab022..9035413 100644 --- a/JsonApiToolkit/Controllers/JsonApiController.cs +++ b/JsonApiToolkit/Controllers/JsonApiController.cs @@ -178,7 +178,7 @@ string resourceType includeFilters.Count, typeof(T).Name ); - filteredQuery = filteredQuery.ApplyFilteredIncludes(mappedIncludes, includeFilters); + filteredQuery = filteredQuery.ApplyFilteredIncludes(mappedIncludes, includeFilters, Logger); } else if (mappedIncludes.Count > 0) { diff --git a/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs b/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs deleted file mode 100644 index c7ae788..0000000 --- a/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs +++ /dev/null @@ -1,449 +0,0 @@ -using System.Collections; -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. -/// Converts filter syntax to strongly-typed expressions for Entity Framework. -/// -public static class FilterExpressionBuilder -{ - /// - /// Builds a composite filter expression from filter conditions and nested groups. - /// Supports dot notation for nested properties (e.g., "user.address.city"). - /// - /// Filter group with conditions and nested groups - /// Parameter expression for the entity - /// Optional logger - /// Expression for LINQ Where clause, or null if no valid filters - public static Expression? BuildFilterExpression( - FilterGroup group, - ParameterExpression parameter, - 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( - typeof(T), - filter.Field - ); - if (property == null) - { - logger?.LogWarning( - "Property '{Field}' not found on {Type}, skipping filter", - filter.Field, - typeof(T).Name - ); - continue; - } - expr = BuildSingleFilterExpression(parameter, filter, logger); - } - - if (expr != null) - { - expressions.Add(expr); - } - else - { - logger?.LogWarning("Failed to build filter for '{Field}'", filter.Field); - } - } - - foreach (FilterGroup nestedGroup in group.Groups) - { - Expression? nestedExpr = BuildFilterExpression(nestedGroup, parameter, logger); - if (nestedExpr != null) - { - expressions.Add(nestedExpr); - } - } - - 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; - - // For NOT: apply De Morgan's law - // NOT(A AND B) = NOT(A) OR NOT(B) - if (group.LogicalOperator == LogicalOperator.Not) - { - foreach (Expression expr in expressions) - { - var notExpr = Expression.Not(expr); - combinedExpression = - combinedExpression == null - ? notExpr - : Expression.OrElse(combinedExpression, notExpr); - } - } - else - { - foreach (Expression expr in expressions) - { - if (combinedExpression == null) - { - combinedExpression = expr; - } - else - { - combinedExpression = 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. - /// - /// Parameter expression for the entity - /// Filter parameter to build - /// Optional logger - /// Expression for the filter, or null if invalid - public static Expression? BuildSingleFilterExpression( - ParameterExpression parameter, - FilterParameter filter, - ILogger? logger = null - ) - { - if (filter.Field.Contains('.')) - { - return 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 BuildPropertyFilterExpression(propertyAccess, filter, logger); - } - - private static Expression BuildLikeExpression(Expression property, string value) - { - if (property.Type == typeof(string)) - { - // For string types, use Contains directly - MethodInfo? method = typeof(string).GetMethod("Contains", [typeof(string)]); - return Expression.Call(property, method!, Expression.Constant(value)); - } - - // For non-string types, we need to handle nulls properly - // Check if the property is nullable - Type? underlyingType = Nullable.GetUnderlyingType(property.Type); - if (underlyingType != null || !property.Type.IsValueType) - { - // Property is nullable or reference type - need null check - // Create: property != null && property.ToString().Contains(value) - - // Null check - Expression notNullCheck = Expression.NotEqual( - property, - Expression.Constant(null, property.Type) - ); - - // ToString call with null check - MethodInfo? toStringMethod = property.Type.GetMethod("ToString", Type.EmptyTypes); - if (toStringMethod == null) - { - // If no ToString method, use Object.ToString - 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(value) - ); - - // Combine: not null && contains - return Expression.AndAlso(notNullCheck, containsCall); - } - else - { - // Non-nullable value type - can call ToString directly - 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)); - } - } - - 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 (Exception) - { - // Track failed conversions - failedValues.Add(rawValue); - } - } - - // If any values failed to convert, throw an exception with details - 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); - } - - private static Expression? BuildSafeNestedFilterExpression( - ParameterExpression parameter, - FilterParameter filter, - ILogger? logger = null - ) - { - string[] parts = filter.Field.Split('.'); - Expression current = parameter; - var nullChecks = new List(); - - // Navigate through all but the last property - 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); - - // Add null check for reference types - if ( - !prop.PropertyType.IsValueType - || Nullable.GetUnderlyingType(prop.PropertyType) != null - ) - { - nullChecks.Add(Expression.NotEqual(current, Expression.Constant(null))); - } - } - - // Get the final property - 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 = BuildPropertyFilterExpression(finalProperty, filter, logger); - if (filterExpression == null) - return null; - - // For inequality: null != value is true - // For equality: null == value needs all non-null checks - Expression result; - 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); - result = Expression.OrElse(anyNull, notNullAndFilter); - } - else - { - result = filterExpression; - } - } - else - { - result = filterExpression; - foreach (Expression nullCheck in nullChecks) - result = Expression.AndAlso(nullCheck, result); - } - - return result; - } - - private static Expression? BuildPropertyFilterExpression( - Expression propertyAccess, - FilterParameter filter, - ILogger? logger = null - ) - { - Type targetType = propertyAccess.Type; - - 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 = BuildInExpression( - Expression.Property(propertyAccess, "Value"), - filter.Value, - underlying - ); - return Expression.AndAlso(notNullExpr, containsExpr); - } - return 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 = BuildInExpression( - Expression.Property(propertyAccess, "Value"), - filter.Value, - underlying - ); - return Expression.OrElse(isNullExpr, Expression.Not(containsExpr)); - } - return Expression.Not(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}", - 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 => BuildLikeExpression(propertyAccess, filter.Value), - _ => Expression.Equal(propertyAccess, constant), - }; - } -} diff --git a/JsonApiToolkit/Extensions/Querying/FilteredIncludeBuilder.cs b/JsonApiToolkit/Extensions/Querying/FilteredIncludeBuilder.cs deleted file mode 100644 index 166f054..0000000 --- a/JsonApiToolkit/Extensions/Querying/FilteredIncludeBuilder.cs +++ /dev/null @@ -1,582 +0,0 @@ -using System.Linq.Expressions; -using System.Reflection; -using JsonApiToolkit.Models.Querying.Filtering; -using Microsoft.EntityFrameworkCore; - -namespace JsonApiToolkit.Extensions.Querying; - -/// -/// Builds filtered Include expressions for EF Core queries. -/// Uses EF Core's filtered Include() to apply filters on relationships. -/// -public static class FilteredIncludeBuilder -{ - /// - /// Applies filtered includes using EF Core's Include().Where() pattern. - /// - public static IQueryable ApplyFilteredIncludes( - this IQueryable query, - List? includePaths, - List includeFilters - ) - where T : class - { - if (includePaths == null || includePaths.Count == 0) - return query; - - // Group filters by relationship path - var filtersByRelationship = includeFilters - .GroupBy(f => f.RelationshipPath, StringComparer.OrdinalIgnoreCase) - .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); - - // Sort include paths by depth (process shorter paths first) - var sortedPaths = includePaths.OrderBy(p => p.Count(c => c == '.')).ToList(); - - // Process each include path - foreach (var includePath in sortedPaths) - { - var segments = includePath.Split('.'); - - // Check if this path has filters - if ( - filtersByRelationship.TryGetValue(includePath, out var filters) - && filters.Count > 0 - ) - { - // Apply filtered include chain - query = ApplyFilteredIncludeChain(query, segments, filters, typeof(T)); - } - else - { - // Regular include without filters - use string-based Include - query = query.Include(includePath); - } - } - - return query; - } - - /// - /// Applies a filtered include chain for nested relationships. - /// For example: Include(v => v.Cve).ThenInclude(c => c.CveComments.Where(...)) - /// - private static IQueryable ApplyFilteredIncludeChain( - IQueryable query, - string[] pathSegments, - List filters, - Type rootType - ) - where T : class - { - if (pathSegments.Length == 0) - return query; - - if (pathSegments.Length == 1) - { - // Simple case - single level filtered include - return ApplyFilteredIncludeWithFilters(query, pathSegments[0], filters, rootType); - } - - // Multi-level case: need to build Include().ThenInclude().ThenInclude()... - // with filtering on the last level - return ApplyNestedFilteredInclude(query, pathSegments, filters, rootType); - } - - /// - /// Builds a nested Include/ThenInclude chain with filtering on the deepest level. - /// Uses string-based Include with manual query construction. - /// - private static IQueryable ApplyNestedFilteredInclude( - IQueryable query, - string[] pathSegments, - List filters, - Type rootType - ) - where T : class - { - // For 2-level nesting (e.g., cve.cvecomments), we can build the chain - if (pathSegments.Length == 2) - { - return ApplyTwoLevelFilteredInclude(query, pathSegments, filters, rootType); - } - - // For deeper nesting, use string-based include (no filtering) - // TODO: Implement full depth support - var fullPath = string.Join(".", pathSegments); - return query.Include(fullPath); - } - - /// - /// Special case for two-level includes: Include(x => x.Nav1).ThenInclude(x => x.Nav2.Where(...)) - /// - private static IQueryable ApplyTwoLevelFilteredInclude( - IQueryable query, - string[] pathSegments, - List filters, - Type rootType - ) - where T : class - { - var firstProperty = QueryHelpers.GetPropertyByJsonName(rootType, pathSegments[0]); - if (firstProperty == null) - return query.Include(string.Join(".", pathSegments)); - - var firstNavType = GetNavigationTargetType(firstProperty.PropertyType); - var secondProperty = QueryHelpers.GetPropertyByJsonName(firstNavType, pathSegments[1]); - if (secondProperty == null) - return query.Include(string.Join(".", pathSegments)); - - var isSecondCollection = IsCollectionType(secondProperty.PropertyType); - if (!isSecondCollection) - { - // Can't filter non-collection, use string-based include - return query.Include(string.Join(".", pathSegments)); - } - - var elementType = GetCollectionElementType(secondProperty.PropertyType); - if (elementType == null) - return query.Include(string.Join(".", pathSegments)); - - // Build: Include(v => v.FirstNav).ThenInclude(x => x.SecondNav.Where(filter)) - try - { - // Build Include expression for first nav - var rootParam = Expression.Parameter(rootType, "root"); - var firstNavAccess = Expression.Property(rootParam, firstProperty); - var includeLambda = Expression.Lambda(firstNavAccess, rootParam); - - // Apply the Include - var includeMethod = typeof(EntityFrameworkQueryableExtensions) - .GetMethods() - .First(m => - m.Name == "Include" - && m.GetParameters().Length == 2 - && m.GetParameters()[1].ParameterType.GetGenericTypeDefinition() - == typeof(Expression<>) - ) - .MakeGenericMethod(rootType, firstProperty.PropertyType); - - var includedQuery = includeMethod.Invoke(null, new object[] { query, includeLambda }); - - // Build ThenInclude expression with filtering - var navParam = Expression.Parameter(firstNavType, "nav"); - var secondNavAccess = Expression.Property(navParam, secondProperty); - - // Build filter: nav.SecondNav.Where(filter) - var filterParam = Expression.Parameter(elementType, "item"); - Expression? filterExpr = null; - - foreach (var filter in filters) - { - var singleFilterExpr = BuildSingleFilterExpression(filterParam, filter); - if (singleFilterExpr != null) - { - filterExpr = - filterExpr == null - ? singleFilterExpr - : Expression.OrElse(filterExpr, singleFilterExpr); - } - } - - if (filterExpr != null) - { - // Create Where lambda - var whereLambda = Expression.Lambda(filterExpr, filterParam); - - // Call Where on the collection - var whereMethod = typeof(Enumerable) - .GetMethods() - .First(m => m.Name == "Where" && m.GetParameters().Length == 2) - .MakeGenericMethod(elementType); - - var filteredCollection = Expression.Call(whereMethod, secondNavAccess, whereLambda); - var thenIncludeLambda = Expression.Lambda(filteredCollection, navParam); - - // Apply ThenInclude - var thenIncludeMethod = typeof(EntityFrameworkQueryableExtensions) - .GetMethods() - .First(m => m.Name == "ThenInclude" && m.GetGenericArguments().Length == 3) - .MakeGenericMethod(rootType, firstNavType, filteredCollection.Type); - - var result = thenIncludeMethod.Invoke( - null, - new[] { includedQuery, thenIncludeLambda } - ); - return (IQueryable)result!; - } - else - { - // No valid filter, use unfiltered ThenInclude - var thenIncludeLambda = Expression.Lambda(secondNavAccess, navParam); - - var thenIncludeMethod = typeof(EntityFrameworkQueryableExtensions) - .GetMethods() - .First(m => m.Name == "ThenInclude" && m.GetGenericArguments().Length == 3) - .MakeGenericMethod(rootType, firstNavType, secondProperty.PropertyType); - - var result = thenIncludeMethod.Invoke( - null, - new[] { includedQuery, thenIncludeLambda } - ); - return (IQueryable)result!; - } - } - catch - { - // Fallback to string-based include if expression building fails - return query.Include(string.Join(".", pathSegments)); - } - } - - private static LambdaExpression? BuildIncludeExpression(Type entityType, PropertyInfo property) - { - var parameter = Expression.Parameter(entityType, "x"); - var propertyAccess = Expression.Property(parameter, property); - return Expression.Lambda(propertyAccess, parameter); - } - - private static LambdaExpression? BuildThenIncludeExpression( - Type entityType, - PropertyInfo property, - Type previousResultType - ) - { - var parameter = Expression.Parameter(entityType, "x"); - var propertyAccess = Expression.Property(parameter, property); - return Expression.Lambda(propertyAccess, parameter); - } - - private static LambdaExpression? BuildFilteredThenIncludeExpression( - Type entityType, - PropertyInfo property, - List filters, - Type previousResultType - ) - { - var isCollection = IsCollectionType(property.PropertyType); - - if (!isCollection) - { - // Can't filter non-collection navigations - return BuildThenIncludeExpression(entityType, property, previousResultType); - } - - var elementType = GetCollectionElementType(property.PropertyType); - if (elementType == null) - return null; - - // Build: x => x.Navigation.Where(filter) - var parameter = Expression.Parameter(entityType, "x"); - var navigationAccess = Expression.Property(parameter, property); - var elementParameter = Expression.Parameter(elementType, "e"); - - // Build combined filter expression (OR logic for multiple filters) - Expression? filterExpression = null; - foreach (var filter in filters) - { - var singleFilterExpr = BuildSingleFilterExpression(elementParameter, filter); - if (singleFilterExpr != null) - { - filterExpression = - filterExpression == null - ? singleFilterExpr - : Expression.OrElse(filterExpression, singleFilterExpr); - } - } - - if (filterExpression == null) - return null; - - // Create Where lambda - var whereLambda = Expression.Lambda(filterExpression, elementParameter); - - // Call Where method - var whereMethod = typeof(Enumerable) - .GetMethods() - .First(m => m.Name == "Where" && m.GetParameters().Length == 2) - .MakeGenericMethod(elementType); - - var filteredCollection = Expression.Call(whereMethod, navigationAccess, whereLambda); - - return Expression.Lambda(filteredCollection, parameter); - } - - private static object ApplyIncludeToQuery(object query, LambdaExpression includeExpression) - { - var queryType = query.GetType().GetGenericArguments()[0]; - var navigationPropertyType = includeExpression.ReturnType; - - var includeMethod = typeof(EntityFrameworkQueryableExtensions) - .GetMethods() - .First(m => - m.Name == "Include" - && m.GetParameters().Length == 2 - && m.GetParameters()[1].ParameterType.GetGenericTypeDefinition() - == typeof(Expression<>) - ) - .MakeGenericMethod(queryType, navigationPropertyType); - - return includeMethod.Invoke(null, new[] { query, includeExpression })!; - } - - private static object ApplyThenIncludeToQuery( - object query, - LambdaExpression thenIncludeExpression, - Type previousEntityType, - Type previousNavigationType - ) - { - var queryType = query.GetType(); - var navigationPropertyType = thenIncludeExpression.ReturnType; - - // Find the right ThenInclude overload - var thenIncludeMethod = typeof(EntityFrameworkQueryableExtensions) - .GetMethods() - .FirstOrDefault(m => - m.Name == "ThenInclude" - && m.GetParameters().Length == 2 - && m.GetGenericArguments().Length == 3 - ); - - if (thenIncludeMethod == null) - return query; - - // Get the element type for collections - var previousEntityGenericType = - GetCollectionElementType(previousNavigationType) ?? previousNavigationType; - - var entityType = queryType.GetGenericArguments()[0]; // TEntity - var propertyType = thenIncludeExpression.ReturnType; // TProperty - - thenIncludeMethod = thenIncludeMethod.MakeGenericMethod( - entityType, - previousEntityGenericType, - propertyType - ); - - return thenIncludeMethod.Invoke(null, new[] { query, thenIncludeExpression })!; - } - - private static Type GetNavigationTargetType(Type navigationType) - { - return GetCollectionElementType(navigationType) ?? navigationType; - } - - private static IQueryable ApplyFilteredIncludeWithFilters( - IQueryable query, - string navigationPath, - List filters, - Type entityType - ) - where T : class - { - // Get the navigation property info - var navigationProperty = QueryHelpers.GetPropertyByJsonName(entityType, navigationPath); - if (navigationProperty == null) - return query.Include(navigationPath); // Fallback to regular include - - // Determine if it's a collection or single navigation - var propertyType = navigationProperty.PropertyType; - var isCollection = IsCollectionType(propertyType); - - if (isCollection) - { - // Build filtered include for collection - var elementType = GetCollectionElementType(propertyType); - if (elementType != null) - { - var includeExpression = BuildFilteredIncludeExpression( - entityType, - navigationProperty, - elementType, - filters - ); - - // Apply the filtered include - query = ApplyIncludeExpression(query, includeExpression); - } - else - { - // Fallback to regular include - query = query.Include(navigationPath); - } - } - else - { - // For single navigations, we can't filter - just include normally - query = query.Include(navigationPath); - } - - return query; - } - - private static Expression? BuildFilteredIncludeExpression( - Type entityType, - PropertyInfo navigationProperty, - Type elementType, - List filters - ) - { - // Create parameter for the main entity (e.g., Blog) - var entityParameter = Expression.Parameter(entityType, "e"); - - // Create the navigation property access (e.g., e.Posts) - var navigationAccess = Expression.Property(entityParameter, navigationProperty); - - // Create parameter for the collection element (e.g., Post) - var elementParameter = Expression.Parameter(elementType, "x"); - - // Build filter expression for the collection elements - Expression? filterExpression = null; - - foreach (var filter in filters) - { - var singleFilterExpr = BuildSingleFilterExpression(elementParameter, filter); - - if (singleFilterExpr != null) - { - filterExpression = - filterExpression == null - ? singleFilterExpr - : Expression.OrElse(filterExpression, singleFilterExpr); - } - } - - if (filterExpression == null) - return null; - - // Create the Where lambda: x => [filter expression] - var whereLambda = Expression.Lambda(filterExpression, elementParameter); - - // Get the Where method for IEnumerable - var whereMethod = typeof(Enumerable) - .GetMethods() - .First(m => m.Name == "Where" && m.GetParameters().Length == 2) - .MakeGenericMethod(elementType); - - // Create the filtered collection expression: navigation.Where(lambda) - var filteredCollection = Expression.Call(whereMethod, navigationAccess, whereLambda); - - // Create the final lambda: e => e.Navigation.Where(filter) - var includeLambda = Expression.Lambda(filteredCollection, entityParameter); - - return includeLambda; - } - - private static Expression? BuildSingleFilterExpression( - ParameterExpression parameter, - IncludeFilter filter - ) - { - // Get the property path within the related entity - var property = GetPropertyExpression(parameter, filter.FieldPath, parameter.Type); - if (property == null) - return null; - - // Use the existing FilterExpressionBuilder logic - var filterParam = new FilterParameter - { - Field = filter.FieldPath, - Operator = filter.Filter.Operator, - Value = filter.Filter.Value, - }; - - return FilterExpressionBuilder.BuildSingleFilterExpression(parameter, filterParam); - } - - private static IQueryable ApplyIncludeExpression( - IQueryable query, - Expression? includeExpression - ) - where T : class - { - if (includeExpression == null) - return query; - - // Get the return type of the lambda expression - var lambdaType = includeExpression.Type; - if (lambdaType.IsGenericType && lambdaType.GetGenericTypeDefinition() == typeof(Func<,>)) - { - var returnType = lambdaType.GetGenericArguments()[1]; - - // Use reflection to call the Include method with the expression - var includeMethod = typeof(EntityFrameworkQueryableExtensions) - .GetMethods() - .First(m => - m.Name == "Include" - && m.GetParameters().Length == 2 - && m.GetParameters()[1].ParameterType.GetGenericTypeDefinition() - == typeof(Expression<>) - ) - .MakeGenericMethod(typeof(T), returnType); - - return (IQueryable) - includeMethod.Invoke(null, new object[] { query, includeExpression })!; - } - - return query; - } - - 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; - } - - private static bool IsCollectionType(Type type) - { - if (type.IsGenericType) - { - var genericTypeDef = type.GetGenericTypeDefinition(); - return genericTypeDef == typeof(ICollection<>) - || genericTypeDef == typeof(IList<>) - || genericTypeDef == typeof(List<>) - || genericTypeDef == typeof(IEnumerable<>) - || genericTypeDef == typeof(HashSet<>) - || genericTypeDef == typeof(ISet<>); - } - - return type.IsArray - || type.GetInterfaces() - .Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)); - } - - private static Type? GetCollectionElementType(Type collectionType) - { - if (collectionType.IsArray) - return collectionType.GetElementType(); - - if (collectionType.IsGenericType) - { - return collectionType.GetGenericArguments().FirstOrDefault(); - } - - var enumerableInterface = collectionType - .GetInterfaces() - .FirstOrDefault(i => - i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>) - ); - - return enumerableInterface?.GetGenericArguments().FirstOrDefault(); - } -} diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionBuilder.cs b/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionBuilder.cs new file mode 100644 index 0000000..e7eb41c --- /dev/null +++ b/JsonApiToolkit/Extensions/Querying/Filtering/FilterExpressionBuilder.cs @@ -0,0 +1,147 @@ +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 + ) + { + 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( + typeof(T), + filter.Field + ); + if (property == null) + { + logger?.LogWarning( + "Property '{Field}' not found on {Type}, skipping filter", + filter.Field, + typeof(T).Name + ); + continue; + } + expr = BuildSingleFilterExpression(parameter, filter, logger); + } + + if (expr != null) + { + expressions.Add(expr); + } + else + { + logger?.LogWarning("Failed to build filter for '{Field}'", filter.Field); + } + } + + foreach (FilterGroup nestedGroup in group.Groups) + { + Expression? nestedExpr = BuildFilterExpression(nestedGroup, parameter, logger); + if (nestedExpr != null) + expressions.Add(nestedExpr); + } + + 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) + { + foreach (Expression expr in expressions) + { + var notExpr = Expression.Not(expr); + combinedExpression = + combinedExpression == null + ? notExpr + : Expression.OrElse(combinedExpression, notExpr); + } + } + else + { + foreach (Expression expr in expressions) + { + if (combinedExpression == null) + { + combinedExpression = expr; + } + else + { + combinedExpression = 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 NestedPropertyNavigator.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 NestedPropertyNavigator.BuildPropertyFilterExpression( + propertyAccess, + filter, + logger + ); + } +} diff --git a/JsonApiToolkit/Extensions/Querying/FilterHandler.cs b/JsonApiToolkit/Extensions/Querying/Filtering/FilterHandler.cs similarity index 100% rename from JsonApiToolkit/Extensions/Querying/FilterHandler.cs rename to JsonApiToolkit/Extensions/Querying/Filtering/FilterHandler.cs diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/FilterOperatorExpressions.cs b/JsonApiToolkit/Extensions/Querying/Filtering/FilterOperatorExpressions.cs new file mode 100644 index 0000000..69801ea --- /dev/null +++ b/JsonApiToolkit/Extensions/Querying/Filtering/FilterOperatorExpressions.cs @@ -0,0 +1,117 @@ +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) + { + if (property.Type == typeof(string)) + { + MethodInfo? method = typeof(string).GetMethod("Contains", [typeof(string)]); + return Expression.Call(property, method!, Expression.Constant(value)); + } + + 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(value) + ); + + 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(value)); + } + } + + 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 (Exception) + { + 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/IncludeFilterParser.cs b/JsonApiToolkit/Extensions/Querying/Filtering/IncludeFilterParser.cs similarity index 92% rename from JsonApiToolkit/Extensions/Querying/IncludeFilterParser.cs rename to JsonApiToolkit/Extensions/Querying/Filtering/IncludeFilterParser.cs index 95c0e2b..bd8be3d 100644 --- a/JsonApiToolkit/Extensions/Querying/IncludeFilterParser.cs +++ b/JsonApiToolkit/Extensions/Querying/Filtering/IncludeFilterParser.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using JsonApiToolkit.Models.Errors; using JsonApiToolkit.Models.Querying.Filtering; @@ -234,6 +233,14 @@ HashSet normalizedIncludePaths $"Cannot filter on '{includeFilter.RelationshipPath}' - relationship must be included in the request" ); } + + var depth = includeFilter.RelationshipPath.Count(c => c == '.') + 1; + if (depth > 2) + { + throw new JsonApiBadRequestException( + $"Filtered includes beyond 2 levels are not supported. Include path '{includeFilter.RelationshipPath}' has {depth} levels. Maximum supported: 2 levels." + ); + } } } } diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs b/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs new file mode 100644 index 0000000..83eac1c --- /dev/null +++ b/JsonApiToolkit/Extensions/Querying/Filtering/NestedPropertyNavigator.cs @@ -0,0 +1,182 @@ +using System.Linq.Expressions; +using System.Reflection; +using JsonApiToolkit.Models.Querying.Filtering; +using Microsoft.Extensions.Logging; + +namespace JsonApiToolkit.Extensions.Querying; + +internal static class NestedPropertyNavigator +{ + internal static Expression? BuildSafeNestedFilterExpression( + ParameterExpression parameter, + FilterParameter filter, + ILogger? logger = null + ) + { + 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); + + 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 = BuildPropertyFilterExpression(finalProperty, filter, logger); + if (filterExpression == null) + return null; + + Expression result; + 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); + result = Expression.OrElse(anyNull, notNullAndFilter); + } + else + { + result = filterExpression; + } + } + else + { + result = filterExpression; + foreach (Expression nullCheck in nullChecks) + result = Expression.AndAlso(nullCheck, result); + } + + return result; + } + + internal static Expression? BuildPropertyFilterExpression( + Expression propertyAccess, + FilterParameter filter, + ILogger? logger = null + ) + { + Type targetType = propertyAccess.Type; + + 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}", + 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/PaginationHandler.cs b/JsonApiToolkit/Extensions/Querying/Handlers/PaginationHandler.cs similarity index 100% rename from JsonApiToolkit/Extensions/Querying/PaginationHandler.cs rename to JsonApiToolkit/Extensions/Querying/Handlers/PaginationHandler.cs diff --git a/JsonApiToolkit/Extensions/Querying/SortingHandler.cs b/JsonApiToolkit/Extensions/Querying/Handlers/SortingHandler.cs similarity index 100% rename from JsonApiToolkit/Extensions/Querying/SortingHandler.cs rename to JsonApiToolkit/Extensions/Querying/Handlers/SortingHandler.cs diff --git a/JsonApiToolkit/Extensions/Querying/QueryHelpers.cs b/JsonApiToolkit/Extensions/Querying/Helpers/QueryHelpers.cs similarity index 100% rename from JsonApiToolkit/Extensions/Querying/QueryHelpers.cs rename to JsonApiToolkit/Extensions/Querying/Helpers/QueryHelpers.cs diff --git a/JsonApiToolkit/Extensions/Querying/Helpers/TypeHelpers.cs b/JsonApiToolkit/Extensions/Querying/Helpers/TypeHelpers.cs new file mode 100644 index 0000000..a76cfb7 --- /dev/null +++ b/JsonApiToolkit/Extensions/Querying/Helpers/TypeHelpers.cs @@ -0,0 +1,42 @@ +namespace JsonApiToolkit.Extensions.Querying; + +internal static class TypeHelpers +{ + internal static bool IsCollectionType(Type type) + { + if (type.IsGenericType) + { + var genericTypeDef = type.GetGenericTypeDefinition(); + return genericTypeDef == typeof(ICollection<>) + || genericTypeDef == typeof(IList<>) + || genericTypeDef == typeof(List<>) + || genericTypeDef == typeof(IEnumerable<>) + || genericTypeDef == typeof(HashSet<>) + || genericTypeDef == typeof(ISet<>); + } + + return type.IsArray + || type.GetInterfaces() + .Any(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + } + + internal static Type? GetCollectionElementType(Type collectionType) + { + if (collectionType.IsArray) + return collectionType.GetElementType(); + + if (collectionType.IsGenericType) + return collectionType.GetGenericArguments().FirstOrDefault(); + + var enumerableInterface = collectionType + .GetInterfaces() + .FirstOrDefault(i => + i.IsGenericType && i.GetGenericTypeDefinition() == typeof(IEnumerable<>) + ); + + return enumerableInterface?.GetGenericArguments().FirstOrDefault(); + } + + internal static Type GetNavigationTargetType(Type navigationType) => + GetCollectionElementType(navigationType) ?? navigationType; +} diff --git a/JsonApiToolkit/Extensions/Querying/Includes/EfCoreIncludeExpressions.cs b/JsonApiToolkit/Extensions/Querying/Includes/EfCoreIncludeExpressions.cs new file mode 100644 index 0000000..7c1e065 --- /dev/null +++ b/JsonApiToolkit/Extensions/Querying/Includes/EfCoreIncludeExpressions.cs @@ -0,0 +1,84 @@ +using System.Linq.Expressions; +using System.Reflection; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiToolkit.Extensions.Querying; + +internal static class EfCoreIncludeExpressions +{ + internal static MethodInfo GetThenIncludeMethod( + bool isPreviousCollection, + Type entityType, + Type previousPropertyType, + Type newPropertyType + ) + { + var thenIncludeMethods = typeof(EntityFrameworkQueryableExtensions) + .GetMethods() + .Where(m => m.Name == "ThenInclude" && m.GetGenericArguments().Length == 3) + .ToList(); + + foreach (var method in thenIncludeMethods) + { + var parameters = method.GetParameters(); + if (parameters.Length != 2) + continue; + + var firstParamType = parameters[0].ParameterType; + if ( + !firstParamType.IsGenericType + || firstParamType.GetGenericTypeDefinition().Name != "IIncludableQueryable`2" + ) + continue; + + var genericArgs = firstParamType.GetGenericArguments(); + if (genericArgs.Length != 2) + continue; + + var secondGenericArg = genericArgs[1]; + + bool isCollectionOverload = + secondGenericArg.IsGenericType + && secondGenericArg.GetGenericTypeDefinition() == typeof(IEnumerable<>); + + if (isCollectionOverload == isPreviousCollection) + return method.MakeGenericMethod(entityType, previousPropertyType, newPropertyType); + } + + return typeof(EntityFrameworkQueryableExtensions) + .GetMethods() + .First(m => m.Name == "ThenInclude" && m.GetGenericArguments().Length == 3) + .MakeGenericMethod(entityType, previousPropertyType, newPropertyType); + } + + internal static IQueryable ApplyIncludeExpression( + IQueryable query, + Expression? includeExpression + ) + where T : class + { + if (includeExpression == null) + return query; + + var lambdaType = includeExpression.Type; + if (lambdaType.IsGenericType && lambdaType.GetGenericTypeDefinition() == typeof(Func<,>)) + { + var returnType = lambdaType.GetGenericArguments()[1]; + + var includeMethod = typeof(EntityFrameworkQueryableExtensions) + .GetMethods() + .First(m => + m.Name == "Include" + && m.GetParameters().Length == 2 + && m.GetParameters()[1].ParameterType.GetGenericTypeDefinition() + == typeof(Expression<>) + ) + .MakeGenericMethod(typeof(T), returnType); + + return (IQueryable) + includeMethod.Invoke(null, new object[] { query, includeExpression })!; + } + + return query; + } +} diff --git a/JsonApiToolkit/Extensions/Querying/Includes/FilteredIncludeBuilder.cs b/JsonApiToolkit/Extensions/Querying/Includes/FilteredIncludeBuilder.cs new file mode 100644 index 0000000..8f38c97 --- /dev/null +++ b/JsonApiToolkit/Extensions/Querying/Includes/FilteredIncludeBuilder.cs @@ -0,0 +1,337 @@ +using System.Linq.Expressions; +using System.Reflection; +using JsonApiToolkit.Models.Querying.Filtering; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; + +namespace JsonApiToolkit.Extensions.Querying; + +/// +/// Builds filtered Include expressions for EF Core queries. +/// +public static class FilteredIncludeBuilder +{ + /// + /// Applies filtered includes using EF Core's Include().Where() pattern. + /// + public static IQueryable ApplyFilteredIncludes( + this IQueryable query, + List? includePaths, + List includeFilters, + ILogger? logger = null + ) + where T : class + { + if (includePaths == null || includePaths.Count == 0) + return query; + + var filtersByRelationship = includeFilters + .GroupBy(f => f.RelationshipPath, StringComparer.OrdinalIgnoreCase) + .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase); + + var sortedPaths = includePaths.OrderBy(p => p.Count(c => c == '.')).ToList(); + + foreach (var includePath in sortedPaths) + { + var segments = includePath.Split('.'); + + if ( + filtersByRelationship.TryGetValue(includePath, out var filters) + && filters.Count > 0 + ) + { + query = ApplyFilteredIncludeChain(query, segments, filters, typeof(T), logger); + } + else + { + query = query.Include(includePath); + } + } + + return query; + } + + private static IQueryable ApplyFilteredIncludeChain( + IQueryable query, + string[] pathSegments, + List filters, + Type rootType, + ILogger? logger + ) + where T : class + { + if (pathSegments.Length == 0) + return query; + + if (pathSegments.Length == 1) + return ApplyFilteredIncludeWithFilters(query, pathSegments[0], filters, rootType, logger); + + if (pathSegments.Length == 2) + return ApplyTwoLevelFilteredInclude(query, pathSegments, filters, rootType, logger); + + logger?.LogWarning( + "Filtered includes beyond 2 levels are not supported. Include path '{Path}' will use unfiltered include. Filters will be ignored.", + string.Join(".", pathSegments) + ); + return query.Include(string.Join(".", pathSegments)); + } + + private static IQueryable ApplyTwoLevelFilteredInclude( + IQueryable query, + string[] pathSegments, + List filters, + Type rootType, + ILogger? logger + ) + where T : class + { + var firstProperty = QueryHelpers.GetPropertyByJsonName(rootType, pathSegments[0]); + if (firstProperty == null) + return query.Include(string.Join(".", pathSegments)); + + var firstNavType = TypeHelpers.GetNavigationTargetType(firstProperty.PropertyType); + var secondProperty = QueryHelpers.GetPropertyByJsonName(firstNavType, pathSegments[1]); + if (secondProperty == null) + return query.Include(string.Join(".", pathSegments)); + + var isSecondCollection = TypeHelpers.IsCollectionType(secondProperty.PropertyType); + if (!isSecondCollection) + { + logger?.LogWarning( + "Cannot apply filters to reference navigation '{Path}'. Filters on single-valued navigations are not supported. Using unfiltered include.", + string.Join(".", pathSegments) + ); + return query.Include(string.Join(".", pathSegments)); + } + + var elementType = TypeHelpers.GetCollectionElementType(secondProperty.PropertyType); + if (elementType == null) + return query.Include(string.Join(".", pathSegments)); + + try + { + var rootParam = Expression.Parameter(rootType, "root"); + var firstNavAccess = Expression.Property(rootParam, firstProperty); + var includeLambda = Expression.Lambda(firstNavAccess, rootParam); + + var includeMethod = typeof(EntityFrameworkQueryableExtensions) + .GetMethods() + .First(m => + m.Name == "Include" + && m.GetParameters().Length == 2 + && m.GetParameters()[1].ParameterType.GetGenericTypeDefinition() + == typeof(Expression<>) + ) + .MakeGenericMethod(rootType, firstProperty.PropertyType); + + var includedQuery = includeMethod.Invoke(null, new object[] { query, includeLambda }); + + var navParam = Expression.Parameter(firstNavType, "nav"); + var secondNavAccess = Expression.Property(navParam, secondProperty); + + var filterParam = Expression.Parameter(elementType, "item"); + Expression? filterExpr = null; + + foreach (var filter in filters) + { + var singleFilterExpr = BuildSingleFilterExpression(filterParam, filter); + if (singleFilterExpr != null) + { + filterExpr = + filterExpr == null + ? singleFilterExpr + : Expression.OrElse(filterExpr, singleFilterExpr); + } + } + + if (filterExpr != null) + { + var whereLambda = Expression.Lambda(filterExpr, filterParam); + + var whereMethod = typeof(Enumerable) + .GetMethods() + .First(m => m.Name == "Where" && m.GetParameters().Length == 2) + .MakeGenericMethod(elementType); + + var filteredCollection = Expression.Call(whereMethod, secondNavAccess, whereLambda); + var thenIncludeLambda = Expression.Lambda(filteredCollection, navParam); + + var isFirstCollection = TypeHelpers.IsCollectionType(firstProperty.PropertyType); + var thenIncludeMethod = EfCoreIncludeExpressions.GetThenIncludeMethod( + isFirstCollection, + rootType, + firstNavType, + filteredCollection.Type + ); + + var result = thenIncludeMethod.Invoke( + null, + new[] { includedQuery, thenIncludeLambda } + ); + return (IQueryable)result!; + } + else + { + var thenIncludeLambda = Expression.Lambda(secondNavAccess, navParam); + + var isFirstCollection = TypeHelpers.IsCollectionType(firstProperty.PropertyType); + var thenIncludeMethod = EfCoreIncludeExpressions.GetThenIncludeMethod( + isFirstCollection, + rootType, + firstNavType, + secondProperty.PropertyType + ); + + var result = thenIncludeMethod.Invoke( + null, + new[] { includedQuery, thenIncludeLambda } + ); + return (IQueryable)result!; + } + } + catch (Exception ex) + { + logger?.LogWarning( + ex, + "Failed to build filtered include expression for '{Path}'. Falling back to unfiltered include.", + string.Join(".", pathSegments) + ); + return query.Include(string.Join(".", pathSegments)); + } + } + + private static IQueryable ApplyFilteredIncludeWithFilters( + IQueryable query, + string navigationPath, + List filters, + Type entityType, + ILogger? logger + ) + where T : class + { + var navigationProperty = QueryHelpers.GetPropertyByJsonName(entityType, navigationPath); + if (navigationProperty == null) + return query.Include(navigationPath); + + var propertyType = navigationProperty.PropertyType; + var isCollection = TypeHelpers.IsCollectionType(propertyType); + + if (isCollection) + { + var elementType = TypeHelpers.GetCollectionElementType(propertyType); + if (elementType != null) + { + var includeExpression = BuildFilteredIncludeExpression( + entityType, + navigationProperty, + elementType, + filters + ); + + query = EfCoreIncludeExpressions.ApplyIncludeExpression(query, includeExpression); + } + else + { + query = query.Include(navigationPath); + } + } + else + { + logger?.LogWarning( + "Cannot apply filters to reference navigation '{Path}'. Filters on single-valued navigations are not supported. Using unfiltered include.", + navigationPath + ); + query = query.Include(navigationPath); + } + + return query; + } + + private static Expression? BuildFilteredIncludeExpression( + Type entityType, + PropertyInfo navigationProperty, + Type elementType, + List filters + ) + { + var entityParameter = Expression.Parameter(entityType, "e"); + var navigationAccess = Expression.Property(entityParameter, navigationProperty); + var elementParameter = Expression.Parameter(elementType, "x"); + + Expression? filterExpression = null; + + foreach (var filter in filters) + { + var singleFilterExpr = BuildSingleFilterExpression(elementParameter, filter); + + if (singleFilterExpr != null) + { + filterExpression = + filterExpression == null + ? singleFilterExpr + : Expression.OrElse(filterExpression, singleFilterExpr); + } + } + + if (filterExpression == null) + return null; + + var whereLambda = Expression.Lambda(filterExpression, elementParameter); + + var whereMethod = typeof(Enumerable) + .GetMethods() + .First(m => m.Name == "Where" && m.GetParameters().Length == 2) + .MakeGenericMethod(elementType); + + var filteredCollection = Expression.Call(whereMethod, navigationAccess, whereLambda); + + var includeLambda = Expression.Lambda(filteredCollection, entityParameter); + + return includeLambda; + } + + private static Expression? BuildSingleFilterExpression( + ParameterExpression parameter, + IncludeFilter filter + ) + { + var property = GetPropertyExpression(parameter, filter.FieldPath, parameter.Type); + if (property == null) + return null; + + var filterParam = new FilterParameter + { + Field = filter.FieldPath, + Operator = filter.Filter.Operator, + Value = filter.Filter.Value, + }; + + return FilterExpressionBuilder.BuildSingleFilterExpression(parameter, filterParam); + } + + 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; + } +} diff --git a/JsonApiToolkit/JsonApiToolkit.csproj b/JsonApiToolkit/JsonApiToolkit.csproj index 3c46f2d..c074562 100644 --- a/JsonApiToolkit/JsonApiToolkit.csproj +++ b/JsonApiToolkit/JsonApiToolkit.csproj @@ -7,7 +7,7 @@ Intility.JsonApiToolkit - 1.1.6-rc1 + 1.1.7-rc3 Intility Intility A toolkit for implementing JSON:API specification in .NET applications