diff --git a/JsonApiToolkit.Tests/Extensions/IncludeFilterParserTests.cs b/JsonApiToolkit.Tests/Extensions/IncludeFilterParserTests.cs index 98acd5f..d8fc28f 100644 --- a/JsonApiToolkit.Tests/Extensions/IncludeFilterParserTests.cs +++ b/JsonApiToolkit.Tests/Extensions/IncludeFilterParserTests.cs @@ -64,7 +64,7 @@ public void SeparateIncludeFilters_WithOnlyMainFilters_ReturnsMainFilters() [Fact] public void SeparateIncludeFilters_WithSimpleIncludeFilter_SeparatesCorrectly() { - // Arrange + // Arrange - Include filters must have IsIncludeFilter=true (set by parser for bracket syntax) var filters = new FilterGroup { Filters = new List @@ -80,6 +80,7 @@ public void SeparateIncludeFilters_WithSimpleIncludeFilter_SeparatesCorrectly() Field = "comments.status", Operator = FilterOperator.Eq, Value = "approved", + IsIncludeFilter = true, // Bracket syntax: filter[comments][status][eq]=approved }, }, }; @@ -103,10 +104,51 @@ public void SeparateIncludeFilters_WithSimpleIncludeFilter_SeparatesCorrectly() Assert.Equal("approved", includeFilters[0].FilterGroup.Filters[0].Value); } + [Fact] + public void SeparateIncludeFilters_WithDotNotation_TreatedAsPrimaryFilter() + { + // Arrange - Dot notation filters are primary filters (filter main resource through relationship) + var filters = new FilterGroup + { + Filters = new List + { + new() + { + Field = "title", + Operator = FilterOperator.Eq, + Value = "Test", + }, + new() + { + Field = "comments.status", // Dot notation without IsIncludeFilter = primary filter + Operator = FilterOperator.Eq, + Value = "approved", + IsIncludeFilter = false, + }, + }, + }; + var includePaths = new List { "comments" }; + + // Act + var (mainFilters, includeFilters) = IncludeFilterParser.SeparateIncludeFilters( + filters, + includePaths + ); + + // Assert - Both filters should be main filters (dot notation = primary filter) + Assert.NotNull(mainFilters); + Assert.Equal(2, mainFilters.Filters.Count); + Assert.Contains(mainFilters.Filters, f => f.Field == "title"); + Assert.Contains(mainFilters.Filters, f => f.Field == "comments.status"); + + // No include filters - dot notation is now primary filter + Assert.Empty(includeFilters); + } + [Fact] public void SeparateIncludeFilters_WithKebabCaseInclude_HandlesCorrectly() { - // Arrange + // Arrange - Include filters must have IsIncludeFilter=true var filters = new FilterGroup { Filters = new List @@ -116,6 +158,7 @@ public void SeparateIncludeFilters_WithKebabCaseInclude_HandlesCorrectly() Field = "cveComments.companyCode", Operator = FilterOperator.Eq, Value = "AA", + IsIncludeFilter = true, // Bracket syntax: filter[cveComments][companyCode][eq]=AA }, }, }; @@ -137,7 +180,7 @@ public void SeparateIncludeFilters_WithKebabCaseInclude_HandlesCorrectly() [Fact] public void SeparateIncludeFilters_WithNestedIncludeFilter_SeparatesCorrectly() { - // Arrange + // Arrange - Include filters must have IsIncludeFilter=true var filters = new FilterGroup { Filters = new List @@ -147,6 +190,7 @@ public void SeparateIncludeFilters_WithNestedIncludeFilter_SeparatesCorrectly() Field = "comments.author.department", Operator = FilterOperator.Eq, Value = "Security", + IsIncludeFilter = true, // Bracket syntax for nested: filter[comments.author][department][eq]=Security }, }, }; @@ -168,7 +212,7 @@ public void SeparateIncludeFilters_WithNestedIncludeFilter_SeparatesCorrectly() [Fact] public void SeparateIncludeFilters_WithComplexOrFilter_HandlesCorrectly() { - // Arrange + // Arrange - Include filters must have IsIncludeFilter=true var filters = new FilterGroup { LogicalOperator = LogicalOperator.Or, @@ -179,12 +223,14 @@ public void SeparateIncludeFilters_WithComplexOrFilter_HandlesCorrectly() Field = "comments.companyCode", Operator = FilterOperator.Eq, Value = "AA", + IsIncludeFilter = true, }, new() { Field = "comments.companyCode", Operator = FilterOperator.IsNull, Value = "true", + IsIncludeFilter = true, }, }, }; @@ -201,13 +247,16 @@ public void SeparateIncludeFilters_WithComplexOrFilter_HandlesCorrectly() Assert.Equal("comments", includeFilters[0].RelationshipPath); Assert.Equal(LogicalOperator.Or, includeFilters[0].FilterGroup.LogicalOperator); Assert.Equal(2, includeFilters[0].FilterGroup.Filters.Count); - Assert.All(includeFilters[0].FilterGroup.Filters, f => Assert.Equal("companyCode", f.Field)); + Assert.All( + includeFilters[0].FilterGroup.Filters, + f => Assert.Equal("companyCode", f.Field) + ); } [Fact] public void SeparateIncludeFilters_WithFilterOnNonIncludedRelationship_ReturnsAsMainFilter() { - // Arrange + // Arrange - Even with IsIncludeFilter=true, if relationship is not included, it becomes main filter var filters = new FilterGroup { Filters = new List @@ -217,6 +266,7 @@ public void SeparateIncludeFilters_WithFilterOnNonIncludedRelationship_ReturnsAs Field = "comments.status", Operator = FilterOperator.Eq, Value = "approved", + IsIncludeFilter = true, // Marked as include filter but relationship not in includes }, }, }; @@ -229,7 +279,7 @@ public void SeparateIncludeFilters_WithFilterOnNonIncludedRelationship_ReturnsAs ); // Assert - // When the relationship is not included, the filter should be treated as a main filter with dot notation + // When the relationship is not included, the filter should be treated as a main filter Assert.NotNull(mainFilters); Assert.Single(mainFilters.Filters); Assert.Equal("comments.status", mainFilters.Filters[0].Field); @@ -239,7 +289,7 @@ public void SeparateIncludeFilters_WithFilterOnNonIncludedRelationship_ReturnsAs [Fact] public void SeparateIncludeFilters_WithTooDeepNesting_ThrowsException() { - // Arrange + // Arrange - Include filters must have IsIncludeFilter=true for depth checking var filters = new FilterGroup { Filters = new List @@ -249,7 +299,8 @@ public void SeparateIncludeFilters_WithTooDeepNesting_ThrowsException() Field = "a.b.c.d.e", Operator = FilterOperator.Eq, Value = "test", - }, // 5 levels deep + IsIncludeFilter = true, // 5 levels deep + }, }, }; var includePaths = new List { "a.b.c.d" }; @@ -265,7 +316,7 @@ public void SeparateIncludeFilters_WithTooDeepNesting_ThrowsException() [Fact] public void SeparateIncludeFilters_WithMixedMainAndIncludeFilters_SeparatesCorrectly() { - // Arrange + // Arrange - Include filters must have IsIncludeFilter=true var filters = new FilterGroup { Filters = new List @@ -281,6 +332,7 @@ public void SeparateIncludeFilters_WithMixedMainAndIncludeFilters_SeparatesCorre Field = "comments.approved", Operator = FilterOperator.Eq, Value = "true", + IsIncludeFilter = true, // Bracket syntax: filter[comments][approved][eq]=true }, new() { @@ -313,7 +365,7 @@ public void SeparateIncludeFilters_WithMixedMainAndIncludeFilters_SeparatesCorre [Fact] public void SeparateIncludeFilters_WithNestedGroups_HandlesCorrectly() { - // Arrange + // Arrange - Include filters must have IsIncludeFilter=true var filters = new FilterGroup { LogicalOperator = LogicalOperator.And, @@ -338,12 +390,14 @@ public void SeparateIncludeFilters_WithNestedGroups_HandlesCorrectly() Field = "comments.status", Operator = FilterOperator.Eq, Value = "approved", + IsIncludeFilter = true, }, new() { Field = "comments.status", Operator = FilterOperator.Eq, Value = "pending", + IsIncludeFilter = true, }, }, }, @@ -373,7 +427,7 @@ public void SeparateIncludeFilters_WithNestedGroups_HandlesCorrectly() [Fact] public void SeparateIncludeFilters_WithDeepNestedIncludeFilterUsingLeafName_SeparatesCorrectly() { - // Arrange - This tests the scenario: include=cve,cve.cvecomments&filter[cvecomments.companyCode][eq]=AA + // Arrange - This tests the scenario: include=cve,cve.cvecomments&filter[cvecomments][companyCode][eq]=AA var filters = new FilterGroup { Filters = new List @@ -383,6 +437,7 @@ public void SeparateIncludeFilters_WithDeepNestedIncludeFilterUsingLeafName_Sepa Field = "cvecomments.companyCode", Operator = FilterOperator.Eq, Value = "AA", + IsIncludeFilter = true, // Bracket syntax: filter[cvecomments][companyCode][eq]=AA }, }, }; @@ -416,6 +471,7 @@ public void SeparateIncludeFilters_WithDeepNestedIncludeFilterUsingKebabCase_Sep Field = "cveComments.companyCode", Operator = FilterOperator.Eq, Value = "AA", + IsIncludeFilter = true, // Bracket syntax: filter[cveComments][companyCode][eq]=AA }, }, }; @@ -449,12 +505,14 @@ public void SeparateIncludeFilters_WithMultipleDeepNestedFilters_SeparatesCorrec Field = "author.name", Operator = FilterOperator.Eq, Value = "John", + IsIncludeFilter = true, // Bracket syntax }, new() { Field = "comments.status", Operator = FilterOperator.Eq, Value = "approved", + IsIncludeFilter = true, // Bracket syntax }, }, }; diff --git a/JsonApiToolkit.Tests/Parsing/JsonApiQueryParserTests.cs b/JsonApiToolkit.Tests/Parsing/JsonApiQueryParserTests.cs index b63aa10..1a3bef4 100644 --- a/JsonApiToolkit.Tests/Parsing/JsonApiQueryParserTests.cs +++ b/JsonApiToolkit.Tests/Parsing/JsonApiQueryParserTests.cs @@ -6,6 +6,116 @@ namespace JsonApiToolkit.Tests.Parsing; +public class JsonApiFilterParserTests +{ + [Fact] + public void ParseComplexFilter_WithDotNotation_CreatesPrimaryFilter() + { + // Arrange - Dot notation: filter[rel.field][op]=value + var group = new FilterGroup(); + string key = "filter[vulnerability.severity][eq]"; + string value = "Critical"; + + // Act + JsonApiFilterParser.ParseComplexFilter(key, value, group); + + // Assert + Assert.Single(group.Filters); + var filter = group.Filters[0]; + Assert.Equal("vulnerability.severity", filter.Field); + Assert.Equal(FilterOperator.Eq, filter.Operator); + Assert.Equal("Critical", filter.Value); + Assert.False(filter.IsIncludeFilter); // Dot notation = primary filter + } + + [Fact] + public void ParseComplexFilter_WithBracketSyntax_CreatesIncludeFilter() + { + // Arrange - Bracket syntax: filter[rel][field][op]=value + var group = new FilterGroup(); + string key = "filter[vulnerability][severity][eq]"; + string value = "Critical"; + + // Act + JsonApiFilterParser.ParseComplexFilter(key, value, group); + + // Assert + Assert.Single(group.Filters); + var filter = group.Filters[0]; + Assert.Equal("vulnerability.severity", filter.Field); // Combined for downstream + Assert.Equal(FilterOperator.Eq, filter.Operator); + Assert.Equal("Critical", filter.Value); + Assert.True(filter.IsIncludeFilter); // Bracket syntax = include filter + } + + [Fact] + public void ParseComplexFilter_WithSimpleFilter_CreatesPrimaryFilter() + { + // Arrange - Simple filter: filter[field][op]=value + var group = new FilterGroup(); + string key = "filter[status][eq]"; + string value = "Active"; + + // Act + JsonApiFilterParser.ParseComplexFilter(key, value, group); + + // Assert + Assert.Single(group.Filters); + var filter = group.Filters[0]; + Assert.Equal("status", filter.Field); + Assert.Equal(FilterOperator.Eq, filter.Operator); + Assert.Equal("Active", filter.Value); + Assert.False(filter.IsIncludeFilter); + } + + [Fact] + public void ParseComplexFilter_WithAllOperators_ParsesCorrectly() + { + var testCases = new[] + { + ("eq", FilterOperator.Eq), + ("ne", FilterOperator.Ne), + ("gt", FilterOperator.Gt), + ("ge", FilterOperator.Ge), + ("lt", FilterOperator.Lt), + ("le", FilterOperator.Le), + ("like", FilterOperator.Like), + ("in", FilterOperator.In), + ("nin", FilterOperator.Nin), + ("isnull", FilterOperator.IsNull), + ("isnotnull", FilterOperator.IsNotNull), + }; + + foreach (var (opStr, expectedOp) in testCases) + { + var group = new FilterGroup(); + JsonApiFilterParser.ParseComplexFilter($"filter[field][{opStr}]", "value", group); + + Assert.Single(group.Filters); + Assert.Equal(expectedOp, group.Filters[0].Operator); + } + } + + [Fact] + public void ParseComplexFilter_WithNestedBracketSyntax_CreatesIncludeFilter() + { + // Arrange - Nested bracket syntax: filter[rel][nestedField][op]=value + var group = new FilterGroup(); + string key = "filter[comments][author.name][eq]"; + string value = "John"; + + // Act + JsonApiFilterParser.ParseComplexFilter(key, value, group); + + // Assert + Assert.Single(group.Filters); + var filter = group.Filters[0]; + Assert.Equal("comments.author.name", filter.Field); + Assert.Equal(FilterOperator.Eq, filter.Operator); + Assert.True(filter.IsIncludeFilter); + } +} + public class JsonApiQueryParserTests { [Fact] @@ -92,4 +202,169 @@ public void Parse_WithSort_ReturnsSortParameters() Assert.Equal("age", parameters.Sort[1].Field); Assert.True(parameters.Sort[1].IsDescending); } + + [Fact] + public void Parse_WithDotNotationFilter_CreatesPrimaryFilter() + { + // Dot notation: filter[rel.field][op]=value creates primary filter + var httpContext = new DefaultHttpContext(); + httpContext.Request.Query = new QueryCollection( + new Dictionary + { + ["filter[vulnerability.severity][eq]"] = "Critical", + } + ); + + QueryParameters parameters = JsonApiQueryParser.Parse(httpContext.Request); + + Assert.NotNull(parameters.Filter); + Assert.Single(parameters.Filter.Filters); + + var filter = parameters.Filter.Filters[0]; + Assert.Equal("vulnerability.severity", filter.Field); + Assert.Equal(FilterOperator.Eq, filter.Operator); + Assert.Equal("Critical", filter.Value); + Assert.False(filter.IsIncludeFilter); // Primary filter + } + + [Fact] + public void Parse_WithBracketSyntaxFilter_CreatesIncludeFilter() + { + // Bracket syntax: filter[rel][field][op]=value creates include filter + var httpContext = new DefaultHttpContext(); + httpContext.Request.Query = new QueryCollection( + new Dictionary + { + ["filter[vulnerability][severity][eq]"] = "Critical", + } + ); + + QueryParameters parameters = JsonApiQueryParser.Parse(httpContext.Request); + + Assert.NotNull(parameters.Filter); + Assert.Single(parameters.Filter.Filters); + + var filter = parameters.Filter.Filters[0]; + Assert.Equal("vulnerability.severity", filter.Field); + Assert.Equal(FilterOperator.Eq, filter.Operator); + Assert.Equal("Critical", filter.Value); + Assert.True(filter.IsIncludeFilter); // Include filter + } + + [Fact] + public void Parse_WithMixedFilterSyntax_CorrectlyIdentifiesFilterTypes() + { + // Mix of primary and include filters + var httpContext = new DefaultHttpContext(); + httpContext.Request.Query = new QueryCollection( + new Dictionary + { + ["filter[status][eq]"] = "Active", // Primary filter (simple) + ["filter[vulnerability.severity][eq]"] = "Critical", // Primary filter (dot notation) + ["filter[comments][status][eq]"] = "approved", // Include filter (bracket syntax) + } + ); + + QueryParameters parameters = JsonApiQueryParser.Parse(httpContext.Request); + + Assert.NotNull(parameters.Filter); + Assert.Equal(3, parameters.Filter.Filters.Count); + + var statusFilter = parameters.Filter.Filters.First(f => f.Field == "status"); + Assert.False(statusFilter.IsIncludeFilter); + + var vulnFilter = parameters.Filter.Filters.First(f => f.Field == "vulnerability.severity"); + Assert.False(vulnFilter.IsIncludeFilter); + + var commentsFilter = parameters.Filter.Filters.First(f => f.Field == "comments.status"); + Assert.True(commentsFilter.IsIncludeFilter); + } + + [Fact] + public void Parse_WithOrChainDotNotation_CreatesPrimaryFilters() + { + // OR chain with dot notation: filter[or][0][rel.field][op]=value + var httpContext = new DefaultHttpContext(); + httpContext.Request.Query = new QueryCollection( + new Dictionary + { + ["filter[or][0][vulnerability.severity][eq]"] = "Critical", + ["filter[or][1][vulnerability.severity][eq]"] = "High", + } + ); + + QueryParameters parameters = JsonApiQueryParser.Parse(httpContext.Request); + + Assert.NotNull(parameters.Filter); + Assert.Single(parameters.Filter.Groups); // One OR group + + var orGroup = parameters.Filter.Groups[0]; + Assert.Equal(LogicalOperator.Or, orGroup.LogicalOperator); + Assert.Equal(2, orGroup.Filters.Count); + + // Both should be primary filters (dot notation) + Assert.All(orGroup.Filters, f => + { + Assert.Equal("vulnerability.severity", f.Field); + Assert.False(f.IsIncludeFilter); + }); + } + + [Fact] + public void Parse_WithOrChainBracketSyntax_CreatesIncludeFilters() + { + // OR chain with bracket syntax: filter[or][0][rel][field][op]=value + var httpContext = new DefaultHttpContext(); + httpContext.Request.Query = new QueryCollection( + new Dictionary + { + ["filter[or][0][vulnerability][severity][eq]"] = "Critical", + ["filter[or][1][vulnerability][severity][eq]"] = "High", + } + ); + + QueryParameters parameters = JsonApiQueryParser.Parse(httpContext.Request); + + Assert.NotNull(parameters.Filter); + Assert.Single(parameters.Filter.Groups); + + var orGroup = parameters.Filter.Groups[0]; + Assert.Equal(LogicalOperator.Or, orGroup.LogicalOperator); + Assert.Equal(2, orGroup.Filters.Count); + + // Both should be include filters (bracket syntax) + Assert.All(orGroup.Filters, f => + { + Assert.Equal("vulnerability.severity", f.Field); + Assert.True(f.IsIncludeFilter); + }); + } + + [Fact] + public void Parse_WithOrChainMixedSyntax_CorrectlyIdentifiesFilterTypes() + { + // Mix of primary and include filters in OR chain + var httpContext = new DefaultHttpContext(); + httpContext.Request.Query = new QueryCollection( + new Dictionary + { + ["filter[or][0][vulnerability.severity][eq]"] = "Critical", // Primary (dot) + ["filter[or][1][comments][status][eq]"] = "approved", // Include (bracket) + } + ); + + QueryParameters parameters = JsonApiQueryParser.Parse(httpContext.Request); + + Assert.NotNull(parameters.Filter); + Assert.Single(parameters.Filter.Groups); + + var orGroup = parameters.Filter.Groups[0]; + Assert.Equal(2, orGroup.Filters.Count); + + var primaryFilter = orGroup.Filters.First(f => f.Field == "vulnerability.severity"); + Assert.False(primaryFilter.IsIncludeFilter); + + var includeFilter = orGroup.Filters.First(f => f.Field == "comments.status"); + Assert.True(includeFilter.IsIncludeFilter); + } } diff --git a/JsonApiToolkit/Extensions/Querying/Filtering/IncludeFilterParser.cs b/JsonApiToolkit/Extensions/Querying/Filtering/IncludeFilterParser.cs index ca30d94..4664192 100644 --- a/JsonApiToolkit/Extensions/Querying/Filtering/IncludeFilterParser.cs +++ b/JsonApiToolkit/Extensions/Querying/Filtering/IncludeFilterParser.cs @@ -26,7 +26,9 @@ List includeFilters var normalizedIncludePaths = NormalizeIncludePaths(includePaths ?? new List()); // Dictionary to group filters by relationship path - var filtersByRelationship = new Dictionary(StringComparer.OrdinalIgnoreCase); + var filtersByRelationship = new Dictionary( + StringComparer.OrdinalIgnoreCase + ); var mainFilters = ExtractIncludeFilters( filters, @@ -39,7 +41,7 @@ List includeFilters .Select(kvp => new IncludeFilter { RelationshipPath = kvp.Key, - FilterGroup = kvp.Value + FilterGroup = kvp.Value, }) .ToList(); @@ -57,13 +59,15 @@ Dictionary filtersByRelationship var newGroup = new FilterGroup { LogicalOperator = group.LogicalOperator }; // Track filters for each relationship in this group - var localIncludeFilters = new Dictionary(StringComparer.OrdinalIgnoreCase); + var localIncludeFilters = new Dictionary( + StringComparer.OrdinalIgnoreCase + ); foreach (var filter in group.Filters) { if ( IsIncludeFilter( - filter.Field, + filter, normalizedIncludePaths, out var relationshipPath, out var fieldPath @@ -75,7 +79,7 @@ out var fieldPath { localIncludeFilters[relationshipPath] = new FilterGroup { - LogicalOperator = group.LogicalOperator + LogicalOperator = group.LogicalOperator, }; } @@ -84,7 +88,7 @@ out var fieldPath { Field = fieldPath, Operator = filter.Operator, - Value = filter.Value + Value = filter.Value, }; localIncludeFilters[relationshipPath].Filters.Add(relativeFilter); @@ -127,9 +131,11 @@ out var fieldPath var existing = filtersByRelationship[kvp.Key]; // If both groups have the same operator and no nested groups, merge filters - if (existing.LogicalOperator == kvp.Value.LogicalOperator + if ( + existing.LogicalOperator == kvp.Value.LogicalOperator && existing.Groups.Count == 0 - && kvp.Value.Groups.Count == 0) + && kvp.Value.Groups.Count == 0 + ) { existing.Filters.AddRange(kvp.Value.Filters); } @@ -139,7 +145,7 @@ out var fieldPath var combined = new FilterGroup { LogicalOperator = LogicalOperator.And, - Groups = new List { existing, kvp.Value } + Groups = new List { existing, kvp.Value }, }; filtersByRelationship[kvp.Key] = combined; } @@ -154,7 +160,7 @@ out var fieldPath } private static bool IsIncludeFilter( - string field, + FilterParameter filter, HashSet normalizedIncludePaths, out string relationshipPath, out string fieldPath @@ -163,6 +169,12 @@ out string fieldPath relationshipPath = string.Empty; fieldPath = string.Empty; + // Only treat as include filter if explicitly marked via bracket syntax + // Dot notation filters are now primary filters (filter the main resource through relationships) + if (!filter.IsIncludeFilter) + return false; + + var field = filter.Field; if (!field.Contains('.')) return false; diff --git a/JsonApiToolkit/JsonApiToolkit.csproj b/JsonApiToolkit/JsonApiToolkit.csproj index 2f64b8e..a79619f 100644 --- a/JsonApiToolkit/JsonApiToolkit.csproj +++ b/JsonApiToolkit/JsonApiToolkit.csproj @@ -7,7 +7,7 @@ Intility.JsonApiToolkit - 1.1.8-rc1 + 1.1.22-local Intility Intility A toolkit for implementing JSON:API specification in .NET applications diff --git a/JsonApiToolkit/Models/Querying/Filtering/FilterParameter.cs b/JsonApiToolkit/Models/Querying/Filtering/FilterParameter.cs index 9ea27a6..f39a464 100644 --- a/JsonApiToolkit/Models/Querying/Filtering/FilterParameter.cs +++ b/JsonApiToolkit/Models/Querying/Filtering/FilterParameter.cs @@ -19,4 +19,12 @@ public class FilterParameter /// Value to compare against. /// public string Value { get; set; } = string.Empty; + + /// + /// Indicates if this filter should be applied to included relationships (filtered includes) + /// rather than filtering the primary resource. + /// When true: filters what gets included (e.g., filter[rel][field][op]=value bracket syntax) + /// When false: filters the primary resource, optionally navigating through relationships (dot notation) + /// + public bool IsIncludeFilter { get; set; } = false; } diff --git a/JsonApiToolkit/Parsing/JsonApiFilterParser.cs b/JsonApiToolkit/Parsing/JsonApiFilterParser.cs index f5185dc..6d3a3cf 100644 --- a/JsonApiToolkit/Parsing/JsonApiFilterParser.cs +++ b/JsonApiToolkit/Parsing/JsonApiFilterParser.cs @@ -33,29 +33,56 @@ private static FilterOperator ParseFilterOperator(string operatorStr) } /// - /// Parses filter[field][operator]=value syntax. + /// Parses filter syntax supporting both: + /// - Primary filter: filter[field][operator]=value or filter[rel.field][operator]=value (dot notation) + /// - Include filter: filter[rel][field][operator]=value (bracket syntax for filtering included relationships) /// public static void ParseComplexFilter(string key, string value, FilterGroup group) { string[] keyParts = key.Substring(7, key.Length - 8).Split("]["); - if (keyParts.Length != 2) - return; - string field = keyParts[0]; - string operatorStr = keyParts[1].ToLowerInvariant(); + // Standard primary filter: filter[field][operator]=value + if (keyParts.Length == 2) + { + string field = keyParts[0]; + string operatorStr = keyParts[1].ToLowerInvariant(); - var parameter = new FilterParameter + var parameter = new FilterParameter + { + Field = field, + Value = value, + Operator = ParseFilterOperator(operatorStr), + IsIncludeFilter = false, // Dot notation = primary filter + }; + + group.Filters.Add(parameter); + return; + } + + // Include filter syntax: filter[rel][field][operator]=value (3 parts) + if (keyParts.Length == 3) { - Field = field, - Value = value, - Operator = ParseFilterOperator(operatorStr), - }; + string relationship = keyParts[0]; + string field = keyParts[1]; + string operatorStr = keyParts[2].ToLowerInvariant(); + + var parameter = new FilterParameter + { + Field = $"{relationship}.{field}", // Combine for downstream processing + Value = value, + Operator = ParseFilterOperator(operatorStr), + IsIncludeFilter = true, // Bracket syntax = include filter + }; - group.Filters.Add(parameter); + group.Filters.Add(parameter); + } } /// /// Parses filter[or][0][field]=value or filter[not][0][field]=value syntax. + /// Supports both: + /// - Primary filter: filter[or][0][rel.field][op]=value (dot notation) + /// - Include filter: filter[or][0][rel][field][op]=value (bracket syntax) /// public static void ParseLogicalGroup( HttpRequest request, @@ -90,16 +117,35 @@ FilterGroup parentGroup $"filter[{groupName}][{indexGroup.Key}][".Length ); - if (restOfKey.Contains(s_separator[0])) + string[] parts = restOfKey.Split(s_separator, StringSplitOptions.None); + + if (parts.Length == 2) { - string[] parts = restOfKey.Split(s_separator, StringSplitOptions.None); + // Standard: filter[or][0][field][op]=value or filter[or][0][rel.field][op]=value condition.Field = parts[0]; condition.Operator = ParseFilterOperator(parts[1].TrimEnd(']')); + condition.IsIncludeFilter = false; // Dot notation = primary filter } - else + else if (parts.Length == 3) { + // Include filter: filter[or][0][rel][field][op]=value + string relationship = parts[0]; + string field = parts[1]; + condition.Field = $"{relationship}.{field}"; + condition.Operator = ParseFilterOperator(parts[2].TrimEnd(']')); + condition.IsIncludeFilter = true; // Bracket syntax = include filter + } + else if (parts.Length == 1) + { + // Simple: filter[or][0][field]=value (implicit eq) condition.Field = restOfKey.TrimEnd(']'); condition.Operator = FilterOperator.Eq; + condition.IsIncludeFilter = false; + } + else + { + // Unsupported format, skip + continue; } condition.Value = request.Query[item.Key].ToString(); diff --git a/docs/docs/querying.md b/docs/docs/querying.md index 45b0136..44f87d5 100644 --- a/docs/docs/querying.md +++ b/docs/docs/querying.md @@ -40,11 +40,27 @@ JsonApiToolkit provides robust support for JSON:API querying, including filterin 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` - - Nested includes: `GET /api/vulnerabilities?include=cve,cve.cvecomments&filter[cvecomments.companyCode][eq]=AA` +- **Relationship-Based Primary Filtering (Dot Notation):** + Filter the **primary resource** based on attributes of related resources using dot notation. This is useful when you want to find records based on their relationships. + + - `GET /api/books?filter[author.country][eq]=UK` - Returns only books by UK authors + - `GET /api/books?filter[publisher.name][like]=Penguin` - Returns only books from publishers containing "Penguin" + - OR chains: `GET /api/books?filter[or][0][author.country][eq]=UK&filter[or][1][author.country][eq]=US` + +> [!NOTE] +> Dot notation filters always apply to the **primary resource**, even when the relationship is included in the response. The related data is still included, but only matching primary records are returned. + +- **Filtering on Includes (Bracket Syntax):** + Filter **included resources** using bracket syntax. This applies filters directly to what gets included, not what primary records are returned. + + - `GET /api/books?include=reviews&filter[reviews][status][eq]=approved` - Returns all books, but only includes approved reviews + - Complex filters: `GET /api/books?include=reviews&filter[or][0][reviews][rating][gte]=4&filter[or][1][reviews][featured][eq]=true` + - Nested includes: `GET /api/authors?include=books,books.reviews&filter[reviews][verified][eq]=true` + +> [!TIP] +> **Syntax Summary:** +> - `filter[relationship.field][op]=value` (dot notation) → Filters the **primary resource** +> - `filter[relationship][field][op]=value` (bracket syntax) → Filters **included resources** > [!NOTE] > Filtered includes currently support up to 2-level nesting (e.g., `parent.child`). Deeper nesting will fall back to unfiltered includes. @@ -74,7 +90,10 @@ With this request, the toolkit will: - Return the first 10 results. - Include related author and reviews data in the response. -**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. +**Note:** +- Filters without dot notation apply only to the main resource type (books in this example). +- Filters with **dot notation** (e.g., `filter[author.name][eq]=Tolkien`) filter the **primary resource** based on relationship attributes. +- Filters with **bracket syntax** (e.g., `filter[reviews][status][eq]=approved`) filter **what gets included** in the response. ## Limitations