diff --git a/JsonApiToolkit.Tests/Extensions/FilteredIncludeBuilderTests.cs b/JsonApiToolkit.Tests/Extensions/FilteredIncludeBuilderTests.cs new file mode 100644 index 0000000..488ea11 --- /dev/null +++ b/JsonApiToolkit.Tests/Extensions/FilteredIncludeBuilderTests.cs @@ -0,0 +1,212 @@ +using JsonApiToolkit.Extensions.Querying; +using JsonApiToolkit.Models.Querying.Filtering; +using Microsoft.EntityFrameworkCore; +using Xunit; + +namespace JsonApiToolkit.Tests.Extensions; + +public class FilteredIncludeBuilderTests +{ + [Fact] + public void ApplyFilteredIncludes_WithNoIncludePaths_ReturnsOriginalQuery() + { + // Arrange + var query = CreateMockQueryable(); + List? includePaths = null; + var includeFilters = new List(); + + // Act + var result = query.ApplyFilteredIncludes(includePaths, includeFilters); + + // Assert + Assert.Same(query, result); + } + + [Fact] + public void ApplyFilteredIncludes_WithEmptyIncludePaths_ReturnsOriginalQuery() + { + // Arrange + var query = CreateMockQueryable(); + var includePaths = new List(); + var includeFilters = new List(); + + // Act + var result = query.ApplyFilteredIncludes(includePaths, includeFilters); + + // Assert + Assert.Same(query, result); + } + + [Fact] + public void ApplyFilteredIncludes_WithIncludePathsButNoFilters_AppliesRegularIncludes() + { + // Arrange + var query = CreateMockQueryable(); + var includePaths = new List { "comments", "tags" }; + var includeFilters = new List(); + + // Act + var result = query.ApplyFilteredIncludes(includePaths, includeFilters); + + // Assert + // This would normally test that Include was called, but since we're using a mock + // we can't easily verify the EF Core Include calls without a real context + Assert.NotNull(result); + } + + [Fact] + public void ApplyFilteredIncludes_WithSimpleIncludeFilter_BuildsCorrectExpression() + { + // Arrange + var query = CreateMockQueryable(); + var includePaths = new List { "comments" }; + var includeFilters = new List + { + new() + { + RelationshipPath = "comments", + FieldPath = "status", + Filter = new FilterParameter + { + Field = "status", + Operator = FilterOperator.Eq, + Value = "approved", + }, + }, + }; + + // Act + var result = query.ApplyFilteredIncludes(includePaths, includeFilters); + + // Assert + Assert.NotNull(result); + // In a real test with EF Core, we would verify the generated SQL contains WHERE clause + } + + [Fact] + public void ApplyFilteredIncludes_WithMultipleFiltersOnSameRelationship_CombinesFilters() + { + // Arrange + var query = CreateMockQueryable(); + var includePaths = new List { "comments" }; + var includeFilters = new List + { + new() + { + RelationshipPath = "comments", + FieldPath = "status", + Filter = new FilterParameter + { + Field = "status", + Operator = FilterOperator.Eq, + Value = "approved", + }, + }, + new() + { + RelationshipPath = "comments", + FieldPath = "priority", + Filter = new FilterParameter + { + Field = "priority", + Operator = FilterOperator.Gt, + Value = "5", + }, + }, + }; + + // Act + var result = query.ApplyFilteredIncludes(includePaths, includeFilters); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void ApplyFilteredIncludes_WithNestedIncludePath_HandlesCorrectly() + { + // Arrange + var query = CreateMockQueryable(); + var includePaths = new List { "comments.author" }; + var includeFilters = new List(); + + // Act + var result = query.ApplyFilteredIncludes(includePaths, includeFilters); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void ApplyFilteredIncludes_WithMixedFilteredAndUnfilteredIncludes_HandlesCorrectly() + { + // Arrange + var query = CreateMockQueryable(); + var includePaths = new List { "comments", "tags", "author" }; + var includeFilters = new List + { + new() + { + RelationshipPath = "comments", + FieldPath = "status", + Filter = new FilterParameter + { + Field = "status", + Operator = FilterOperator.Eq, + Value = "approved", + }, + }, + // tags and author have no filters + }; + + // Act + var result = query.ApplyFilteredIncludes(includePaths, includeFilters); + + // Assert + Assert.NotNull(result); + } + + private static IQueryable CreateMockQueryable() + where T : class + { + // Create a minimal mock queryable for testing + // In a real scenario, this would be an EF Core DbSet or similar + var data = new List().AsQueryable(); + return data; + } + + // Test entity classes for testing + public class TestEntity + { + public int Id { get; set; } + public string Title { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public List Comments { get; set; } = new(); + public List Tags { get; set; } = new(); + public Author? Author { get; set; } + } + + public class Comment + { + public int Id { get; set; } + public string Content { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public int Priority { get; set; } + public Author? Author { get; set; } + public string? CompanyCode { get; set; } + } + + public class Tag + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + } + + public class Author + { + public int Id { get; set; } + public string Name { get; set; } = string.Empty; + public string Department { get; set; } = string.Empty; + public string Role { get; set; } = string.Empty; + } +} diff --git a/JsonApiToolkit.Tests/Extensions/IncludeFilterParserTests.cs b/JsonApiToolkit.Tests/Extensions/IncludeFilterParserTests.cs new file mode 100644 index 0000000..06d16f8 --- /dev/null +++ b/JsonApiToolkit.Tests/Extensions/IncludeFilterParserTests.cs @@ -0,0 +1,396 @@ +using JsonApiToolkit.Extensions.Querying; +using JsonApiToolkit.Models.Errors; +using JsonApiToolkit.Models.Querying.Filtering; +using Xunit; + +namespace JsonApiToolkit.Tests.Extensions; + +public class IncludeFilterParserTests +{ + [Fact] + public void SeparateIncludeFilters_WithNoFilters_ReturnsEmpty() + { + // Arrange + FilterGroup? filters = null; + var includePaths = new List { "comments" }; + + // Act + var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( + filters, + includePaths + ); + + // Assert + Assert.Null(mainFilters); + Assert.Empty(includeFilters); + } + + [Fact] + public void SeparateIncludeFilters_WithOnlyMainFilters_ReturnsMainFilters() + { + // Arrange + var filters = new FilterGroup + { + Filters = new List + { + new() + { + Field = "title", + Operator = FilterOperator.Eq, + Value = "Test", + }, + new() + { + Field = "status", + Operator = FilterOperator.Eq, + Value = "active", + }, + }, + }; + var includePaths = new List { "comments" }; + + // Act + var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( + filters, + includePaths + ); + + // Assert + Assert.NotNull(mainFilters); + Assert.Equal(2, mainFilters.Filters.Count); + Assert.Empty(includeFilters); + } + + [Fact] + public void SeparateIncludeFilters_WithSimpleIncludeFilter_SeparatesCorrectly() + { + // Arrange + var filters = new FilterGroup + { + Filters = new List + { + new() + { + Field = "title", + Operator = FilterOperator.Eq, + Value = "Test", + }, + new() + { + Field = "comments.status", + Operator = FilterOperator.Eq, + Value = "approved", + }, + }, + }; + var includePaths = new List { "comments" }; + + // Act + var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( + filters, + includePaths + ); + + // Assert + Assert.NotNull(mainFilters); + Assert.Single(mainFilters.Filters); + Assert.Equal("title", mainFilters.Filters[0].Field); + + Assert.Single(includeFilters); + Assert.Equal("comments", includeFilters[0].RelationshipPath); + Assert.Equal("status", includeFilters[0].FieldPath); + Assert.Equal("approved", includeFilters[0].Filter.Value); + } + + [Fact] + public void SeparateIncludeFilters_WithKebabCaseInclude_HandlesCorrectly() + { + // Arrange + var filters = new FilterGroup + { + Filters = new List + { + new() + { + Field = "cveComments.companyCode", + Operator = FilterOperator.Eq, + Value = "AA", + }, + }, + }; + var includePaths = new List { "cve-comments" }; + + // Act + var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( + filters, + includePaths + ); + + // Assert + Assert.Single(includeFilters); + Assert.Equal("cveComments", includeFilters[0].RelationshipPath); + Assert.Equal("companyCode", includeFilters[0].FieldPath); + } + + [Fact] + public void SeparateIncludeFilters_WithNestedIncludeFilter_SeparatesCorrectly() + { + // Arrange + var filters = new FilterGroup + { + Filters = new List + { + new() + { + Field = "comments.author.department", + Operator = FilterOperator.Eq, + Value = "Security", + }, + }, + }; + var includePaths = new List { "comments.author" }; + + // Act + var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( + filters, + includePaths + ); + + // Assert + Assert.Single(includeFilters); + Assert.Equal("comments.author", includeFilters[0].RelationshipPath); + Assert.Equal("department", includeFilters[0].FieldPath); + } + + [Fact] + public void SeparateIncludeFilters_WithComplexOrFilter_HandlesCorrectly() + { + // Arrange + var filters = new FilterGroup + { + LogicalOperator = LogicalOperator.Or, + Filters = new List + { + new() + { + Field = "comments.companyCode", + Operator = FilterOperator.Eq, + Value = "AA", + }, + new() + { + Field = "comments.companyCode", + Operator = FilterOperator.IsNull, + Value = "true", + }, + }, + }; + var includePaths = new List { "comments" }; + + // Act + var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( + filters, + includePaths + ); + + // Assert + Assert.Equal(2, includeFilters.Count); + Assert.All(includeFilters, f => Assert.Equal("comments", f.RelationshipPath)); + Assert.All(includeFilters, f => Assert.Equal("companyCode", f.FieldPath)); + } + + [Fact] + public void SeparateIncludeFilters_WithFilterOnNonIncludedRelationship_ReturnsAsMainFilter() + { + // Arrange + var filters = new FilterGroup + { + Filters = new List + { + new() + { + Field = "comments.status", + Operator = FilterOperator.Eq, + Value = "approved", + }, + }, + }; + var includePaths = new List { "author" }; // comments not included + + // Act + var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( + filters, + includePaths + ); + + // Assert + // When the relationship is not included, the filter should be treated as a main filter with dot notation + Assert.NotNull(mainFilters); + Assert.Single(mainFilters.Filters); + Assert.Equal("comments.status", mainFilters.Filters[0].Field); + Assert.Empty(includeFilters); + } + + [Fact] + public void SeparateIncludeFilters_WithTooManyOrConditions_ThrowsException() + { + // Arrange + var filters = new FilterGroup + { + LogicalOperator = LogicalOperator.Or, + Filters = new List(), + }; + + // Add 11 OR conditions (exceeds limit of 10) + for (int i = 0; i < 11; i++) + { + filters.Filters.Add( + new FilterParameter + { + Field = "comments.status", + Operator = FilterOperator.Eq, + Value = $"value{i}", + } + ); + } + + var includePaths = new List { "comments" }; + + // Act & Assert + var exception = Assert.Throws( + () => IncludeFilterParser.SeparateIncludeFilters(filters, includePaths) + ); + + Assert.Contains("Too many OR conditions", exception.Message); + } + + [Fact] + public void SeparateIncludeFilters_WithTooDeepNesting_ThrowsException() + { + // Arrange + var filters = new FilterGroup + { + Filters = new List + { + new() + { + Field = "a.b.c.d.e", + Operator = FilterOperator.Eq, + Value = "test", + }, // 5 levels deep + }, + }; + var includePaths = new List { "a.b.c.d" }; + + // Act & Assert + var exception = Assert.Throws( + () => IncludeFilterParser.SeparateIncludeFilters(filters, includePaths) + ); + + Assert.Contains("Filter depth exceeds maximum", exception.Message); + } + + [Fact] + public void SeparateIncludeFilters_WithMixedMainAndIncludeFilters_SeparatesCorrectly() + { + // Arrange + var filters = new FilterGroup + { + Filters = new List + { + new() + { + Field = "status", + Operator = FilterOperator.Eq, + Value = "active", + }, + new() + { + Field = "comments.approved", + Operator = FilterOperator.Eq, + Value = "true", + }, + new() + { + Field = "priority", + Operator = FilterOperator.Gt, + Value = "5", + }, + }, + }; + var includePaths = new List { "comments", "author" }; + + // Act + var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( + filters, + includePaths + ); + + // Assert + Assert.NotNull(mainFilters); + Assert.Equal(2, mainFilters.Filters.Count); + Assert.Contains(mainFilters.Filters, f => f.Field == "status"); + Assert.Contains(mainFilters.Filters, f => f.Field == "priority"); + + Assert.Single(includeFilters); + Assert.Equal("comments", includeFilters[0].RelationshipPath); + Assert.Equal("approved", includeFilters[0].FieldPath); + } + + [Fact] + public void SeparateIncludeFilters_WithNestedGroups_HandlesCorrectly() + { + // Arrange + var filters = new FilterGroup + { + LogicalOperator = LogicalOperator.And, + Filters = new List + { + new() + { + Field = "title", + Operator = FilterOperator.Eq, + Value = "Test", + }, + }, + Groups = new List + { + new FilterGroup + { + LogicalOperator = LogicalOperator.Or, + Filters = new List + { + new() + { + Field = "comments.status", + Operator = FilterOperator.Eq, + Value = "approved", + }, + new() + { + Field = "comments.status", + Operator = FilterOperator.Eq, + Value = "pending", + }, + }, + }, + }, + }; + var includePaths = new List { "comments" }; + + // Act + var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( + filters, + includePaths + ); + + // Assert + Assert.NotNull(mainFilters); + Assert.Single(mainFilters.Filters); + Assert.Equal("title", mainFilters.Filters[0].Field); + + Assert.Equal(2, includeFilters.Count); + Assert.All(includeFilters, f => Assert.Equal("comments", f.RelationshipPath)); + Assert.All(includeFilters, f => Assert.Equal("status", f.FieldPath)); + } +} diff --git a/JsonApiToolkit.Tests/Filters/JsonApiExceptionFilterTests.cs b/JsonApiToolkit.Tests/Filters/JsonApiExceptionFilterTests.cs index 12f6ca5..a16cbfc 100644 --- a/JsonApiToolkit.Tests/Filters/JsonApiExceptionFilterTests.cs +++ b/JsonApiToolkit.Tests/Filters/JsonApiExceptionFilterTests.cs @@ -23,12 +23,13 @@ public JsonApiExceptionFilterTests() private ExceptionContext CreateExceptionContext(Exception exception) { var httpContext = new DefaultHttpContext(); - var actionContext = new ActionContext(httpContext, new Microsoft.AspNetCore.Routing.RouteData(), new ActionDescriptor()); - - return new ExceptionContext(actionContext, []) - { - Exception = exception - }; + var actionContext = new ActionContext( + httpContext, + new Microsoft.AspNetCore.Routing.RouteData(), + new ActionDescriptor() + ); + + return new ExceptionContext(actionContext, []) { Exception = exception }; } [Fact] @@ -43,7 +44,7 @@ public void OnException_WithJsonApiBadRequestException_Returns400() var result = Assert.IsType(context.Result); Assert.Equal(400, result.StatusCode); var errorResponse = Assert.IsType(result.Value); - + Assert.Single(errorResponse.Errors); Assert.Equal("400", errorResponse.Errors[0].Status); Assert.Equal("Bad Request", errorResponse.Errors[0].Title); @@ -62,7 +63,7 @@ public void OnException_WithJsonApiNotFoundException_Returns404() var result = Assert.IsType(context.Result); Assert.Equal(404, result.StatusCode); var errorResponse = Assert.IsType(result.Value); - + Assert.Single(errorResponse.Errors); Assert.Equal("404", errorResponse.Errors[0].Status); Assert.Equal("Not Found", errorResponse.Errors[0].Title); @@ -81,7 +82,7 @@ public void OnException_WithJsonApiConflictException_Returns409() var result = Assert.IsType(context.Result); Assert.Equal(409, result.StatusCode); var errorResponse = Assert.IsType(result.Value); - + Assert.Single(errorResponse.Errors); Assert.Equal("409", errorResponse.Errors[0].Status); Assert.Equal("Conflict", errorResponse.Errors[0].Title); @@ -100,7 +101,7 @@ public void OnException_WithJsonApiUnauthorizedException_Returns401() var result = Assert.IsType(context.Result); Assert.Equal(401, result.StatusCode); var errorResponse = Assert.IsType(result.Value); - + Assert.Single(errorResponse.Errors); Assert.Equal("401", errorResponse.Errors[0].Status); Assert.Equal("Unauthorized", errorResponse.Errors[0].Title); @@ -118,7 +119,7 @@ public void OnException_WithJsonApiForbiddenException_Returns403() Assert.True(context.ExceptionHandled); var result = Assert.IsType(context.Result); Assert.Equal(403, result.StatusCode); - + var errorResponse = Assert.IsType(result.Value); Assert.Single(errorResponse.Errors); Assert.Equal("403", errorResponse.Errors[0].Status); @@ -137,7 +138,7 @@ public void OnException_WithJsonApiTooManyRequestsException_Returns429() Assert.True(context.ExceptionHandled); var result = Assert.IsType(context.Result); Assert.Equal(429, result.StatusCode); - + var errorResponse = Assert.IsType(result.Value); Assert.Single(errorResponse.Errors); Assert.Equal("429", errorResponse.Errors[0].Status); @@ -156,12 +157,15 @@ public void OnException_WithUnhandledException_Returns500() Assert.True(context.ExceptionHandled); var result = Assert.IsType(context.Result); Assert.Equal(500, result.StatusCode); - + var errorResponse = Assert.IsType(result.Value); Assert.Single(errorResponse.Errors); Assert.Equal("500", errorResponse.Errors[0].Status); Assert.Equal("Internal Server Error", errorResponse.Errors[0].Title); - Assert.Equal("An error occurred while processing your request.", errorResponse.Errors[0].Detail); + Assert.Equal( + "An error occurred while processing your request.", + errorResponse.Errors[0].Detail + ); } [Fact] @@ -173,13 +177,18 @@ public void OnException_WithHandledException_LogsWithoutStackTrace() _filter.OnException(context); _mockLogger.Verify( - x => x.Log( - LogLevel.Information, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("JsonApiNotFoundException")), - null, - It.IsAny>()), - Times.Once); + x => + x.Log( + LogLevel.Information, + It.IsAny(), + It.Is( + (v, t) => v.ToString()!.Contains("JsonApiNotFoundException") + ), + null, + It.IsAny>() + ), + Times.Once + ); } [Fact] @@ -191,12 +200,17 @@ public void OnException_WithUnhandledException_LogsWithStackTrace() _filter.OnException(context); _mockLogger.Verify( - x => x.Log( - LogLevel.Error, - It.IsAny(), - It.Is((v, t) => v.ToString()!.Contains("An unhandled exception occurred")), - exception, - It.IsAny>()), - Times.Once); + x => + x.Log( + LogLevel.Error, + It.IsAny(), + It.Is( + (v, t) => v.ToString()!.Contains("An unhandled exception occurred") + ), + exception, + It.IsAny>() + ), + Times.Once + ); } } diff --git a/JsonApiToolkit.Tests/Mapping/EntityMapperTests.cs b/JsonApiToolkit.Tests/Mapping/EntityMapperTests.cs index 213176e..72a3dae 100644 --- a/JsonApiToolkit.Tests/Mapping/EntityMapperTests.cs +++ b/JsonApiToolkit.Tests/Mapping/EntityMapperTests.cs @@ -13,10 +13,10 @@ public void GetAttributeProperties_IncludesForeignKeyIds() // Should include foreign key ID Assert.Contains("RelatedEntityId", propertyNames); - + // Should NOT include the primary ID Assert.DoesNotContain("Id", propertyNames); - + // Should include other regular properties Assert.Contains("Name", propertyNames); Assert.Contains("Description", propertyNames); @@ -33,10 +33,10 @@ public void GetAttributeProperties_ExcludesOnlyPrimaryId() // Should include foreign key ID Assert.Contains("TestEntityId", propertyNames); - + // Should NOT include the primary ID Assert.DoesNotContain("Id", propertyNames); - + // Should include other properties Assert.Contains("Name", propertyNames); } @@ -50,7 +50,7 @@ public void GetRelationshipProperties_DoesNotIncludeForeignKeyIds() // Should include actual relationships Assert.Contains("RelatedEntity", propertyNames); Assert.Contains("Children", propertyNames); - + // Should NOT include foreign key IDs Assert.DoesNotContain("RelatedEntityId", propertyNames); } @@ -59,8 +59,8 @@ public void GetRelationshipProperties_DoesNotIncludeForeignKeyIds() public void GetIdProperty_IdentifiesPrimaryId() { var idProperty = EntityMapper.GetIdProperty(typeof(TestEntity)); - + Assert.NotNull(idProperty); Assert.Equal("Id", idProperty.Name); } -} \ No newline at end of file +} diff --git a/JsonApiToolkit/Controllers/JsonApiController.cs b/JsonApiToolkit/Controllers/JsonApiController.cs index aa486e4..f3d9cda 100644 --- a/JsonApiToolkit/Controllers/JsonApiController.cs +++ b/JsonApiToolkit/Controllers/JsonApiController.cs @@ -143,12 +143,29 @@ string resourceType var mappedIncludes = EfIncludePathHelper.MapIncludePathsToClrProperties( parameters.Include ); - queryable = queryable.ApplyIncludes(mappedIncludes); + + // Separate include filters from main filters + var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( + parameters.Filter, + parameters.Include + ); + + // Apply filtered includes if we have any include filters + if (includeFilters.Count > 0) + { + queryable = queryable.ApplyFilteredIncludes(mappedIncludes, includeFilters); + } + else + { + // Regular includes without filters + queryable = queryable.ApplyIncludes(mappedIncludes); + } IQueryable filteredQuery = queryable; - if (parameters.Filter != null) - filteredQuery = filteredQuery.ApplyFilters(parameters.Filter); + // Apply only the main entity filters (include filters were already applied) + if (mainFilters != null) + filteredQuery = filteredQuery.ApplyFilters(mainFilters); if (parameters.Sort?.Count > 0) filteredQuery = filteredQuery.ApplySorting(parameters.Sort); diff --git a/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs b/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs index e6e6d34..2b9e194 100644 --- a/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs +++ b/JsonApiToolkit/Extensions/Querying/FilterExpressionBuilder.cs @@ -104,7 +104,13 @@ ParameterExpression parameter return combinedExpression; } - private static Expression? BuildSingleFilterExpression( + /// + /// Builds a filter expression for a single FilterParameter. + /// + /// The parameter expression representing the entity + /// The filter parameter to build an expression for + /// An expression representing the filter condition, or null if the filter cannot be applied + public static Expression? BuildSingleFilterExpression( ParameterExpression parameter, FilterParameter filter ) diff --git a/JsonApiToolkit/Extensions/Querying/FilteredIncludeBuilder.cs b/JsonApiToolkit/Extensions/Querying/FilteredIncludeBuilder.cs new file mode 100644 index 0000000..4ad0753 --- /dev/null +++ b/JsonApiToolkit/Extensions/Querying/FilteredIncludeBuilder.cs @@ -0,0 +1,322 @@ +using System.Linq.Expressions; +using System.Reflection; +using JsonApiToolkit.Models.Querying.Filtering; +using Microsoft.EntityFrameworkCore; + +namespace JsonApiToolkit.Extensions.Querying; + +/// +/// Builds filtered Include expressions for Entity Framework Core queries. +/// +public static class FilteredIncludeBuilder +{ + /// + /// Applies filtered includes to a queryable, using EF Core's filtered Include functionality. + /// + /// The entity type of the queryable + /// The source queryable + /// The list of relationships to include + /// The filters to apply to included relationships + /// A queryable with filtered includes applied + 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); + + // Process each include path + foreach (var includePath in includePaths) + { + var segments = includePath.Split('.'); + query = ApplyFilteredInclude(query, segments, filtersByRelationship, typeof(T)); + } + + return query; + } + + private static IQueryable ApplyFilteredInclude( + IQueryable query, + string[] pathSegments, + Dictionary> filtersByRelationship, + Type rootType + ) + where T : class + { + if (pathSegments.Length == 0) + return query; + + var currentPath = pathSegments[0]; + var fullPath = currentPath; + + // Build the Include expression + var parameter = Expression.Parameter(rootType, "x"); + var includeProperty = GetPropertyExpression(parameter, currentPath, rootType); + + if (includeProperty == null) + return query; + + // Check if we have filters for this relationship + if (filtersByRelationship.TryGetValue(fullPath, out var filters) && filters.Count > 0) + { + // Apply filtered include + query = ApplyFilteredIncludeWithFilters(query, currentPath, filters, rootType); + } + else + { + // Regular include without filters + query = query.Include(currentPath); + } + + // Handle nested includes recursively + if (pathSegments.Length > 1) + { + var remainingPath = string.Join(".", pathSegments.Skip(1)); + + // For nested includes, we need to check if there are filters at deeper levels + var nestedPath = string.Join(".", pathSegments.Take(2)); + if ( + filtersByRelationship.TryGetValue(nestedPath, out var nestedFilters) + && nestedFilters.Count > 0 + ) + { + // We have filters at a deeper level - this requires special handling + // For now, we'll include the nested path normally + query = query.Include($"{currentPath}.{remainingPath}"); + } + else + { + // Regular nested include + query = query.Include($"{currentPath}.{remainingPath}"); + } + } + + return query; + } + + 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/IncludeFilterParser.cs b/JsonApiToolkit/Extensions/Querying/IncludeFilterParser.cs new file mode 100644 index 0000000..68e9e3c --- /dev/null +++ b/JsonApiToolkit/Extensions/Querying/IncludeFilterParser.cs @@ -0,0 +1,235 @@ +using System.Text.RegularExpressions; +using JsonApiToolkit.Models.Errors; +using JsonApiToolkit.Models.Querying.Filtering; + +namespace JsonApiToolkit.Extensions.Querying; + +/// +/// Provides functionality to parse and separate filters that target included resources from main entity filters. +/// +public static class IncludeFilterParser +{ + private const int MaxIncludeFilterDepth = 3; + private const int MaxIncludeFilters = 20; + private const int MaxOrConditions = 10; + + /// + /// Separates filters targeting included resources from filters targeting the main entity. + /// + /// The original filter group containing all filters + /// The list of include paths requested in the query + /// + /// A tuple containing the main entity filters and a list of filters for included resources + /// + /// + /// Thrown when filters reference relationships that aren't included or exceed complexity limits + /// + public static ( + FilterGroup? mainFilters, + List includeFilters + ) SeparateIncludeFilters(FilterGroup? filters, List? includePaths) + { + if (filters == null) + return (null, new List()); + + var includeFilters = new List(); + var normalizedIncludePaths = NormalizeIncludePaths(includePaths ?? new List()); + + var mainFilters = ExtractIncludeFilters(filters, normalizedIncludePaths, includeFilters); + + ValidateIncludeFilters(includeFilters, normalizedIncludePaths); + + return (mainFilters, includeFilters); + } + + private static FilterGroup? ExtractIncludeFilters( + FilterGroup group, + HashSet normalizedIncludePaths, + List includeFilters + ) + { + var newGroup = new FilterGroup { LogicalOperator = group.LogicalOperator }; + + // Check OR conditions count + if (group.LogicalOperator == LogicalOperator.Or && group.Filters.Count > MaxOrConditions) + { + throw new JsonApiBadRequestException( + $"Too many OR conditions in filter group. Maximum allowed: {MaxOrConditions}" + ); + } + + foreach (var filter in group.Filters) + { + if ( + IsIncludeFilter( + filter.Field, + normalizedIncludePaths, + out var relationshipPath, + out var fieldPath + ) + ) + { + includeFilters.Add( + new IncludeFilter + { + RelationshipPath = relationshipPath, + FieldPath = fieldPath, + Filter = filter, + } + ); + } + else + { + newGroup.Filters.Add(filter); + } + } + + foreach (var nestedGroup in group.Groups) + { + var processedNestedGroup = ExtractIncludeFilters( + nestedGroup, + normalizedIncludePaths, + includeFilters + ); + + if ( + processedNestedGroup != null + && (processedNestedGroup.Filters.Count > 0 || processedNestedGroup.Groups.Count > 0) + ) + { + newGroup.Groups.Add(processedNestedGroup); + } + } + + // Return null if the group is empty after extraction + if (newGroup.Filters.Count == 0 && newGroup.Groups.Count == 0) + return null; + + return newGroup; + } + + private static bool IsIncludeFilter( + string field, + HashSet normalizedIncludePaths, + out string relationshipPath, + out string fieldPath + ) + { + relationshipPath = string.Empty; + fieldPath = string.Empty; + + if (!field.Contains('.')) + return false; + + var parts = field.Split('.'); + + // Check filter depth + if (parts.Length > MaxIncludeFilterDepth + 1) + { + throw new JsonApiBadRequestException( + $"Filter depth exceeds maximum allowed depth of {MaxIncludeFilterDepth} for field: {field}" + ); + } + + // Try to match progressively longer relationship paths + for (int i = parts.Length - 1; i >= 1; i--) + { + var potentialRelationship = string.Join(".", parts.Take(i)); + var normalizedPotential = ConvertKebabToCamelCase(potentialRelationship); + + if ( + normalizedIncludePaths.Any(path => + path.Equals(normalizedPotential, StringComparison.OrdinalIgnoreCase) + || path.StartsWith( + normalizedPotential + ".", + StringComparison.OrdinalIgnoreCase + ) + ) + ) + { + relationshipPath = potentialRelationship; + fieldPath = string.Join(".", parts.Skip(i)); + return true; + } + } + + return false; + } + + private static HashSet NormalizeIncludePaths(List includePaths) + { + var normalized = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var path in includePaths) + { + // Convert kebab-case to camelCase for comparison + var normalizedPath = ConvertKebabToCamelCase(path); + normalized.Add(normalizedPath); + } + + return normalized; + } + + private static string ConvertKebabToCamelCase(string kebabCase) + { + if (string.IsNullOrEmpty(kebabCase)) + return kebabCase; + + var parts = kebabCase.Split('.'); + var convertedParts = parts.Select(part => + { + if (!part.Contains('-')) + return part; + + var segments = part.Split('-'); + var result = segments[0].ToLowerInvariant(); + + for (int i = 1; i < segments.Length; i++) + { + if (segments[i].Length > 0) + { + result += + char.ToUpperInvariant(segments[i][0]) + + segments[i].Substring(1).ToLowerInvariant(); + } + } + + return result; + }); + + return string.Join(".", convertedParts); + } + + private static void ValidateIncludeFilters( + List includeFilters, + HashSet normalizedIncludePaths + ) + { + if (includeFilters.Count > MaxIncludeFilters) + { + throw new JsonApiBadRequestException( + $"Too many include filters. Maximum allowed: {MaxIncludeFilters}" + ); + } + + foreach (var includeFilter in includeFilters) + { + var normalizedRelationship = ConvertKebabToCamelCase(includeFilter.RelationshipPath); + + if ( + !normalizedIncludePaths.Any(path => + path.Equals(normalizedRelationship, StringComparison.OrdinalIgnoreCase) + || path.StartsWith( + normalizedRelationship + ".", + StringComparison.OrdinalIgnoreCase + ) + ) + ) + { + throw new JsonApiBadRequestException( + $"Cannot filter on '{includeFilter.RelationshipPath}' - relationship must be included in the request" + ); + } + } + } +} diff --git a/JsonApiToolkit/Extensions/Querying/PaginationHandler.cs b/JsonApiToolkit/Extensions/Querying/PaginationHandler.cs index c8d888f..94d3d96 100644 --- a/JsonApiToolkit/Extensions/Querying/PaginationHandler.cs +++ b/JsonApiToolkit/Extensions/Querying/PaginationHandler.cs @@ -32,10 +32,10 @@ PaginationParameters pagination // Calculate total count and pages to determine valid page range int totalCount = query.Count(); int totalPages = (int)Math.Ceiling(totalCount / (double)pagination.Size); - + // Clamp page number to valid range (1 to totalPages, default to 1 if empty) int effectivePage = Math.Max(1, Math.Min(pagination.Number, Math.Max(totalPages, 1))); - + int skip = (effectivePage - 1) * pagination.Size; return query.Skip(skip).Take(pagination.Size); } @@ -66,7 +66,7 @@ PaginationParameters pagination // Fallback for in-memory queryables that don't support async operations totalCount = query.Count(); } - + int totalPages = (int)Math.Ceiling(totalCount / (double)pagination.Size); // Clamp page number to valid range (1 to totalPages, default to 1 if empty) diff --git a/JsonApiToolkit/JsonApiToolkit.csproj b/JsonApiToolkit/JsonApiToolkit.csproj index 8231ed9..c48321a 100644 --- a/JsonApiToolkit/JsonApiToolkit.csproj +++ b/JsonApiToolkit/JsonApiToolkit.csproj @@ -7,7 +7,7 @@ Intility.JsonApiToolkit - 0.1.12 + 1.0.1 Intility Intility A toolkit for implementing JSON:API specification in .NET applications diff --git a/JsonApiToolkit/Models/Querying/Filtering/IncludeFilter.cs b/JsonApiToolkit/Models/Querying/Filtering/IncludeFilter.cs new file mode 100644 index 0000000..92864ea --- /dev/null +++ b/JsonApiToolkit/Models/Querying/Filtering/IncludeFilter.cs @@ -0,0 +1,44 @@ +namespace JsonApiToolkit.Models.Querying.Filtering; + +/// +/// Represents a filter that should be applied to an included relationship in a JSON:API query. +/// +/// +/// Include filters allow filtering of related resources that are being included in the response. +/// For example, when including comments on a post, you can filter to only include comments from a specific author. +/// +public class IncludeFilter +{ + /// + /// The relationship path to filter on, using JSON property names. + /// + /// + /// This is the navigation property path from the main entity to the relationship being filtered. + /// For example: "cveComments" for a direct relationship, or "cveComments.author" for a nested relationship. + /// + public string RelationshipPath { get; set; } = string.Empty; + + /// + /// The field path within the related entity to filter on. + /// + /// + /// This is the property path within the related entity that should be filtered. + /// Can be a simple property name like "companyCode" or a nested path like "author.department". + /// + public string FieldPath { get; set; } = string.Empty; + + /// + /// The filter parameter containing the operator and value for the filter condition. + /// + public FilterParameter Filter { get; set; } = new(); + + /// + /// The full field path from the original filter parameter. + /// + /// + /// This combines RelationshipPath and FieldPath with a dot separator. + /// For example: "cveComments.companyCode" + /// + public string FullPath => + string.IsNullOrEmpty(FieldPath) ? RelationshipPath : $"{RelationshipPath}.{FieldPath}"; +} diff --git a/JsonApiToolkit/Parsing/JsonApiQueryParser.cs b/JsonApiToolkit/Parsing/JsonApiQueryParser.cs index cf4e0a5..9fe91f1 100644 --- a/JsonApiToolkit/Parsing/JsonApiQueryParser.cs +++ b/JsonApiToolkit/Parsing/JsonApiQueryParser.cs @@ -37,6 +37,7 @@ public static class JsonApiQueryParser private const int DEFAULT_PAGE_SIZE = 10; private const int MIN_PAGE_SIZE = 1; private const int MAX_PAGE_SIZE = 100; + /// /// Parses all JSON:API query parameters from an HTTP request into a structured QueryParameters object. /// @@ -106,7 +107,9 @@ public static QueryParameters Parse(HttpRequest request) queryParams.Pagination = new PaginationParameters { Number = int.TryParse(pageNumber, out int num) ? Math.Max(1, num) : 1, - Size = int.TryParse(pageSize, out int size) ? Math.Clamp(size, MIN_PAGE_SIZE, MAX_PAGE_SIZE) : DEFAULT_PAGE_SIZE, + Size = int.TryParse(pageSize, out int size) + ? Math.Clamp(size, MIN_PAGE_SIZE, MAX_PAGE_SIZE) + : DEFAULT_PAGE_SIZE, }; } diff --git a/docs/docs/querying.md b/docs/docs/querying.md index ca4c375..03ac829 100644 --- a/docs/docs/querying.md +++ b/docs/docs/querying.md @@ -39,6 +39,11 @@ JsonApiToolkit provides robust support for JSON:API querying, including filterin - **Inclusion (`include`):** Specify which related resources should be included in the response. - Example: `GET /api/books?include=author,reviews` + +- **Filtering on Includes (Advanced):** + Filter included resources using dot notation. This feature applies filters directly to the included relationships at the database level. + - Example: `GET /api/books?include=reviews&filter[reviews.status][eq]=approved` + - Complex filters: `GET /api/books?include=reviews&filter[or][0][reviews.rating][gte]=4&filter[or][1][reviews.featured][eq]=true` ## How It Works @@ -65,11 +70,12 @@ With this request, the toolkit will: - Return the first 10 results. - Include related author and reviews data in the response. -**Note:** Filtering applies only to the main resource type (books in this example). The `include` parameter controls which related resources are loaded in the response, but does not affect which main resources are returned by the filters. +**Note:** Filters without dot notation apply only to the main resource type (books in this example). Filters with dot notation (e.g., `filter[reviews.status][eq]=approved`) filter the included resources themselves. ## Limitations -- **Filtering on included resources**: Filters only apply to the main resource type. To filter based on related entity properties, structure your query at the main entity level or use custom controller logic. +- **Include filter validation**: Filters with dot notation can only be applied to relationships that are explicitly included in the request. Use the `AllowedIncludesAttribute` to control which relationships can be filtered. +- **Complex nested filtering**: Maximum filter depth is 3 levels (e.g., `entity.relationship.property`). ## Attribute Mapping