From 42866b922b94d06263de13d16be238240ed55b8a Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 23:39:35 +0300 Subject: [PATCH 01/24] fix(parser): turn the filter length and nesting limits off by default v1.14.2 parsed a filter of any length and any nesting depth. The default limits of 5000 characters and 32 levels rejected filters that v1.14.2 accepted. Set both defaults to int.MaxValue, so the limits are an opt-in through MaxInputLength and MaxNestingDepth. A later major version can turn them on by default again. --- QueryKit.UnitTests/ParseLimitsTests.cs | 48 ++++++++++------------ QueryKit/Configuration/QueryKitSettings.cs | 11 ++++- README.md | 2 +- 3 files changed, 32 insertions(+), 29 deletions(-) diff --git a/QueryKit.UnitTests/ParseLimitsTests.cs b/QueryKit.UnitTests/ParseLimitsTests.cs index 723fb3c..3b0bfd8 100644 --- a/QueryKit.UnitTests/ParseLimitsTests.cs +++ b/QueryKit.UnitTests/ParseLimitsTests.cs @@ -17,15 +17,21 @@ public void filter_within_default_nesting_depth_parses() } [Fact] - public void filter_over_default_nesting_depth_throws() + public void filter_over_33_nesting_levels_parses_by_default() { - var input = new string('(', QueryKitSettings.DefaultMaxNestingDepth + 1) - + """Title == "salt" """ - + new string(')', QueryKitSettings.DefaultMaxNestingDepth + 1); + var input = new string('(', 33) + """Title == "salt" """ + new string(')', 33); - var act = () => FilterParser.ParseFilter(input); - act.Should().Throw() - .WithMessage($"*depth of {QueryKitSettings.DefaultMaxNestingDepth + 1}*maximum allowed depth of {QueryKitSettings.DefaultMaxNestingDepth}*"); + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.Should().NotBeNull(); + } + + [Fact] + public void quoted_value_with_33_parentheses_parses_by_default() + { + var input = $"""Title == "{new string('(', 33)}" """; + + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.Should().NotBeNull(); } [Fact] @@ -65,14 +71,13 @@ public void filter_within_default_input_length_parses() } [Fact] - public void filter_over_default_input_length_throws() + public void filter_over_5000_characters_parses_by_default() { - var padding = new string('a', QueryKitSettings.DefaultMaxInputLength); + var padding = new string('a', 5000); var input = $"""Title == "{padding}" """; - var act = () => FilterParser.ParseFilter(input); - act.Should().Throw() - .WithMessage($"*length of {input.Length}*maximum allowed length of {QueryKitSettings.DefaultMaxInputLength}*"); + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.Should().NotBeNull(); } [Fact] @@ -103,24 +108,15 @@ public void filter_within_configured_input_length_parses() } [Fact] - public void configuration_that_implements_only_the_interface_uses_the_default_limits() + public void configuration_that_implements_only_the_interface_has_no_limits() { var config = new FilterBehaviorInterfaceTests.InterfaceOnlyConfiguration(); - var filterExpression = FilterParser.ParseFilter("""Title == "salt" """, config); - filterExpression.Should().NotBeNull(); + var deep = new string('(', 33) + """Title == "salt" """ + new string(')', 33); + FilterParser.ParseFilter(deep, config).Should().NotBeNull(); - var tooDeep = new string('(', QueryKitSettings.DefaultMaxNestingDepth + 1) - + """Title == "salt" """ - + new string(')', QueryKitSettings.DefaultMaxNestingDepth + 1); - var actDeep = () => FilterParser.ParseFilter(tooDeep, config); - actDeep.Should().Throw() - .WithMessage($"*maximum allowed depth of {QueryKitSettings.DefaultMaxNestingDepth}*"); - - var tooLong = $"""Title == "{new string('a', QueryKitSettings.DefaultMaxInputLength)}" """; - var actLong = () => FilterParser.ParseFilter(tooLong, config); - actLong.Should().Throw() - .WithMessage($"*maximum allowed length of {QueryKitSettings.DefaultMaxInputLength}*"); + var longInput = $"""Title == "{new string('a', 5000)}" """; + FilterParser.ParseFilter(longInput, config).Should().NotBeNull(); } [Fact] diff --git a/QueryKit/Configuration/QueryKitSettings.cs b/QueryKit/Configuration/QueryKitSettings.cs index fcb2fef..e346083 100644 --- a/QueryKit/Configuration/QueryKitSettings.cs +++ b/QueryKit/Configuration/QueryKitSettings.cs @@ -5,8 +5,15 @@ namespace QueryKit.Configuration; public class QueryKitSettings { - public const int DefaultMaxNestingDepth = 32; - public const int DefaultMaxInputLength = 5000; + /// + /// The default nesting depth limit is off. Set to turn the limit on. + /// + public const int DefaultMaxNestingDepth = int.MaxValue; + + /// + /// The default input length limit is off. Set to turn the limit on. + /// + public const int DefaultMaxInputLength = int.MaxValue; public QueryKitPropertyMappings PropertyMappings { get; set; } = new QueryKitPropertyMappings(); public string EqualsOperator { get; set; } = ComparisonOperator.EqualsOperator().Operator(); diff --git a/README.md b/README.md index 8027b84..2899dc1 100644 --- a/README.md +++ b/README.md @@ -823,7 +823,7 @@ var filterExpression = FilterParser.ParseFilter(input, config); #### Parse Limits -`IQueryKitParseLimits` caps how much a filter string can do before QueryKit parses it, through `MaxInputLength` (default `5000` characters) and `MaxNestingDepth` (default `32` levels of parentheses). `QueryKitConfiguration` implements this interface, so a custom configuration class can implement `IQueryKitParseLimits` directly instead. A filter string that goes over `MaxInputLength` throws a `QueryKitInputLengthExceededException`. A filter string that goes over `MaxNestingDepth` throws a `QueryKitNestingDepthExceededException`. Both exceptions throw before parsing starts. These limits apply only to filter strings. Sort strings have no limit, and the number of items in an in-list has no limit. The nesting-depth check counts every `(` character, including a `(` inside a quoted value. +`IQueryKitParseLimits` caps how much a filter string can do before QueryKit parses it, through `MaxInputLength` (a number of characters) and `MaxNestingDepth` (a number of levels of parentheses). Both limits are off by default. If your app sends user input to QueryKit, turn both limits on. A filter string with a few thousand nested parentheses can overflow the call stack and stop the process. `QueryKitConfiguration` implements this interface, so a custom configuration class can implement `IQueryKitParseLimits` directly instead. A filter string that goes over `MaxInputLength` throws a `QueryKitInputLengthExceededException`. A filter string that goes over `MaxNestingDepth` throws a `QueryKitNestingDepthExceededException`. Both exceptions throw before parsing starts. These limits apply only to filter strings. Sort strings have no limit, and the number of items in an in-list has no limit. The nesting-depth check counts every `(` character, including a `(` inside a quoted value. ```csharp var config = new QueryKitConfiguration(config => From a947ebb3f9b28704757d82f68866aa9f3b0c6f63 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 23:40:05 +0300 Subject: [PATCH 02/24] fix(config): match a property max depth by path prefix again v1.14.2 applied HasMaxDepth to every path that starts with the property name, so HasMaxDepth on Address also applied to AddressBackup.State. Match by prefix again, like v1.14.2. A later major version can apply the depth only to the property and the paths below it. --- QueryKit.UnitTests/PropertyDepthTests.cs | 9 ++++----- QueryKit/QueryKitPropertyMappings.cs | 7 +++---- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/QueryKit.UnitTests/PropertyDepthTests.cs b/QueryKit.UnitTests/PropertyDepthTests.cs index 35659d2..8301fbc 100644 --- a/QueryKit.UnitTests/PropertyDepthTests.cs +++ b/QueryKit.UnitTests/PropertyDepthTests.cs @@ -257,7 +257,7 @@ public void filter_root_property_allowed_when_max_depth_is_0() } [Fact] - public void filter_per_property_max_depth_does_not_apply_to_a_property_that_starts_with_its_name() + public void filter_per_property_max_depth_applies_to_a_property_that_starts_with_its_name() { var input = """AddressBackup.State == "x" """; var config = new QueryKitConfiguration(settings => @@ -267,12 +267,11 @@ public void filter_per_property_max_depth_does_not_apply_to_a_property_that_star }); var act = () => FilterParser.ParseFilter(input, config); - act.Should().Throw() - .WithMessage("*AddressBackup.State*depth of 1*maximum allowed depth of 0*"); + act.Should().NotThrow(); } [Fact] - public void sort_per_property_max_depth_does_not_apply_to_a_property_that_starts_with_its_name() + public void sort_per_property_max_depth_applies_to_a_property_that_starts_with_its_name() { var input = "AddressBackup.State"; var config = new QueryKitConfiguration(settings => @@ -282,7 +281,7 @@ public void sort_per_property_max_depth_does_not_apply_to_a_property_that_starts }); var act = () => SortParser.ParseSort(input, config); - act.Should().Throw(); + act.Should().NotThrow(); } private class Owner diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index d771ca8..6e2dbc7 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -420,12 +420,11 @@ private static string GetOperator(ExpressionType nodeType) if (string.IsNullOrEmpty(propertyPath)) return null; - // Check if the property path is, or is under, any configured property that has MaxDepth + // Check if the property path starts with any configured property that has MaxDepth foreach (var mapping in _propertyMappings.Values) { - if (mapping.MaxDepth.HasValue && !string.IsNullOrEmpty(mapping.Name) && - (propertyPath.Equals(mapping.Name, StringComparison.OrdinalIgnoreCase) || - propertyPath.StartsWith(mapping.Name + ".", StringComparison.OrdinalIgnoreCase))) + if (mapping.MaxDepth.HasValue && + propertyPath.StartsWith(mapping.Name ?? "", StringComparison.OrdinalIgnoreCase)) { return mapping.MaxDepth; } From 2b1c252a1bc6156c64026317832c6e13219d5aa6 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 23:41:02 +0300 Subject: [PATCH 03/24] fix(filter): accept only one word on the right side again v1.14.2 read one identifier on the right side of a comparison, so Title == Author.Name and Title == foo.bar threw ParsingException. Read one identifier again, like v1.14.2. A later minor version can accept a nested property path on the right side. --- .../Tests/PropertyResolverTests.cs | 29 --------------- QueryKit.UnitTests/PropertyResolverTests.cs | 35 +++++-------------- QueryKit/FilterParser.cs | 2 +- 3 files changed, 10 insertions(+), 56 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 3a42f8e..938961d 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -5,8 +5,6 @@ namespace QueryKit.IntegrationTests.Tests; using FluentAssertions; using Microsoft.EntityFrameworkCore; using SharedTestingHelper.Fakes; -using SharedTestingHelper.Fakes.Author; -using SharedTestingHelper.Fakes.Recipes; using WebApiTestProject.Entities; public class PropertyResolverTests : TestBase @@ -387,33 +385,6 @@ public async Task query_name_in_arithmetic_is_filtered() people.Should().ContainSingle(x => x.Id == fakePerson.Id); } - [Fact] - public async Task property_path_on_the_right_side_is_compared() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var name = Guid.NewGuid().ToString(); - var matchingRecipe = new FakeRecipeBuilder() - .WithTitle(name) - .Build(); - matchingRecipe.SetAuthor(new FakeAuthorBuilder().WithName(name).Build()); - var otherRecipe = new FakeRecipeBuilder() - .WithTitle(name) - .Build(); - otherRecipe.SetAuthor(new FakeAuthorBuilder().WithName(Guid.NewGuid().ToString()).Build()); - await testingServiceScope.InsertAsync(matchingRecipe, otherRecipe); - - var input = $"""Title == "{name}" && Title == Author.Name"""; - - // Act - var recipes = await testingServiceScope.DbContext().Recipes - .ApplyQueryKitFilter(input) - .ToListAsync(); - - // Assert - recipes.Should().ContainSingle(x => x.Id == matchingRecipe.Id); - } - [Fact] public async Task unknown_property_in_arithmetic_removes_the_clause_when_unknown_properties_are_allowed() { diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 274c57b..5132b06 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -661,42 +661,25 @@ public void alias_replacement_replaces_a_query_name_in_a_nested_path() } [Fact] - public void property_path_on_the_right_side_is_compared() + public void property_path_on_the_right_side_throws() { var input = """Title == Author.Name"""; - var filterExpression = FilterParser.ParseFilter(input); + var act = () => FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => (x.Title == x.Author.Name)"); + act.Should().Throw() + .WithInnerException() + .WithMessage("*Equal is not defined for the types 'System.String' and*Author*"); } [Fact] - public void property_path_on_the_right_side_obeys_max_property_depth() + public void unquoted_dotted_word_on_the_right_side_throws() { - var input = """Title == Author.Name"""; - var config = new QueryKitConfiguration(config => - { - config.MaxPropertyDepth = 0; - }); - - var act = () => FilterParser.ParseFilter(input, config); - - act.Should().Throw(); - } - - [Fact] - public void prevented_property_path_on_the_right_side_removes_the_clause() - { - var input = """Title == Author.Name || Directions == "x" """; - var config = new QueryKitConfiguration(config => - { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Author.Name).PreventFilter(); - }); + var input = """Title == foo.bar"""; - var filterExpression = FilterParser.ParseFilter(input, config); + var act = () => FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("""x => (x.Directions == "x")"""); + act.Should().Throw().WithMessage("*Line 1, Column 13*"); } [Fact] diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index f35d41c..53cdabc 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -334,7 +334,7 @@ private static List SplitListItems(string list) .XOr(NumberParser.Select(v => new RightSideValue(v, false))) .XOr((RawStringLiteralParser.Or(DoubleQuoteParser)).Select(v => new RightSideValue(v, true))) .XOr(SquareBracketParser.Select(v => new RightSideValue(v, false))) - .XOr(Identifier.DelimitedBy(Parse.Char('.')).Select(v => new RightSideValue(string.Join(".", v), false))); // Keep this last to try property paths only if nothing else matches + .XOr(Identifier.Select(v => new RightSideValue(v, false))); // Keep this last to try property paths only if nothing else matches private static readonly Parser RightSideValueParser = from atSign in Parse.Char('@').Optional() From 7653edbfc14b9b554d59598ab509ac59fe76bcc2 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 23:41:47 +0300 Subject: [PATCH 04/24] fix(filter): build TimeOnly literals with the five-int constructor again v1.14.2 built a TimeOnly value with the constructor that takes five ints. On net6.0 this constructor does not exist, so a TimeOnly filter threw ArgumentNullException. Main fell back to a constant. Remove the fallback, so a v1.14.2 consumer sees the same exception. The opt-in parameter path keeps its behavior. A later version can build TimeOnly values on net6.0. --- QueryKit/FilterValue.cs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/QueryKit/FilterValue.cs b/QueryKit/FilterValue.cs index 5916323..453e843 100644 --- a/QueryKit/FilterValue.cs +++ b/QueryKit/FilterValue.cs @@ -85,15 +85,11 @@ private static Expression Literal(object? value, Type type) : Expression.New(type.GetConstructor(new[] { underlying })!, created); } - // The TimeOnly constructor with microseconds needs .NET 7. Without it, the value is a constant. - private static Expression? NewTimeOnly(TimeOnly time) + // The TimeOnly constructor with microseconds needs .NET 7. On net6.0, Expression.New throws + // ArgumentNullException, the same as v1.14.2. + private static Expression NewTimeOnly(TimeOnly time) { - var ctor = typeof(TimeOnly).GetConstructor(new[] { typeof(int), typeof(int), typeof(int), typeof(int), typeof(int) }); - if (ctor == null) - { - return null; - } - + var ctor = typeof(TimeOnly).GetConstructor(new[] { typeof(int), typeof(int), typeof(int), typeof(int), typeof(int) })!; var fractionalTicks = time.Ticks % TimeSpan.TicksPerSecond; var millisecond = (int)(fractionalTicks / TimeSpan.TicksPerMillisecond); var microsecond = (int)(fractionalTicks % TimeSpan.TicksPerMillisecond / 10); From a3a4d54c82211ced12fe9a78f8bf8c3564f769dd Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 23:43:43 +0300 Subject: [PATCH 05/24] fix(filter): throw ArgumentException for an unknown property in arithmetic again v1.14.2 threw ArgumentException for an unknown property in arithmetic, also with AllowUnknownProperties. Main threw UnknownFilterPropertyException, or removed the clause when unknown properties were allowed. Throw ArgumentException again, like v1.14.2. --- .../Tests/PropertyResolverTests.cs | 28 ------------------- QueryKit.UnitTests/PropertyResolverTests.cs | 20 +++++++------ QueryKit/FilterParser.cs | 15 ++-------- 3 files changed, 13 insertions(+), 50 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 938961d..80bcf93 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -385,34 +385,6 @@ public async Task query_name_in_arithmetic_is_filtered() people.Should().ContainSingle(x => x.Id == fakePerson.Id); } - [Fact] - public async Task unknown_property_in_arithmetic_removes_the_clause_when_unknown_properties_are_allowed() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithAge(30) - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - var input = $"""Title == "{title}" && ((Nope + 1) > 3 || Age > 100)"""; - var config = new QueryKitConfiguration(config => - { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.AllowUnknownProperties = true; - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().BeEmpty(); - } - [Theory] [InlineData("first-name")] [InlineData("_first")] diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 5132b06..e564baf 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -683,7 +683,7 @@ public void unquoted_dotted_word_on_the_right_side_throws() } [Fact] - public void unknown_property_in_arithmetic_removes_the_clause_when_unknown_properties_are_allowed() + public void unknown_property_in_arithmetic_throws_when_unknown_properties_are_allowed() { var input = """(Nope + 1) > 3 || Age > 100"""; var config = new QueryKitConfiguration(config => @@ -692,13 +692,14 @@ public void unknown_property_in_arithmetic_removes_the_clause_when_unknown_prope config.AllowUnknownProperties = true; }); - var filterExpression = FilterParser.ParseFilter(input, config); + var act = () => FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + act.Should().Throw() + .WithMessage("Property 'Nope' not found on type 'TestingPerson'"); } [Fact] - public void unknown_property_on_the_right_side_of_arithmetic_removes_the_clause_when_unknown_properties_are_allowed() + public void unknown_property_on_the_right_side_of_arithmetic_throws_when_unknown_properties_are_allowed() { var input = """(Age + 0) > Nope || Title == "a" """; var config = new QueryKitConfiguration(config => @@ -707,19 +708,20 @@ public void unknown_property_on_the_right_side_of_arithmetic_removes_the_clause_ config.AllowUnknownProperties = true; }); - var filterExpression = FilterParser.ParseFilter(input, config); + var act = () => FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "a")"""); + act.Should().Throw() + .WithMessage("Property 'Nope' not found on type 'TestingPerson'"); } [Fact] - public void unknown_property_in_arithmetic_is_not_recognized() + public void unknown_property_in_arithmetic_throws_an_argument_exception() { var input = """(Nope + 1) > 3"""; var act = () => FilterParser.ParseFilter(input); - act.Should().Throw() - .WithMessage("The filter property 'Nope' was not recognized."); + act.Should().Throw() + .WithMessage("Property 'Nope' not found on type 'TestingPerson'"); } } diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 53cdabc..2a781c0 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -740,23 +740,12 @@ private static Parser ArithmeticComparisonExprParser(ParameterExp { case PropertyArithmeticExpression property: var reference = PropertyResolver.Resolve(entityType, property.PropertyPath, config); - if (!reference.CanFilter) - { - return null; - } - - // Arithmetic supports only members, so a derived property or a custom operation is unknown here if (reference.Kind != PropertyReferenceKind.Member) { - if (config?.AllowUnknownProperties == true) - { - return null; - } - - throw new UnknownFilterPropertyException(reference.UnknownSegment!); + return property; } - return new PropertyArithmeticExpression(reference.Path); + return reference.CanFilter ? new PropertyArithmeticExpression(reference.Path) : null; case BinaryArithmeticExpression binary: var left = ResolveArithmeticProperties(binary.Left, entityType, config); var right = ResolveArithmeticProperties(binary.Right, entityType, config); From 2f33f60a1ba1a7957cce594f567f3defcd95bc93 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 23:49:54 +0300 Subject: [PATCH 06/24] fix(filter): reopen the prevent-setting paths that v1.14.2 did not check v1.14.2 checked PreventFilter only for a left-side member, looked up by its name in the exact case after the query-name rewrite. It did not check arithmetic, the right side, another case in a property list, derived properties, or custom operations. PreventSort was looked up by the typed path in the exact case. The parser now does the same checks as v1.14.2 again, so that a v1.14.2 consumer sees no difference. This reopens the six bypasses I1 to I6 of the breaking-change audit. A later major release closes them again. --- .../Tests/PropertyResolverTests.cs | 208 ------------------ QueryKit.UnitTests/PropertyResolverTests.cs | 119 ++-------- .../QueryKitConfigurationExtensions.cs | 5 + QueryKit/FilterParser.cs | 43 ++-- QueryKit/PropertyResolver.cs | 4 - QueryKit/SortParser.cs | 3 +- 6 files changed, 45 insertions(+), 337 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 80bcf93..3791747 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -122,214 +122,6 @@ public async Task prevented_property_clause_by_its_query_name_under_or_does_not_ people.Should().BeEmpty(); } - [Fact] - public async Task prevented_property_in_arithmetic_is_not_filtered() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithAge(5) - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - var input = $"""Title == "{title}" && (Age + 0) > 10"""; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.Age).PreventFilter(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().ContainSingle(x => x.Id == fakePerson.Id); - } - - [Fact] - public async Task prevented_property_on_the_right_side_is_not_compared() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("Same") - .WithLastName("Same") - .WithAge(30) - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - var input = $"""Title == "{title}" && (FirstName == LastName || Age > 100)"""; - var config = new QueryKitConfiguration(config => - { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.LastName).PreventFilter(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().BeEmpty(); - } - - [Fact] - public async Task prevented_property_in_a_list_is_not_filtered_in_any_case() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("Paul") - .WithLastName("Other") - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - var input = $"""Title == "{title}" && (firstname, LastName) == "Paul" """; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.FirstName).PreventFilter(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().BeEmpty(); - } - - [Fact] - public async Task prevented_sort_property_with_a_query_name_is_not_sorted_when_written_by_its_member_name() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var firstPerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("A") - .WithAge(1) - .Build(); - var secondPerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("B") - .WithAge(2) - .Build(); - await testingServiceScope.InsertAsync(firstPerson, secondPerson); - - var input = "firstname desc, Age"; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.FirstName).HasQueryName("first").PreventSort(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .Where(x => x.Title == title) - .ApplyQueryKitSort(input, config) - .ToListAsync(); - - // Assert - people.Select(x => x.Id).Should().Equal(firstPerson.Id, secondPerson.Id); - } - - [Fact] - public async Task prevented_custom_operation_is_not_filtered() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithAge(5) - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - var input = $"""Title == "{title}" && adult == true"""; - var config = new QueryKitConfiguration(config => - { - config.CustomOperation((x, op, value) => x.Age > 17).HasQueryName("adult").PreventFilter(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().ContainSingle(x => x.Id == fakePerson.Id); - } - - [Fact] - public async Task prevented_derived_property_is_not_filtered() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("Paul") - .WithLastName("Other") - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - var input = $"""Title == "{title}" && full == "no match" """; - var config = new QueryKitConfiguration(config => - { - config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventFilter(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().ContainSingle(x => x.Id == fakePerson.Id); - } - - [Fact] - public async Task prevented_derived_sort_property_is_not_sorted() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var firstPerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("A") - .WithAge(1) - .Build(); - var secondPerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("B") - .WithAge(2) - .Build(); - await testingServiceScope.InsertAsync(firstPerson, secondPerson); - - var input = "full desc, Age"; - var config = new QueryKitConfiguration(config => - { - config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventSort(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .Where(x => x.Title == title) - .ApplyQueryKitSort(input, config) - .ToListAsync(); - - // Assert - people.Select(x => x.Id).Should().Equal(firstPerson.Id, secondPerson.Id); - } - [Fact] public async Task query_name_in_a_property_list_is_filtered() { diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index e564baf..b34dd7d 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -83,53 +83,6 @@ public void ignored_clause_in_a_group_is_true_equals_true_in_the_group() """x => ((x.Title == "a") AndAlso ((True == True) OrElse (x.Age > 100)))"""); } - [Fact] - public void prevented_property_in_arithmetic_is_true_equals_true() - { - var input = """(Age + 0) > 10 || Title == "a" """; - var config = new QueryKitConfiguration(config => - { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.ReplaceWithTrue; - config.Property(x => x.Age!).PreventFilter(); - }); - - var filterExpression = FilterParser.ParseFilter(input, config); - - filterExpression.ToDisplayString().Should().Be( - """x => ((True == True) OrElse (x.Title == "a"))"""); - } - - [Fact] - public void prevented_property_on_the_right_side_of_arithmetic_is_true_equals_true() - { - var input = """(Age + 0) > (Rating * 2)"""; - var config = new QueryKitConfiguration(config => - { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.ReplaceWithTrue; - config.Property(x => x.Rating!).PreventFilter(); - }); - - var filterExpression = FilterParser.ParseFilter(input, config); - - filterExpression.ToDisplayString().Should().Be("x => (True == True)"); - } - - [Fact] - public void prevented_property_on_the_right_side_is_true_equals_true_when_replaced() - { - var input = """FirstName == Title || Age > 100"""; - var config = new QueryKitConfiguration(config => - { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.ReplaceWithTrue; - config.Property(x => x.Title!).PreventFilter(); - }); - - var filterExpression = FilterParser.ParseFilter(input, config); - - filterExpression.ToDisplayString().Should().Be( - "x => ((True == True) OrElse (x.Age > 100))"); - } - [Fact] public void unknown_property_clause_is_removed_under_or() { @@ -207,7 +160,7 @@ public void property_list_with_only_prevented_properties_is_removed() } [Fact] - public void prevented_property_in_arithmetic_removes_the_clause() + public void prevented_property_in_arithmetic_is_still_filtered() { var input = """(Age + 0) > 10 || Title == "a" """; var config = new QueryKitConfiguration(config => @@ -218,11 +171,11 @@ public void prevented_property_in_arithmetic_removes_the_clause() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "a")"""); + filterExpression.ToDisplayString().Should().Be("""x => (((x.Age + Convert(0, Nullable`1)) > Convert(10, Nullable`1)) OrElse (x.Title == "a"))"""); } [Fact] - public void prevented_property_on_the_right_side_of_arithmetic_removes_the_clause() + public void prevented_property_on_the_right_side_of_arithmetic_is_still_filtered() { var input = """(Age + 0) > (Rating * 2)"""; var config = new QueryKitConfiguration(config => @@ -233,7 +186,7 @@ public void prevented_property_on_the_right_side_of_arithmetic_removes_the_claus var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => True"); + filterExpression.ToDisplayString().Should().Be("x => (Convert((x.Age + Convert(0, Nullable`1)), Nullable`1) > (x.Rating * Convert(2, Nullable`1)))"); } [Fact] @@ -251,7 +204,7 @@ public void arithmetic_property_obeys_max_property_depth() } [Fact] - public void prevented_property_on_the_right_side_removes_the_clause() + public void prevented_property_on_the_right_side_is_still_compared() { var input = """FirstName == Title || Age > 100"""; var config = new QueryKitConfiguration(config => @@ -262,26 +215,11 @@ public void prevented_property_on_the_right_side_removes_the_clause() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); - } - - [Fact] - public void prevented_property_on_the_right_side_removes_the_clause_in_any_case() - { - var input = """FirstName == title || Age > 100"""; - var config = new QueryKitConfiguration(config => - { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Title).PreventFilter(); - }); - - var filterExpression = FilterParser.ParseFilter(input, config); - - filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + filterExpression.ToDisplayString().Should().Be("x => ((x.FirstName == x.Title) OrElse (x.Age > 100))"); } [Fact] - public void prevented_property_in_a_list_is_skipped_in_any_case() + public void prevented_property_in_a_list_in_another_case_is_still_filtered() { var input = """(title, FirstName) == "x" """; var config = new QueryKitConfiguration(config => @@ -291,7 +229,7 @@ public void prevented_property_in_a_list_is_skipped_in_any_case() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "x")"""); + filterExpression.ToDisplayString().Should().Be("""x => ((x.Title == "x") OrElse (x.FirstName == "x"))"""); } [Fact] @@ -310,7 +248,7 @@ public void prevented_property_removes_the_clause_in_any_case() } [Fact] - public void prevented_property_with_a_query_name_removes_the_clause_when_written_by_its_member_name_in_any_case() + public void prevented_property_with_a_query_name_is_still_filtered_by_its_member_name_in_another_case() { var input = """title == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => @@ -321,7 +259,7 @@ public void prevented_property_with_a_query_name_removes_the_clause_when_written var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + filterExpression.ToDisplayString().Should().Be("""x => ((x.Title == "x") OrElse (x.Age > 100))"""); } [Fact] @@ -354,7 +292,7 @@ public void prevented_sort_property_is_skipped_in_any_case() } [Fact] - public void prevented_sort_property_with_a_query_name_is_skipped_when_written_by_its_member_name() + public void prevented_sort_property_with_a_query_name_still_sorts_by_its_member_name_in_another_case() { var input = "title desc"; var config = new QueryKitConfiguration(config => @@ -364,11 +302,12 @@ public void prevented_sort_property_with_a_query_name_is_skipped_when_written_by var sortExpressions = SortParser.ParseSort(input, config); - sortExpressions.Should().BeEmpty(); + sortExpressions.Should().ContainSingle(); + sortExpressions[0].Expression!.ToString().Should().Be("x => Convert(x.Title, Object)"); } [Fact] - public void prevented_derived_property_removes_the_clause() + public void prevented_derived_property_is_still_filtered() { var input = """full == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => @@ -379,11 +318,11 @@ public void prevented_derived_property_removes_the_clause() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + filterExpression.ToDisplayString().Should().Be("""x => ((((x.FirstName + " ") + x.LastName) == "x") OrElse (x.Age > 100))"""); } [Fact] - public void prevented_derived_property_in_a_list_is_skipped() + public void prevented_derived_property_in_a_list_is_still_filtered() { var input = """(full, FirstName) == "x" """; var config = new QueryKitConfiguration(config => @@ -393,11 +332,11 @@ public void prevented_derived_property_in_a_list_is_skipped() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "x")"""); + filterExpression.ToDisplayString().Should().Be("""x => ((((x.FirstName + " ") + x.LastName) == "x") OrElse (x.FirstName == "x"))"""); } [Fact] - public void prevented_custom_operation_removes_the_clause() + public void prevented_custom_operation_is_still_applied() { var input = """adult == true || Age > 100"""; var config = new QueryKitConfiguration(config => @@ -408,26 +347,11 @@ public void prevented_custom_operation_removes_the_clause() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); - } - - [Fact] - public void prevented_custom_operation_is_true_equals_true_when_replaced() - { - var input = """adult == true || Age > 100"""; - var config = new QueryKitConfiguration(config => - { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.ReplaceWithTrue; - config.CustomOperation((x, op, value) => x.Age > 17).HasQueryName("adult").PreventFilter(); - }); - - var filterExpression = FilterParser.ParseFilter(input, config); - - filterExpression.ToDisplayString().Should().Be("x => ((True == True) OrElse (x.Age > 100))"); + filterExpression.ToDisplayString().Should().Be("x => (Invoke((entity, op, value) => (Convert(entity, TestingPerson).Age > Convert(17, Nullable`1)), Convert(x, Object), ==, True) OrElse (x.Age > 100))"); } [Fact] - public void prevented_derived_sort_property_is_skipped() + public void prevented_derived_sort_property_still_sorts() { var input = "full desc, Age"; var config = new QueryKitConfiguration(config => @@ -437,8 +361,7 @@ public void prevented_derived_sort_property_is_skipped() var sortExpressions = SortParser.ParseSort(input, config); - sortExpressions.Should().ContainSingle(); - sortExpressions[0].Expression!.ToString().Should().Be("x => Convert(x.Age, Object)"); + sortExpressions.Should().HaveCount(2); } [Fact] diff --git a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs index f025780..bd3cc41 100644 --- a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs +++ b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs @@ -9,6 +9,11 @@ internal static class QueryKitConfigurationExtensions return configuration.PropertyMappings.GetPropertyPathByQueryName(queryName); } + internal static bool IsPropertySortable(this IQueryKitConfiguration configuration, string? propertyName) + { + return configuration.PropertyMappings.GetPropertyInfo(propertyName)?.CanSort ?? true; + } + internal static void ValidatePropertyDepth(this IQueryKitConfiguration? configuration, string? propertyPath) { if (configuration == null || string.IsNullOrEmpty(propertyPath)) diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 2a781c0..afaaa7e 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -719,10 +719,6 @@ private static Parser ArithmeticComparisonExprParser(ParameterExp { var leftArithmetic = ResolveArithmeticProperties(temp.leftArithmetic, typeof(T), config); var rightArithmetic = ResolveArithmeticProperties(temp.rightSide, typeof(T), config); - if (leftArithmetic == null || rightArithmetic == null) - { - return IgnoredClause(config); - } var leftExpr = leftArithmetic.ToLinqExpression(parameter, typeof(T)); var rightExpr = rightArithmetic.ToLinqExpression(parameter, typeof(T)); @@ -733,8 +729,7 @@ private static Parser ArithmeticComparisonExprParser(ParameterExp } // Resolves each property in an arithmetic expression to its member path. - // Returns null when a property cannot be filtered, because then the parser removes the clause. - private static ArithmeticExpression? ResolveArithmeticProperties(ArithmeticExpression expr, Type entityType, IQueryKitConfiguration? config) + private static ArithmeticExpression ResolveArithmeticProperties(ArithmeticExpression expr, Type entityType, IQueryKitConfiguration? config) { switch (expr) { @@ -745,14 +740,13 @@ private static Parser ArithmeticComparisonExprParser(ParameterExp return property; } - return reference.CanFilter ? new PropertyArithmeticExpression(reference.Path) : null; + return new PropertyArithmeticExpression(reference.Path); case BinaryArithmeticExpression binary: var left = ResolveArithmeticProperties(binary.Left, entityType, config); var right = ResolveArithmeticProperties(binary.Right, entityType, config); - return left == null || right == null ? null : new BinaryArithmeticExpression(left, binary.Operator, right); + return new BinaryArithmeticExpression(left, binary.Operator, right); case GroupedArithmeticExpression grouped: - var inner = ResolveArithmeticProperties(grouped.Inner, entityType, config); - return inner == null ? null : new GroupedArithmeticExpression(inner); + return new GroupedArithmeticExpression(ResolveArithmeticProperties(grouped.Inner, entityType, config)); default: return expr; } @@ -797,6 +791,11 @@ private static bool IsValidPropertyName(string value) value.All(c => char.IsLetterOrDigit(c) || c == '_' || c == '.'); } + // The filter settings of a left-side property: by the name that its query name maps to, in the exact case. + // Derived properties and custom operations are not in this lookup. + private static QueryKitPropertyInfo? GetFilterPropertyInfo(string text, IQueryKitConfiguration? config) + => config?.PropertyMappings?.GetPropertyInfo(config.PropertyMappings.GetPropertyPathByQueryName(text) ?? text); + private static CaseInsensitiveMode ResolveCaseMode(string? propertyPath, IQueryKitConfiguration? config) { if (!string.IsNullOrEmpty(propertyPath) && config?.PropertyMappings != null) @@ -827,11 +826,6 @@ private static Parser ComparisonExprParser(ParameterExpression pa { if (temp.reference.Kind == PropertyReferenceKind.CustomOperation) { - if (!temp.reference.CanFilter) - { - return IgnoredClause(config); - } - return CreateCustomOperationExpression(parameter, temp.reference.Mapping!, temp.op, temp.right); } @@ -873,11 +867,6 @@ private static Parser ComparisonExprParser(ParameterExpression pa // A quoted string literal is always a value, even when its text matches a property name. if (!temp.rightIsQuotedLiteral && IsPropertyPath(temp.right, parameter.Type)) { - if (!PropertyResolver.Resolve(parameter.Type, temp.right, config).CanFilter) - { - return IgnoredClause(config); - } - var rightPropertyExpr = CreateRightPropertyExpr(parameter, temp.right, config); if (rightPropertyExpr != null) { @@ -1017,12 +1006,13 @@ private static Expression CreateLeftExpr(ParameterExpression parameter, Property ? reference.Mapping!.DerivedExpression! : CreateMemberExpression(parameter, reference.Path); - var propertyConfig = reference.Mapping; - if (propertyConfig != null && !propertyConfig.CanFilter) + if (GetFilterPropertyInfo(reference.Text, config)?.CanFilter == false) { return RemovedClauseExpression.Instance; } + var propertyConfig = reference.Mapping; + // Check if this property uses HasConversion if (propertyConfig?.UsesConversion == true) { @@ -1161,12 +1151,15 @@ private static Parser PropertyListComparisonExprParser( foreach (var fullPropPath in temp.properties) { // Build expression for each property. A property list does not support custom operations. - var reference = PropertyResolver.Resolve(parameter.Type, fullPropPath, config); - if (!reference.CanFilter) + // Check if property can be filtered + var propertyConfig = config?.PropertyMappings?.GetPropertyInfo(fullPropPath); + if (propertyConfig != null && !propertyConfig.CanFilter) { continue; } + var reference = PropertyResolver.Resolve(parameter.Type, fullPropPath, config); + if (reference.Kind is PropertyReferenceKind.Unknown or PropertyReferenceKind.CustomOperation) { if (config?.AllowUnknownProperties == true) @@ -1194,7 +1187,7 @@ private static Parser PropertyListComparisonExprParser( } var rightExpr = CreateRightExpr(leftExpr, temp.right, temp.op, config, resolvedPropPath); - var comparison = temp.op.GetExpression(leftExpr, rightExpr, config?.DbContextType, ResolveCaseMode(reference.Path, config)); + var comparison = temp.op.GetExpression(leftExpr, rightExpr, config?.DbContextType, ResolveCaseMode(fullPropPath, config)); // Combine with AND for negative operators, OR for positive operators result = result == null diff --git a/QueryKit/PropertyResolver.cs b/QueryKit/PropertyResolver.cs index 8db8a0c..dc8d457 100644 --- a/QueryKit/PropertyResolver.cs +++ b/QueryKit/PropertyResolver.cs @@ -36,10 +36,6 @@ private PropertyReference(PropertyReferenceKind kind, string text, string path, /// The configuration of the member, the derived property, or the custom operation, if there is one. public QueryKitPropertyInfo? Mapping { get; } - public bool CanFilter => Mapping?.CanFilter ?? true; - - public bool CanSort => Mapping?.CanSort ?? true; - /// When the reference is not a member, the first path segment that did not resolve to a member. public string? UnknownSegment { get; } diff --git a/QueryKit/SortParser.cs b/QueryKit/SortParser.cs index 26c0280..22a915a 100644 --- a/QueryKit/SortParser.cs +++ b/QueryKit/SortParser.cs @@ -56,8 +56,7 @@ private static SortExpressionInfo CreateSortExpression(string sortClause, } var propertyPath = config?.GetPropertyPathByQueryName(propertyName) ?? propertyName; - var reference = PropertyResolver.Resolve(typeof(T), propertyPath, config); - if (reference.Kind != PropertyReferenceKind.CustomOperation && !reference.CanSort) + if (config != null && config.IsPropertySortable(propertyPath) == false) { return new SortExpressionInfo { From c0e99144106424b5895b3bc0260e01bbdd17f745 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 23:55:05 +0300 Subject: [PATCH 07/24] fix(filter): throw InvalidOperationException for a fully prevented query name again In v1.14.2, a property with PreventFilter and PreventSort threw InvalidOperationException when the filter used its query name before an operator. The alias rewrite pass did this check before the parser ran, so the exception was not wrapped in ParsingException. ParseFilter runs the same logical alias, comparison alias, and query name passes on a copy of the input again. The result of the passes is not used, because the grammar resolves query names. Only the check has an effect. --- .../Tests/PropertyResolverTests.cs | 29 ------------------- QueryKit.UnitTests/PropertyResolverTests.cs | 23 +++++++++++++-- .../QueryKitConfigurationExtensions.cs | 28 ++++++++++++++++++ QueryKit/FilterParser.cs | 14 +++++++++ 4 files changed, 62 insertions(+), 32 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 3791747..0ffc1f1 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -93,35 +93,6 @@ public async Task prevented_property_clause_under_or_does_not_return_every_row() people.Should().BeEmpty(); } - [Fact] - public async Task prevented_property_clause_by_its_query_name_under_or_does_not_return_every_row() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("Paul") - .WithAge(30) - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - var input = $"""Title == "{title}" && (first == "Paul" || Age > 100)"""; - var config = new QueryKitConfiguration(config => - { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.FirstName).HasQueryName("first").PreventFilter().PreventSort(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().BeEmpty(); - } - [Fact] public async Task query_name_in_a_property_list_is_filtered() { diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index b34dd7d..57eb776 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -541,7 +541,7 @@ public void query_name_in_a_value_is_not_replaced() } [Fact] - public void property_prevented_for_filter_and_sort_is_removed_by_its_query_name() + public void property_prevented_for_filter_and_sort_throws_by_its_query_name() { var input = """name == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => @@ -550,9 +550,26 @@ public void property_prevented_for_filter_and_sort_is_removed_by_its_query_name( config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); }); - var filterExpression = FilterParser.ParseFilter(input, config); + var act = () => FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + act.Should().ThrowExactly() + .WithMessage("'Title' is not allowed for filtering or sorting."); + } + + [Fact] + public void property_prevented_for_filter_and_sort_throws_by_its_query_name_before_an_operator_alias() + { + var input = """name eq "x" """; + var config = new QueryKitConfiguration(config => + { + config.EqualsOperator = "eq"; + config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); + }); + + var act = () => FilterParser.ParseFilter(input, config); + + act.Should().ThrowExactly() + .WithMessage("'Title' is not allowed for filtering or sorting."); } [Fact] diff --git a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs index bd3cc41..d84a576 100644 --- a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs +++ b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs @@ -1,9 +1,37 @@ namespace QueryKit.Configuration; +using System.Text.RegularExpressions; using QueryKit.Exceptions; +using QueryKit.Operators; internal static class QueryKitConfigurationExtensions { + internal static string ReplaceComparisonAliases(this IQueryKitConfiguration configuration, string input) + { + var aliasedOperators = ComparisonOperator.GetAliasMatches(configuration); + foreach (var comparisonAliasMatch in aliasedOperators) + { + var escapedAlias = Regex.Escape(comparisonAliasMatch.Alias); + var regex = new Regex($@"(?<=\s|^){escapedAlias}(?=\s|$)", RegexOptions.IgnoreCase); + input = regex.Replace(input, comparisonAliasMatch.Operator); + } + + return input; + } + + internal static string ReplaceLogicalAliases(this IQueryKitConfiguration configuration, string input) + { + var aliasedOperators = LogicalOperator.GetAliasMatches(configuration); + foreach (var logicalAliasMatch in aliasedOperators) + { + var escapedAlias = Regex.Escape(logicalAliasMatch.Alias); + var regex = new Regex($@"(?<=\s|^){escapedAlias}(?=\s|$)", RegexOptions.IgnoreCase); + input = regex.Replace(input, logicalAliasMatch.Operator); + } + + return input; + } + internal static string? GetPropertyPathByQueryName(this IQueryKitConfiguration configuration, string? queryName) { return configuration.PropertyMappings.GetPropertyPathByQueryName(queryName); diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index afaaa7e..029d6ab 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -22,6 +22,7 @@ public static class FilterParser public static Expression> ParseFilter(string input, IQueryKitConfiguration? config = null) { EnsureWithinParseLimits(input, config); + EnsureNoQueryNameOfAPropertyPreventedForFilterAndSort(input, config); var parameter = Expression.Parameter(typeof(T), "x"); Expression expr; @@ -55,6 +56,19 @@ public static Expression> ParseFilter(string input, IQueryKitCo return Expression.Lambda>(expr, parameter); } + // A property that can not be filtered or sorted throws InvalidOperationException when the filter uses its query name. + // The alias passes run on a copy of the input, so the check sees the operators in the same form as the rewrite pass. + private static void EnsureNoQueryNameOfAPropertyPreventedForFilterAndSort(string input, IQueryKitConfiguration? config) + { + if (config?.PropertyMappings == null) + { + return; + } + + var aliasesReplaced = config.ReplaceComparisonAliases(config.ReplaceLogicalAliases(input)); + config.PropertyMappings.ReplaceAliasesWithPropertyPaths(aliasesReplaced); + } + private static Expression ReplaceDerivedProperties(Expression expr, IQueryKitConfiguration? config, ParameterExpression parameter) { if (config?.PropertyMappings == null) From 76d3a7d85fccd5ca1a2ff18577b138cb7143cd43 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 23:59:16 +0300 Subject: [PATCH 08/24] fix(filter): look up HasConversion by query name again In v1.14.2, the HasConversion lookups searched by query name after the query name was already replaced with the property path. A property with both HasConversion and HasQueryName did not use its conversion. The lookups search by query name again. A null literal on a converted property builds a value from the text null again. A converted Nullable struct, Guid string operators, and lower-case property lists do not use the conversion again. --- .../Tests/HasConversionTests.cs | 149 ------------------ QueryKit.UnitTests/HasConversionTests.cs | 92 ++++++----- QueryKit/FilterParser.cs | 30 +--- 3 files changed, 58 insertions(+), 213 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/HasConversionTests.cs b/QueryKit.IntegrationTests/Tests/HasConversionTests.cs index d30a624..0d120b0 100644 --- a/QueryKit.IntegrationTests/Tests/HasConversionTests.cs +++ b/QueryKit.IntegrationTests/Tests/HasConversionTests.cs @@ -41,126 +41,6 @@ public async Task can_filter_by_email_with_has_conversion() people[0].Id.Should().Be(person.Id); } - [Fact] - public async Task can_filter_by_email_with_query_name_and_has_conversion() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var testEmail = $"{Guid.NewGuid()}@example.com"; - var person = new FakeTestingPersonBuilder() - .WithEmail(testEmail) - .Build(); - var personTwo = new FakeTestingPersonBuilder().Build(); - - await testingServiceScope.InsertAsync(person, personTwo); - - var input = $"""mail == "{testEmail}" """; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.Email).HasQueryName("mail").HasConversion(); - }); - - // Act - var queryablePeople = testingServiceScope.DbContext().People; - var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config); - var people = await appliedQueryable.ToListAsync(); - - // Assert - people.Count.Should().Be(1); - people[0].Id.Should().Be(person.Id); - } - - [Fact] - public async Task can_filter_by_email_property_path_when_query_name_and_has_conversion_are_configured() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var testEmail = $"{Guid.NewGuid()}@example.com"; - var person = new FakeTestingPersonBuilder() - .WithEmail(testEmail) - .Build(); - var personTwo = new FakeTestingPersonBuilder().Build(); - - await testingServiceScope.InsertAsync(person, personTwo); - - var input = $"""Email == "{testEmail}" """; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.Email).HasQueryName("mail").HasConversion(); - }); - - // Act - var queryablePeople = testingServiceScope.DbContext().People; - var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config); - var people = await appliedQueryable.ToListAsync(); - - // Assert - people.Count.Should().Be(1); - people[0].Id.Should().Be(person.Id); - } - - [Fact] - public async Task can_filter_by_email_value_with_query_name_and_has_conversion() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var testEmail = $"{Guid.NewGuid()}@example.com"; - var person = new FakeTestingPersonBuilder() - .WithEmail(testEmail) - .Build(); - var personTwo = new FakeTestingPersonBuilder().Build(); - - await testingServiceScope.InsertAsync(person, personTwo); - - var input = $"""Email.Value == "{testEmail}" """; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.Email).HasQueryName("mail").HasConversion(); - }); - - // Act - var queryablePeople = testingServiceScope.DbContext().People; - var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config); - var people = await appliedQueryable.ToListAsync(); - - // Assert - people.Count.Should().Be(1); - people[0].Id.Should().Be(person.Id); - } - - [Fact] - public async Task can_filter_by_null_email_with_query_name_and_has_conversion() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = Guid.NewGuid().ToString(); - var person = new FakeTestingPersonBuilder() - .WithTitle(title) - .Build(); - person.Email = null!; - var personTwo = new FakeTestingPersonBuilder() - .WithTitle(title) - .Build(); - - await testingServiceScope.InsertAsync(person, personTwo); - - var input = """mail == null"""; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.Email).HasQueryName("mail").HasConversion(); - }); - - // Act - var queryablePeople = testingServiceScope.DbContext().People - .Where(x => x.Title == title); - var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config); - var people = await appliedQueryable.ToListAsync(); - - // Assert - people.Count.Should().Be(1); - people[0].Id.Should().Be(person.Id); - } - [Fact] public async Task can_filter_by_nested_postal_code_with_has_conversion() { @@ -190,35 +70,6 @@ public async Task can_filter_by_nested_postal_code_with_has_conversion() people[0].Id.Should().Be(person.Id); } - [Fact] - public async Task can_filter_by_nested_postal_code_with_query_name_and_has_conversion() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var postalCode = Guid.NewGuid().ToString("N")[..10]; - var person = new FakeTestingPersonBuilder() - .WithPhysicalAddress(new Address("Line1", "Line2", "City", "State", postalCode, "Country")) - .Build(); - var personTwo = new FakeTestingPersonBuilder().Build(); - - await testingServiceScope.InsertAsync(person, personTwo); - - var input = $"""zip == "{postalCode}" """; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.PhysicalAddress.PostalCode).HasQueryName("zip").HasConversion(); - }); - - // Act - var queryablePeople = testingServiceScope.DbContext().People; - var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config); - var people = await appliedQueryable.ToListAsync(); - - // Assert - people.Count.Should().Be(1); - people[0].Id.Should().Be(person.Id); - } - [Fact] public async Task can_filter_guid_with_contains_query_name_and_has_conversion() { diff --git a/QueryKit.UnitTests/HasConversionTests.cs b/QueryKit.UnitTests/HasConversionTests.cs index 29a623b..e7df106 100644 --- a/QueryKit.UnitTests/HasConversionTests.cs +++ b/QueryKit.UnitTests/HasConversionTests.cs @@ -1,6 +1,7 @@ namespace QueryKit.UnitTests; using Configuration; +using Exceptions; using FluentAssertions; using WebApiTestProject.Entities; @@ -26,7 +27,7 @@ public void can_filter_struct_with_has_conversion() } [Fact] - public void can_filter_struct_with_query_name_and_has_conversion() + public void struct_with_query_name_and_has_conversion_throws() { // Arrange var config = new QueryKitConfiguration(config => @@ -35,15 +36,16 @@ public void can_filter_struct_with_query_name_and_has_conversion() }); // Act - var result = WrappedIdRows().ApplyQueryKitFilter("""wrappedid == "2" """, config).ToList(); + var act = () => WrappedIdRows().ApplyQueryKitFilter("""wrappedid == "2" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Name.Should().Be("two"); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("Unsupported value '2' for type 'WrappedId'"); } [Fact] - public void can_filter_struct_with_has_conversion_configured_before_query_name() + public void struct_with_has_conversion_configured_before_query_name_throws() { // Arrange var config = new QueryKitConfiguration(config => @@ -52,11 +54,12 @@ public void can_filter_struct_with_has_conversion_configured_before_query_name() }); // Act - var result = WrappedIdRows().ApplyQueryKitFilter("""wrappedid == "2" """, config).ToList(); + var act = () => WrappedIdRows().ApplyQueryKitFilter("""wrappedid == "2" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Name.Should().Be("two"); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("Unsupported value '2' for type 'WrappedId'"); } [Fact] @@ -77,7 +80,7 @@ public void can_filter_struct_with_query_name_differing_only_in_case_and_has_con } [Fact] - public void can_filter_struct_with_not_equals_query_name_and_has_conversion() + public void struct_with_not_equals_query_name_and_has_conversion_throws() { // Arrange var config = new QueryKitConfiguration(config => @@ -86,15 +89,16 @@ public void can_filter_struct_with_not_equals_query_name_and_has_conversion() }); // Act - var result = WrappedIdRows().ApplyQueryKitFilter("""wrappedid != "2" """, config).ToList(); + var act = () => WrappedIdRows().ApplyQueryKitFilter("""wrappedid != "2" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Name.Should().Be("one"); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("Unsupported value '2' for type 'WrappedId'"); } [Fact] - public void can_filter_by_property_path_when_query_name_and_has_conversion_are_configured() + public void property_path_with_query_name_and_has_conversion_configured_throws() { // Arrange var config = new QueryKitConfiguration(config => @@ -103,11 +107,12 @@ public void can_filter_by_property_path_when_query_name_and_has_conversion_are_c }); // Act - var result = WrappedIdRows().ApplyQueryKitFilter("""Id == "2" """, config).ToList(); + var act = () => WrappedIdRows().ApplyQueryKitFilter("""Id == "2" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Name.Should().Be("two"); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("Unsupported value '2' for type 'WrappedId'"); } [Fact] @@ -128,7 +133,7 @@ public void can_filter_reference_type_with_query_name_differing_only_in_case_and } [Fact] - public void can_filter_reference_type_with_query_name_and_has_conversion() + public void reference_type_with_query_name_and_has_conversion_throws() { // Arrange var config = new QueryKitConfiguration(config => @@ -137,15 +142,16 @@ public void can_filter_reference_type_with_query_name_and_has_conversion() }); // Act - var result = EmailRows().ApplyQueryKitFilter("""mail == "b@x.com" """, config).ToList(); + var act = () => EmailRows().ApplyQueryKitFilter("""mail == "b@x.com" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Email!.Value.Should().Be("b@x.com"); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("Unsupported value 'b@x.com' for type 'EmailAddressRecord'"); } [Fact] - public void can_filter_nested_property_with_query_name_and_has_conversion() + public void nested_property_with_query_name_and_has_conversion_throws() { // Arrange var rows = new List @@ -159,15 +165,16 @@ public void can_filter_nested_property_with_query_name_and_has_conversion() }); // Act - var result = rows.ApplyQueryKitFilter("""contact == "b@x.com" """, config).ToList(); + var act = () => rows.ApplyQueryKitFilter("""contact == "b@x.com" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Owner.Contact!.Value.Should().Be("b@x.com"); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("Unsupported value 'b@x.com' for type 'EmailAddressRecord'"); } [Fact] - public void child_property_of_converted_parent_with_query_name_compares_parent() + public void child_property_of_converted_parent_with_query_name_compares_the_child() { // Arrange var input = """Email.Value == "a@x.com" """; @@ -185,8 +192,8 @@ public void child_property_of_converted_parent_with_query_name_compares_parent() var filterWithoutQueryName = FilterParser.ParseFilter(input, configWithoutQueryName); // Assert - filterWithQueryName.ToDisplayString().Should().Be("""x => (x.Email == new EmailAddress("a@x.com"))"""); - filterWithQueryName.ToDisplayString().Should().Be(filterWithoutQueryName.ToDisplayString()); + filterWithQueryName.ToDisplayString().Should().Be("""x => (x.Email.Value == "a@x.com")"""); + filterWithoutQueryName.ToDisplayString().Should().Be("""x => (x.Email == new EmailAddress("a@x.com"))"""); } [Fact] @@ -207,7 +214,7 @@ public void can_filter_property_list_with_lowercase_path_and_has_conversion() } [Fact] - public void can_filter_nullable_struct_with_has_conversion() + public void nullable_struct_with_has_conversion_throws() { // Arrange var rows = new List @@ -222,11 +229,12 @@ public void can_filter_nullable_struct_with_has_conversion() }); // Act - var result = rows.ApplyQueryKitFilter("""Id == "2" """, config).ToList(); + var act = () => rows.ApplyQueryKitFilter("""Id == "2" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Id.Should().Be(new WrappedId(2)); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("The binary operator Equal is not defined for the types*"); } [Fact] @@ -252,7 +260,7 @@ public void can_filter_null_on_nullable_struct_with_has_conversion() } [Fact] - public void can_filter_null_on_reference_type_with_has_conversion() + public void null_on_reference_type_with_has_conversion_matches_no_row() { // Arrange var rows = EmailRows(); @@ -266,12 +274,11 @@ public void can_filter_null_on_reference_type_with_has_conversion() var result = rows.ApplyQueryKitFilter("""Email == null""", config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Email.Should().BeNull(); + result.Should().BeEmpty(); } [Fact] - public void can_filter_null_on_reference_type_with_query_name_and_has_conversion() + public void null_on_reference_type_with_query_name_and_has_conversion_throws() { // Arrange var rows = EmailRows(); @@ -282,15 +289,16 @@ public void can_filter_null_on_reference_type_with_query_name_and_has_conversion }); // Act - var result = rows.ApplyQueryKitFilter("""mail == null""", config).ToList(); + var act = () => rows.ApplyQueryKitFilter("""mail == null""", config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Email.Should().BeNull(); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("Unsupported value 'null' for type 'EmailAddressRecord'"); } [Fact] - public void can_filter_guid_with_contains_and_has_conversion() + public void guid_with_contains_and_has_conversion_throws() { // Arrange var config = new QueryKitConfiguration(config => @@ -299,11 +307,11 @@ public void can_filter_guid_with_contains_and_has_conversion() }); // Act - var result = GuidRows().ApplyQueryKitFilter("""Id @= "ab7afb17" """, config).ToList(); + var act = () => GuidRows().ApplyQueryKitFilter("""Id @= "ab7afb17" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Id.Should().Be(KnownGuid); + act.Should().ThrowExactly() + .WithMessage("Expression of type 'System.Guid' cannot be used for parameter of type 'System.String'*"); } [Fact] diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 029d6ab..119b502 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -487,26 +487,17 @@ private static Expression CreateRightExpr(Expression leftExpr, string right, Com // Check if this property uses HasConversion if (config?.PropertyMappings != null && !string.IsNullOrEmpty(propertyPath)) { - var propertyConfig = config.PropertyMappings.GetPropertyInfo(propertyPath); + var propertyConfig = config.PropertyMappings.GetPropertyInfoByQueryName(propertyPath); if (propertyConfig?.UsesConversion == true && propertyConfig.ConversionTargetType != null) { // For HasConversion properties, try to create a constant of the original type // by constructing it from the string value using a constructor that takes the target type if (propertyConfig.ConversionTargetType == typeof(string)) { - // A null literal compares against null instead of being passed to the constructor - var underlyingType = Nullable.GetUnderlyingType(leftExpr.Type); - if (right == "null" && (!leftExpr.Type.IsValueType || underlyingType != null)) - { - return Expression.Constant(null, leftExpr.Type); - } - - // Nullable structs are constructed from their underlying type, then converted back - var stringCtor = (underlyingType ?? leftExpr.Type).GetConstructor(new[] { typeof(string) }); + var stringCtor = leftExpr.Type.GetConstructor(new[] { typeof(string) }); if (stringCtor != null) { - Expression constructed = Expression.New(stringCtor, FilterValue.Create(right, typeof(string))); - return underlyingType == null ? constructed : Expression.Convert(constructed, leftExpr.Type); + return Expression.New(stringCtor, FilterValue.Create(right, typeof(string))); } } @@ -868,7 +859,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa if (temp.op.IsStringComparisonOperator()) { var guidStringExpr = HandleGuidConversion(leftExpr, leftExpr.Type); - return temp.op.GetExpression(guidStringExpr, CreateRightExpr(guidStringExpr, temp.right, temp.op, config, guidPropertyPath), + return temp.op.GetExpression(guidStringExpr, CreateRightExpr(leftExpr, temp.right, temp.op, config, guidPropertyPath), config?.DbContextType, ResolveCaseMode(guidPropertyPath, config)); } @@ -1025,9 +1016,9 @@ private static Expression CreateLeftExpr(ParameterExpression parameter, Property return RemovedClauseExpression.Instance; } - var propertyConfig = reference.Mapping; - // Check if this property uses HasConversion + var propertyConfig = config?.PropertyMappings?.GetPropertyInfoByQueryName( + config.PropertyMappings.GetPropertyPathByQueryName(reference.Text) ?? reference.Text); if (propertyConfig?.UsesConversion == true) { // For HasConversion properties, return the property expression as-is @@ -1041,7 +1032,7 @@ private static Expression CreateLeftExpr(ParameterExpression parameter, Property nestedMemberExpression.Expression is MemberExpression parentExpression) { var parentPropertyPath = GetPropertyPath(parentExpression, parameter); - var parentPropertyConfig = config?.PropertyMappings?.GetPropertyInfo(parentPropertyPath); + var parentPropertyConfig = config?.PropertyMappings?.GetPropertyInfoByQueryName(parentPropertyPath); if (parentPropertyConfig?.UsesConversion == true) { @@ -1188,11 +1179,6 @@ private static Parser PropertyListComparisonExprParser( ? reference.Mapping!.DerivedExpression! : CreateMemberExpression(parameter, reference.Path); - // Use the resolved member path for HasConversion support, since the typed path can differ in casing - var resolvedPropPath = leftExpr is MemberExpression listMemberExpr - ? GetPropertyPath(listMemberExpr, parameter) - : fullPropPath; - // Handle GUID conversion for string operators if ((leftExpr.Type == typeof(Guid) || leftExpr.Type == typeof(Guid?)) && temp.op.IsStringComparisonOperator()) @@ -1200,7 +1186,7 @@ private static Parser PropertyListComparisonExprParser( leftExpr = HandleGuidConversion(leftExpr, leftExpr.Type); } - var rightExpr = CreateRightExpr(leftExpr, temp.right, temp.op, config, resolvedPropPath); + var rightExpr = CreateRightExpr(leftExpr, temp.right, temp.op, config, fullPropPath); var comparison = temp.op.GetExpression(leftExpr, rightExpr, config?.DbContextType, ResolveCaseMode(fullPropPath, config)); // Combine with AND for negative operators, OR for positive operators From 0f1d86411984ff3f5aca637b5d264e2096e708c5 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:02:41 +0300 Subject: [PATCH 09/24] fix(filter): match a child collection member in the exact case again After a collection, only properties match again. The first segment must match in the exact case, and a later segment matches in any case. A segment that does not match throws NullReferenceException, like v1.14.2. --- QueryKit.UnitTests/FilterParserTests.cs | 35 ++++++++++++++++++++++--- QueryKit/PropertyResolver.cs | 17 +++++++++++- 2 files changed, 47 insertions(+), 5 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 0e69729..30c540a 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -556,12 +556,39 @@ public void simple_child_collection_for_string_equal() } [Fact] - public void child_collection_member_resolves_in_any_case() + public void child_collection_member_in_another_case_throws() { var input = """ingredients.name == "flour" """; - var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should() - .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => (z == "flour"))""""); + var act = () => FilterParser.ParseFilter(input); + act.Should().ThrowExactly(); + } + + [Fact] + public void unknown_child_collection_member_throws_when_unknown_properties_are_allowed() + { + var input = """Ingredients.Nope == "flour" """; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + var act = () => FilterParser.ParseFilter(input, config); + act.Should().ThrowExactly(); + } + + [Fact] + public void member_after_a_child_collection_member_resolves_in_any_case() + { + var input = """Ingredients.Preparations.text == "sifted" """; + var act = () => FilterParser.ParseFilter(input); + act.Should().NotThrow(); + } + + [Fact] + public void nested_child_collection_member_in_another_case_throws() + { + var input = """Ingredients.preparations.Text == "sifted" """; + var act = () => FilterParser.ParseFilter(input); + act.Should().ThrowExactly(); } [Fact] diff --git a/QueryKit/PropertyResolver.cs b/QueryKit/PropertyResolver.cs index dc8d457..e57b8d6 100644 --- a/QueryKit/PropertyResolver.cs +++ b/QueryKit/PropertyResolver.cs @@ -79,20 +79,35 @@ internal static PropertyReference Resolve(Type rootType, string reference, IQuer } // Matches each segment to a public member, ignoring case. A segment after a collection resolves on the element type. + // After a collection, only properties match: the first segment in the exact case, a later segment in any case. + // A segment after a collection that does not match throws NullReferenceException. private static string? ResolveMemberPath(Type rootType, string path, out string? unknownSegment) { var memberNames = new List(); var currentType = rootType; + var afterCollection = false; foreach (var segment in path.Split('.')) { + var firstAfterCollection = !afterCollection && memberNames.Count > 0 && IsCollection(currentType); while (IsCollection(currentType)) { currentType = currentType.GetGenericArguments()[0]; } - var member = (MemberInfo?)currentType.GetProperty(segment, MemberFlags) + MemberInfo? member; + if (firstAfterCollection || afterCollection) + { + member = (firstAfterCollection ? currentType.GetProperty(segment) : currentType.GetProperty(segment, MemberFlags)) + ?? throw new NullReferenceException(); + afterCollection = true; + } + else + { + member = (MemberInfo?)currentType.GetProperty(segment, MemberFlags) ?? currentType.GetField(segment, MemberFlags); + } + if (member == null) { unknownSegment = segment; From f0f48bee52605684c540675bf57471ce83928b93 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:07:10 +0300 Subject: [PATCH 10/24] fix(filter): replace query names before the parse again ParseFilter replaces each query name in front of a comparison operator with its property path again, like v1.14.2. The pass also changes a query name inside a quoted value, as v1.14.2 did. The pass also matches a query name in front of a comparison alias. The parser still reads the aliases, so the aliases inside quoted values stay as they are. --- QueryKit.UnitTests/PropertyResolverTests.cs | 23 ++++++++++++--- .../QueryKitConfigurationExtensions.cs | 28 ------------------- QueryKit/FilterParser.cs | 12 ++++---- QueryKit/QueryKitPropertyMappings.cs | 12 ++++++-- 4 files changed, 34 insertions(+), 41 deletions(-) diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 57eb776..f019245 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -426,7 +426,7 @@ public void query_name_with_a_hyphen_resolves_in_every_case() } [Fact] - public void query_name_with_a_hyphen_in_a_value_is_not_replaced() + public void query_name_with_a_hyphen_in_a_value_is_replaced() { var input = """Title == "first-name == x" """; var config = new QueryKitConfiguration(config => @@ -436,7 +436,22 @@ public void query_name_with_a_hyphen_in_a_value_is_not_replaced() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "first-name == x")"""); + filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "FirstName == x")"""); + } + + [Fact] + public void query_name_with_a_hyphen_before_an_operator_alias_filters_by_its_property() + { + var input = """first-name eq "Ann" """; + var config = new QueryKitConfiguration(config => + { + config.EqualsOperator = "eq"; + config.Property(x => x.FirstName).HasQueryName("first-name"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "Ann")"""); } [Fact] @@ -527,7 +542,7 @@ public void derived_property_query_name_with_a_hyphen_resolves_to_its_expression } [Fact] - public void query_name_in_a_value_is_not_replaced() + public void query_name_in_a_value_is_replaced() { var input = """FirstName == "name == x" """; var config = new QueryKitConfiguration(config => @@ -537,7 +552,7 @@ public void query_name_in_a_value_is_not_replaced() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "name == x")"""); + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "Title == x")"""); } [Fact] diff --git a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs index d84a576..bd3cc41 100644 --- a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs +++ b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs @@ -1,37 +1,9 @@ namespace QueryKit.Configuration; -using System.Text.RegularExpressions; using QueryKit.Exceptions; -using QueryKit.Operators; internal static class QueryKitConfigurationExtensions { - internal static string ReplaceComparisonAliases(this IQueryKitConfiguration configuration, string input) - { - var aliasedOperators = ComparisonOperator.GetAliasMatches(configuration); - foreach (var comparisonAliasMatch in aliasedOperators) - { - var escapedAlias = Regex.Escape(comparisonAliasMatch.Alias); - var regex = new Regex($@"(?<=\s|^){escapedAlias}(?=\s|$)", RegexOptions.IgnoreCase); - input = regex.Replace(input, comparisonAliasMatch.Operator); - } - - return input; - } - - internal static string ReplaceLogicalAliases(this IQueryKitConfiguration configuration, string input) - { - var aliasedOperators = LogicalOperator.GetAliasMatches(configuration); - foreach (var logicalAliasMatch in aliasedOperators) - { - var escapedAlias = Regex.Escape(logicalAliasMatch.Alias); - var regex = new Regex($@"(?<=\s|^){escapedAlias}(?=\s|$)", RegexOptions.IgnoreCase); - input = regex.Replace(input, logicalAliasMatch.Operator); - } - - return input; - } - internal static string? GetPropertyPathByQueryName(this IQueryKitConfiguration configuration, string? queryName) { return configuration.PropertyMappings.GetPropertyPathByQueryName(queryName); diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 119b502..26e6fdb 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -22,7 +22,7 @@ public static class FilterParser public static Expression> ParseFilter(string input, IQueryKitConfiguration? config = null) { EnsureWithinParseLimits(input, config); - EnsureNoQueryNameOfAPropertyPreventedForFilterAndSort(input, config); + input = ReplaceQueryNamesWithPropertyPaths(input, config); var parameter = Expression.Parameter(typeof(T), "x"); Expression expr; @@ -56,17 +56,17 @@ public static Expression> ParseFilter(string input, IQueryKitCo return Expression.Lambda>(expr, parameter); } + // Each query name in front of a comparison operator or a comparison alias is replaced with its property path before the parse. // A property that can not be filtered or sorted throws InvalidOperationException when the filter uses its query name. - // The alias passes run on a copy of the input, so the check sees the operators in the same form as the rewrite pass. - private static void EnsureNoQueryNameOfAPropertyPreventedForFilterAndSort(string input, IQueryKitConfiguration? config) + private static string ReplaceQueryNamesWithPropertyPaths(string input, IQueryKitConfiguration? config) { if (config?.PropertyMappings == null) { - return; + return input; } - var aliasesReplaced = config.ReplaceComparisonAliases(config.ReplaceLogicalAliases(input)); - config.PropertyMappings.ReplaceAliasesWithPropertyPaths(aliasesReplaced); + var comparisonAliases = ComparisonOperator.GetAliasMatches(config).Select(x => x.Alias); + return config.PropertyMappings.ReplaceAliasesWithPropertyPaths(input, comparisonAliases); } private static Expression ReplaceDerivedProperties(Expression expr, IQueryKitConfiguration? config, ParameterExpression parameter) diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index 6e2dbc7..37846fd 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -132,20 +132,26 @@ protected override Expression VisitParameter(ParameterExpression node) } public string ReplaceAliasesWithPropertyPaths(string input) + => ReplaceAliasesWithPropertyPaths(input, Enumerable.Empty()); + + // A comparison alias is an operator only as a whole word, so the filter text keeps its aliases and the parser reads them. + internal string ReplaceAliasesWithPropertyPaths(string input, IEnumerable comparisonAliases) { - var operators = ComparisonOperator.List.Select(x => x.Operator()).ToList(); + var operatorPatterns = ComparisonOperator.List.Select(x => $@"\s*{Regex.Escape(x.Operator())}") + .Concat(comparisonAliases.Select(alias => $@"\s+{Regex.Escape(alias)}(?=\s|$)")) + .ToList(); foreach (QueryKitPropertyInfo queryKitPropertyInfo in _propertyMappings.Values) { var propertyPath = GetPropertyPathByQueryName(queryKitPropertyInfo.QueryName); if (!string.IsNullOrEmpty(propertyPath)) { - foreach (var op in operators) + foreach (var operatorPattern in operatorPatterns) { // Use regular expression to isolate left side of the expression. Query names and // operators are matched literally, so escape any regex metacharacters they contain // (e.g. the `^` in `^^` would otherwise be read as a start-of-line anchor). - var regex = AliasRegexCache.Get($@"\b{Regex.Escape(queryKitPropertyInfo.QueryName!)}\b(?=\s*{Regex.Escape(op)})"); + var regex = AliasRegexCache.Get($@"\b{Regex.Escape(queryKitPropertyInfo.QueryName!)}\b(?={operatorPattern})"); if (queryKitPropertyInfo is { CanSort: false, CanFilter: false} && regex.IsMatch(input)) { From 5e7d91813dc732d85bb219234b115d6811f39b4a Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:09:00 +0300 Subject: [PATCH 11/24] fix(filter): resolve query names only in front of an operator again The property resolver no longer maps a query name to its property path, and the grammar reads only identifier paths again. A query name works through the rewrite before the parse, like v1.14.2. A query name in a property list throws UnknownFilterPropertyException again, and a query name in arithmetic throws ArgumentException again. A query name with a hyphen, a leading underscore, or a space still works in front of an operator. --- .../Tests/PropertyResolverTests.cs | 55 ------------------- QueryKit.UnitTests/PropertyResolverTests.cs | 38 +++++++++---- QueryKit/FilterParser.cs | 31 +---------- QueryKit/PropertyResolver.cs | 6 +- QueryKit/QueryKitPropertyMappings.cs | 8 --- 5 files changed, 30 insertions(+), 108 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 0ffc1f1..8513412 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -93,61 +93,6 @@ public async Task prevented_property_clause_under_or_does_not_return_every_row() people.Should().BeEmpty(); } - [Fact] - public async Task query_name_in_a_property_list_is_filtered() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("Paul") - .WithLastName("Other") - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - var input = $"""Title == "{title}" && (first, LastName) == "Paul" """; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.FirstName).HasQueryName("first"); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().ContainSingle(x => x.Id == fakePerson.Id); - } - - [Fact] - public async Task query_name_in_arithmetic_is_filtered() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithAge(30) - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - var input = $"""Title == "{title}" && (years + 0) > 20"""; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.Age).HasQueryName("years"); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().ContainSingle(x => x.Id == fakePerson.Id); - } - [Theory] [InlineData("first-name")] [InlineData("_first")] diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index f019245..40b340d 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -365,7 +365,7 @@ public void prevented_derived_sort_property_still_sorts() } [Fact] - public void query_name_in_a_property_list_resolves_to_its_property() + public void query_name_in_a_property_list_throws() { var input = """(name, FirstName) == "x" """; var config = new QueryKitConfiguration(config => @@ -373,13 +373,27 @@ public void query_name_in_a_property_list_resolves_to_its_property() config.Property(x => x.Title).HasQueryName("name"); }); - var filterExpression = FilterParser.ParseFilter(input, config); + var act = () => FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => ((x.Title == "x") OrElse (x.FirstName == "x"))"""); + act.Should().ThrowExactly().WithMessage("*'name'*"); } [Fact] - public void query_name_in_arithmetic_resolves_to_its_property() + public void query_name_of_a_prevented_property_in_a_property_list_throws() + { + var input = """(hidden, Title) == "x" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.FirstName).HasQueryName("hidden").PreventFilter().PreventSort(); + }); + + var act = () => FilterParser.ParseFilter(input, config); + + act.Should().ThrowExactly().WithMessage("*'hidden'*"); + } + + [Fact] + public void query_name_in_arithmetic_throws() { var input = """(stars + 0) > 3"""; var config = new QueryKitConfiguration(config => @@ -387,9 +401,9 @@ public void query_name_in_arithmetic_resolves_to_its_property() config.Property(x => x.Rating).HasQueryName("stars"); }); - var filterExpression = FilterParser.ParseFilter(input, config); + var act = () => FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Contain("x.Rating"); + act.Should().ThrowExactly(); } [Theory] @@ -514,7 +528,7 @@ public void query_name_does_not_match_the_start_of_a_longer_property_name() } [Fact] - public void query_name_with_a_hyphen_in_a_property_list_resolves_to_its_property() + public void query_name_with_a_hyphen_in_a_property_list_throws() { var input = """(first-name, Title) == "x" """; var config = new QueryKitConfiguration(config => @@ -522,13 +536,13 @@ public void query_name_with_a_hyphen_in_a_property_list_resolves_to_its_property config.Property(x => x.FirstName).HasQueryName("first-name"); }); - var filterExpression = FilterParser.ParseFilter(input, config); + var act = () => FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => ((x.FirstName == "x") OrElse (x.Title == "x"))"""); + act.Should().ThrowExactly().WithMessage("*'first'*"); } [Fact] - public void derived_property_query_name_with_a_hyphen_resolves_to_its_expression() + public void derived_property_query_name_with_a_hyphen_throws() { var input = """full-name == "Ann Lee" """; var config = new QueryKitConfiguration(config => @@ -536,9 +550,9 @@ public void derived_property_query_name_with_a_hyphen_resolves_to_its_expression config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full-name"); }); - var filterExpression = FilterParser.ParseFilter(input, config); + var act = () => FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (((x.FirstName + " ") + x.LastName) == "Ann Lee")"""); + act.Should().ThrowExactly().WithMessage("*'full'*"); } [Fact] diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 26e6fdb..a0227ac 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -121,33 +121,6 @@ from rest in Parse.LetterOrDigit.XOr(Parse.Char('_')).Many() private static readonly Parser IdentifierPathParser = Identifier.DelimitedBy(Parse.Char('.')).Select(parts => string.Join(".", parts)); - // A property is a configured query name or a path of identifiers. Query names are matched in the grammar, - // so a query name can hold any text (e.g. `first-name`, `_first`, or `first name`) and text inside quoted values is never changed. - // Longer query names are tried first so a query name that starts with another query name (e.g. `first` and `first name`) still matches. - private static Parser PropertyPathParser(IQueryKitConfiguration? config) - { - Parser parser = i => Result.Failure(i, "no query name", Array.Empty()); - var queryNames = config?.PropertyMappings?.QueryNames ?? Enumerable.Empty(); - foreach (var queryName in queryNames.OrderByDescending(x => x.Length)) - { - parser = parser.Or(QueryName(queryName)); - } - - return parser.Or(IdentifierPathParser); - } - - // A query name is a whole name: the next character can not continue a property path. - private static Parser QueryName(string queryName) => input => - { - var result = Parse.IgnoreCase(queryName).Text()(input); - if (!result.WasSuccessful || result.Remainder.AtEnd || !IsPropertyPathChar(result.Remainder.Current)) - return result; - - return Result.Failure(input, $"Query name '{queryName}' must not be followed by '{result.Remainder.Current}'", new[] { queryName }); - }; - - private static bool IsPropertyPathChar(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '.'; - private static Parser> PropertyListParser(Parser propertyPathParser) { var propertiesParser = propertyPathParser.Token().DelimitedBy(Parse.Char(',').Token()); @@ -991,7 +964,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa private static Parser CreateLeftExprParser(Type entityType, IQueryKitConfiguration? config) { - var leftPropertyParser = PropertyPathParser(config).Token(); + var leftPropertyParser = IdentifierPathParser.Token(); return leftPropertyParser.Select(left => { @@ -1135,7 +1108,7 @@ private static Parser PropertyListComparisonExprParser( var comparisonOperatorParser = ComparisonOperatorParser(config).Token(); var rightSideValueParser = RightSideValueParser.Token(); - return PropertyListParser(PropertyPathParser(config)) + return PropertyListParser(IdentifierPathParser) .SelectMany(properties => comparisonOperatorParser, (properties, op) => new { properties, op }) .SelectMany(temp => rightSideValueParser, diff --git a/QueryKit/PropertyResolver.cs b/QueryKit/PropertyResolver.cs index e57b8d6..2fbe954 100644 --- a/QueryKit/PropertyResolver.cs +++ b/QueryKit/PropertyResolver.cs @@ -53,11 +53,9 @@ internal static class PropertyResolver { internal static PropertyReference Resolve(Type rootType, string reference, IQueryKitConfiguration? config) { - // A query name resolves to the property path of its mapping first - var path = config?.PropertyMappings?.GetPropertyPathByQueryName(reference) ?? reference; - config?.ValidatePropertyDepth(path); + config?.ValidatePropertyDepth(reference); - var memberPath = ResolveMemberPath(rootType, path, out var unknownSegment); + var memberPath = ResolveMemberPath(rootType, reference, out var unknownSegment); if (memberPath != null) { return PropertyReference.Member(reference, memberPath, config?.PropertyMappings?.GetPropertyInfo(memberPath)); diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index 37846fd..aa8f8ac 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -14,14 +14,6 @@ public class QueryKitPropertyMappings internal IReadOnlyDictionary DerivedPropertyMappings => _derivedPropertyMappings; internal IReadOnlyDictionary CustomOperationMappings => _customOperationMappings; - // Every query name of a property, a derived property, or a custom operation - internal IEnumerable QueryNames - => _propertyMappings.Values.Concat(_derivedPropertyMappings.Values).Concat(_customOperationMappings.Values) - .Select(info => info.QueryName) - .Where(queryName => !string.IsNullOrEmpty(queryName)) - .Select(queryName => queryName!) - .Distinct(StringComparer.InvariantCultureIgnoreCase); - public QueryKitPropertyMapping Property(Expression>? propertySelector) { var fullPath = GetFullPropertyPath(propertySelector); From 8a09c2512c0b8f72a8991deb6c94af8791a1e74f Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:09:31 +0300 Subject: [PATCH 12/24] fix(filter): skip the property depth check in arithmetic again Arithmetic builds its property paths from the filter text again, like v1.14.2. A property path inside arithmetic does not go through the property resolver, so MaxPropertyDepth does not apply to it. Security consequence: arithmetic can go deeper than MaxPropertyDepth again, as in v1.14.2. --- QueryKit.UnitTests/PropertyResolverTests.cs | 4 +-- QueryKit/FilterParser.cs | 31 ++------------------- 2 files changed, 4 insertions(+), 31 deletions(-) diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 40b340d..4dbb50b 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -190,7 +190,7 @@ public void prevented_property_on_the_right_side_of_arithmetic_is_still_filtered } [Fact] - public void arithmetic_property_obeys_max_property_depth() + public void arithmetic_property_skips_max_property_depth() { var input = """(Recipe.Rating + 0) > 1"""; var config = new QueryKitConfiguration(config => @@ -200,7 +200,7 @@ public void arithmetic_property_obeys_max_property_depth() var act = () => FilterParser.ParseFilter(input, config); - act.Should().Throw(); + act.Should().NotThrow(); } [Fact] diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index a0227ac..e2dcb14 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -695,41 +695,14 @@ private static Parser ArithmeticComparisonExprParser(ParameterExp .SelectMany(temp => parenthesizedArithmetic.Or(rightSideValueParser.Select(value => CreateArithmeticFromValue(value.Value))), (temp, rightSide) => new { temp.leftArithmetic, temp.op, rightSide }) .Select(temp => { - var leftArithmetic = ResolveArithmeticProperties(temp.leftArithmetic, typeof(T), config); - var rightArithmetic = ResolveArithmeticProperties(temp.rightSide, typeof(T), config); - - var leftExpr = leftArithmetic.ToLinqExpression(parameter, typeof(T)); - var rightExpr = rightArithmetic.ToLinqExpression(parameter, typeof(T)); + var leftExpr = temp.leftArithmetic.ToLinqExpression(parameter, typeof(T)); + var rightExpr = temp.rightSide.ToLinqExpression(parameter, typeof(T)); var (leftCompatible, rightCompatible) = EnsureCompatibleTypes(leftExpr, rightExpr); return temp.op.GetExpression(leftCompatible, rightCompatible, config?.DbContextType); }); } - // Resolves each property in an arithmetic expression to its member path. - private static ArithmeticExpression ResolveArithmeticProperties(ArithmeticExpression expr, Type entityType, IQueryKitConfiguration? config) - { - switch (expr) - { - case PropertyArithmeticExpression property: - var reference = PropertyResolver.Resolve(entityType, property.PropertyPath, config); - if (reference.Kind != PropertyReferenceKind.Member) - { - return property; - } - - return new PropertyArithmeticExpression(reference.Path); - case BinaryArithmeticExpression binary: - var left = ResolveArithmeticProperties(binary.Left, entityType, config); - var right = ResolveArithmeticProperties(binary.Right, entityType, config); - return new BinaryArithmeticExpression(left, binary.Operator, right); - case GroupedArithmeticExpression grouped: - return new GroupedArithmeticExpression(ResolveArithmeticProperties(grouped.Inner, entityType, config)); - default: - return expr; - } - } - private static bool ContainsArithmeticOperator(ArithmeticExpression expr) { return expr switch From f5209e5c6a80fd12e13e42976a56ecd307cb51e9 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:25:45 +0300 Subject: [PATCH 13/24] fix(filter): replace operator aliases before the parse again v1.14.2 replaced each operator alias that stands between whitespace with a regex before the parse. This also changed alias text inside a quoted value, so Title eq "salt and pepper" compares with "salt && pepper". Main read the aliases only in the grammar, so the value stayed unchanged and the result was different. The rewrite runs again, in the v1.14.2 order: logical aliases, comparison aliases, then query names. The query-name rewrite goes back to the v1.14.2 pattern, because the aliases are already replaced when it runs. The grammar still reads an alias that the rewrite did not replace, for example (Age)eq 3, which v1.14.2 rejected. The grammar tries the canonical operator first, like v1.14.2. Restores 39312a1. --- .../Tests/FilterParsingRegressionTests.cs | 5 ++-- QueryKit.UnitTests/OperatorAliasTests.cs | 12 ++++---- .../QueryKitConfigurationExtensions.cs | 28 +++++++++++++++++++ QueryKit/FilterParser.cs | 23 +++++---------- QueryKit/QueryKitPropertyMappings.cs | 12 ++------ 5 files changed, 46 insertions(+), 34 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs index b421244..cc62d93 100644 --- a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs +++ b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs @@ -15,7 +15,7 @@ namespace QueryKit.IntegrationTests.Tests; public class FilterParsingRegressionTests : TestBase { [Fact] - public async Task operator_alias_text_inside_quoted_value_is_kept() + public async Task operator_alias_text_inside_quoted_value_is_replaced() { // Arrange var testingServiceScope = new TestingServiceScope(); @@ -41,8 +41,7 @@ public async Task operator_alias_text_inside_quoted_value_is_kept() var people = await appliedQueryable.ToListAsync(); // Assert - people.Count.Should().Be(1); - people[0].Id.Should().Be(fakePersonOne.Id); + people.Should().BeEmpty(); } [Fact] diff --git a/QueryKit.UnitTests/OperatorAliasTests.cs b/QueryKit.UnitTests/OperatorAliasTests.cs index 1751706..68c83ff 100644 --- a/QueryKit.UnitTests/OperatorAliasTests.cs +++ b/QueryKit.UnitTests/OperatorAliasTests.cs @@ -205,27 +205,27 @@ public void can_use_ints() } [Fact] - public void alias_text_inside_quoted_value_is_not_replaced() + public void alias_text_inside_quoted_value_is_replaced() { var input = """Title eq "salt and pepper or eq gt" and Rating gt 3"""; var config = new CustomQueryKitConfiguration(); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "salt and pepper or eq gt") AndAlso (x.Rating > 3))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "salt && pepper || == gt") AndAlso (x.Rating > 3))"""); } [Fact] - public void case_insensitive_alias_text_inside_quoted_value_is_not_replaced() + public void case_insensitive_alias_text_inside_quoted_value_is_replaced() { var input = """Title eqi "Whisk AND fry" """; var config = new CustomQueryKitConfiguration(); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title != null) AndAlso (x.Title.ToLower() == "Whisk AND fry".ToLower()))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title != null) AndAlso (x.Title.ToLower() == "Whisk && fry".ToLower()))"""); } [Fact] - public void can_use_alias_operator_with_query_name() + public void can_use_alias_operator_with_query_name_and_alias_text_in_the_value_is_replaced() { var input = """name eq "a eq b" """; @@ -234,7 +234,7 @@ public void can_use_alias_operator_with_query_name() settings.Property(x => x.Title).HasQueryName("name"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "a eq b")"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "a == b")"""); } public class CustomQueryKitConfiguration : QueryKitConfiguration diff --git a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs index bd3cc41..f196d49 100644 --- a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs +++ b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs @@ -1,9 +1,37 @@ namespace QueryKit.Configuration; +using System.Text.RegularExpressions; using QueryKit.Exceptions; +using QueryKit.Operators; internal static class QueryKitConfigurationExtensions { + internal static string ReplaceComparisonAliases(this IQueryKitConfiguration configuration, string input) + { + var aliasedOperators = ComparisonOperator.GetAliasMatches(configuration); + foreach (var comparisonAliasMatch in aliasedOperators) + { + var escapedAlias = Regex.Escape(comparisonAliasMatch.Alias); + var regex = AliasRegexCache.Get($@"(?<=\s|^){escapedAlias}(?=\s|$)"); + input = regex.Replace(input, comparisonAliasMatch.Operator); + } + + return input; + } + + internal static string ReplaceLogicalAliases(this IQueryKitConfiguration configuration, string input) + { + var aliasedOperators = LogicalOperator.GetAliasMatches(configuration); + foreach (var logicalAliasMatch in aliasedOperators) + { + var escapedAlias = Regex.Escape(logicalAliasMatch.Alias); + var regex = AliasRegexCache.Get($@"(?<=\s|^){escapedAlias}(?=\s|$)"); + input = regex.Replace(input, logicalAliasMatch.Operator); + } + + return input; + } + internal static string? GetPropertyPathByQueryName(this IQueryKitConfiguration configuration, string? queryName) { return configuration.PropertyMappings.GetPropertyPathByQueryName(queryName); diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index e2dcb14..07de80d 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -22,7 +22,10 @@ public static class FilterParser public static Expression> ParseFilter(string input, IQueryKitConfiguration? config = null) { EnsureWithinParseLimits(input, config); - input = ReplaceQueryNamesWithPropertyPaths(input, config); + + input = config?.ReplaceLogicalAliases(input) ?? input; + input = config?.ReplaceComparisonAliases(input) ?? input; + input = config?.PropertyMappings?.ReplaceAliasesWithPropertyPaths(input) ?? input; var parameter = Expression.Parameter(typeof(T), "x"); Expression expr; @@ -56,19 +59,6 @@ public static Expression> ParseFilter(string input, IQueryKitCo return Expression.Lambda>(expr, parameter); } - // Each query name in front of a comparison operator or a comparison alias is replaced with its property path before the parse. - // A property that can not be filtered or sorted throws InvalidOperationException when the filter uses its query name. - private static string ReplaceQueryNamesWithPropertyPaths(string input, IQueryKitConfiguration? config) - { - if (config?.PropertyMappings == null) - { - return input; - } - - var comparisonAliases = ComparisonOperator.GetAliasMatches(config).Select(x => x.Alias); - return config.PropertyMappings.ReplaceAliasesWithPropertyPaths(input, comparisonAliases); - } - private static Expression ReplaceDerivedProperties(Expression expr, IQueryKitConfiguration? config, ParameterExpression parameter) { if (config?.PropertyMappings == null) @@ -165,12 +155,13 @@ from closeParen in Parse.Char(')') private static Parser ComparisonOperatorParser(IQueryKitConfiguration? config) { - var operatorParser = ComparisonOperatorAliasParser(config).Or(CanonicalComparisonOperatorParser); + var operatorParser = CanonicalComparisonOperatorParser.Or(ComparisonOperatorAliasParser(config)); return Parse.Char(ComparisonOperator.AllPrefix).Optional().Select(opt => opt.IsDefined) .Then(hasHash => operatorParser.Select(x => ComparisonOperator.GetByOperatorString(x.Operator, x.CaseInsensitive, hasHash))); } - // Aliases are matched in the grammar (not by rewriting the input) so text inside quoted values is never changed. + // The rewrite before the parse replaces each alias that stands between whitespace, like v1.14.2. + // The grammar reads an alias that the rewrite did not replace, for example `(Age)eq 3`. // Longer aliases are tried first so an alias that starts with another alias (e.g. `@@$$` and `@@$`) still matches. private static Parser<(string Operator, bool CaseInsensitive)> ComparisonOperatorAliasParser(IQueryKitConfiguration? config) { diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index aa8f8ac..9408114 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -124,26 +124,20 @@ protected override Expression VisitParameter(ParameterExpression node) } public string ReplaceAliasesWithPropertyPaths(string input) - => ReplaceAliasesWithPropertyPaths(input, Enumerable.Empty()); - - // A comparison alias is an operator only as a whole word, so the filter text keeps its aliases and the parser reads them. - internal string ReplaceAliasesWithPropertyPaths(string input, IEnumerable comparisonAliases) { - var operatorPatterns = ComparisonOperator.List.Select(x => $@"\s*{Regex.Escape(x.Operator())}") - .Concat(comparisonAliases.Select(alias => $@"\s+{Regex.Escape(alias)}(?=\s|$)")) - .ToList(); + var operators = ComparisonOperator.List.Select(x => x.Operator()).ToList(); foreach (QueryKitPropertyInfo queryKitPropertyInfo in _propertyMappings.Values) { var propertyPath = GetPropertyPathByQueryName(queryKitPropertyInfo.QueryName); if (!string.IsNullOrEmpty(propertyPath)) { - foreach (var operatorPattern in operatorPatterns) + foreach (var op in operators) { // Use regular expression to isolate left side of the expression. Query names and // operators are matched literally, so escape any regex metacharacters they contain // (e.g. the `^` in `^^` would otherwise be read as a start-of-line anchor). - var regex = AliasRegexCache.Get($@"\b{Regex.Escape(queryKitPropertyInfo.QueryName!)}\b(?={operatorPattern})"); + var regex = AliasRegexCache.Get($@"\b{Regex.Escape(queryKitPropertyInfo.QueryName!)}\b(?=\s*{Regex.Escape(op)})"); if (queryKitPropertyInfo is { CanSort: false, CanFilter: false} && regex.IsMatch(input)) { From 01e8b50f8ebe1f4950e7ddccff4ec4a0f004aade Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:26:29 +0300 Subject: [PATCH 14/24] fix(filter): split a list value on every comma again v1.14.2 split the value of the in and not-in operators on every comma, also on a comma inside a quoted item. Serving ^^ ["Warm, with syrup"] reads the two items Warm and with syrup. Main kept the quoted item as one item, so the same filter gave different rows. Restores c90861c. --- .../Tests/FilterParsingRegressionTests.cs | 6 +-- .../FilterParsingRegressionTests.cs | 10 ++--- QueryKit/FilterParser.cs | 37 ++----------------- 3 files changed, 11 insertions(+), 42 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs index cc62d93..82d0c0c 100644 --- a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs +++ b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs @@ -82,7 +82,7 @@ public async Task decimal_value_uses_invariant_culture() } [Fact] - public async Task list_value_with_comma_is_one_item() + public async Task list_value_with_comma_is_split_into_items() { // Arrange var testingServiceScope = new TestingServiceScope(); @@ -106,8 +106,8 @@ public async Task list_value_with_comma_is_one_item() var notInPeople = await queryablePeople.ApplyQueryKitFilter(notInInput).ToListAsync(); // Assert - inPeople.Select(x => x.Id).Should().Equal(fakePersonOne.Id); - notInPeople.Select(x => x.Id).Should().Equal(fakePersonTwo.Id); + inPeople.Select(x => x.Id).Should().Equal(fakePersonTwo.Id); + notInPeople.Select(x => x.Id).Should().Equal(fakePersonOne.Id); } [Theory] diff --git a/QueryKit.UnitTests/FilterParsingRegressionTests.cs b/QueryKit.UnitTests/FilterParsingRegressionTests.cs index 2116f59..14ec340 100644 --- a/QueryKit.UnitTests/FilterParsingRegressionTests.cs +++ b/QueryKit.UnitTests/FilterParsingRegressionTests.cs @@ -95,12 +95,12 @@ public void decimal_value_uses_invariant_culture_in_memory() } [Theory] - [InlineData("Title ^^ [\"Warm, with syrup\", \"a\\b\"]", new[] { "Warm, with syrup", "a\\b" })] - [InlineData("Title !^^ [\"Warm, with syrup\", \"a\\b\"]", new[] { "Warm", "with syrup" })] - [InlineData("Title ^^* [\"WARM, WITH SYRUP\"]", new[] { "Warm, with syrup" })] - [InlineData("Title ^^ [\"\"\"Warm, with syrup\"\"\", \"Warm\"]", new[] { "Warm, with syrup", "Warm" })] + [InlineData("Title ^^ [\"Warm, with syrup\", \"a\\b\"]", new[] { "Warm", "with syrup", "a\\b" })] + [InlineData("Title !^^ [\"Warm, with syrup\", \"a\\b\"]", new[] { "Warm, with syrup" })] + [InlineData("Title ^^* [\"WARM, WITH SYRUP\"]", new[] { "Warm", "with syrup" })] + [InlineData("Title ^^ [\"\"\"Warm, with syrup\"\"\", \"Warm\"]", new[] { "Warm", "with syrup" })] [InlineData("Title ^^ [\" Warm \", \"with syrup \"]", new[] { "Warm", "with syrup" })] - public void list_value_with_comma_is_one_item(string input, string[] expectedTitles) + public void list_value_with_comma_is_split_into_items(string input, string[] expectedTitles) { var people = new[] { diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 07de80d..454107e 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -3,7 +3,6 @@ using System.Globalization; using System.Linq.Expressions; using System.Reflection; -using System.Text; using Configuration; using Exceptions; using Operators; @@ -272,37 +271,7 @@ from closingQuotes in Parse.Char('"').Repeat(count).Text() from openingBracket in Parse.Char('[') from content in SquareBracketValuesParser from closingBracket in Parse.Char(']') - select "[" + string.Join(",", content.Select(EscapeListItem)) + "]"; - - // List items are joined with ',' so quoted items that contain ',' or '\' are escaped and split back with SplitListItems, which trims each item - private static string EscapeListItem(string item) - => item.Replace(@"\", @"\\").Replace(",", @"\,"); - - private static List SplitListItems(string list) - { - var items = new List(); - var current = new StringBuilder(); - var content = list.Substring(1, list.Length - 2); - for (var i = 0; i < content.Length; i++) - { - if (content[i] == '\\' && i + 1 < content.Length) - { - current.Append(content[++i]); - } - else if (content[i] == ',') - { - items.Add(current.ToString().Trim()); - current.Clear(); - } - else - { - current.Append(content[i]); - } - } - items.Add(current.ToString().Trim()); - - return items; - } + select "[" + string.Join(",", content) + "]"; private static readonly Parser RightSideValueChoiceParser = Parse.String("null").Text().Select(v => new RightSideValue(v, false)) @@ -511,7 +480,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ { targetType = typeof(string); } - var values = SplitListItems(right); + var values = right.Trim('[', ']').Split(',').Select(x => x.Trim()).ToList(); var elementType = targetType.IsArray ? targetType.GetElementType()! : targetType; var expressions = values.Select(x => @@ -603,7 +572,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ if (right.StartsWith("[") && right.EndsWith("]")) { - var values = SplitListItems(right); + var values = right.Trim('[', ']').Split(',').Select(x => x.Trim()).ToList(); var elementType = targetType.IsArray ? targetType.GetElementType() : targetType; var expressions = values.Select(x => From 7198ac29ab7fc2cfc8830d601532b4eab6496728 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:28:20 +0300 Subject: [PATCH 15/24] fix(filter): keep the offset of a date time offset literal again v1.14.2 kept the offset of a DateTimeOffset filter value, so 2022-07-01T00:00:03+01:00 gave new DateTimeOffset(..., 01:00:00). Main converted every value to UTC, also when ParameterizeFilterValues is off, so the expression text and the value changed. The value goes to UTC only when ParameterizeFilterValues is on, because Npgsql accepts a DateTimeOffset parameter only with offset 0. Without parameters the value keeps its offset, like v1.14.2. Restores ea5cc66 for the default path. --- QueryKit.UnitTests/FilterParserTests.cs | 8 ++++---- QueryKit/FilterParser.cs | 10 +++++++--- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 30c540a..9db4d38 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -273,7 +273,7 @@ public void can_handle_datetime_comparison_with_timezone() { var input = """SpecificDate == 2022-07-01T00:00:03+01:00"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922268030000000, 00:00:00)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 01:00:00)))"); } [Theory] @@ -308,7 +308,7 @@ public void can_handle_datetime_comparison_with_timezone_another() { var input = """SpecificDate == "2022-07-01T00:00:03+01:00" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922268030000000, 00:00:00)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 01:00:00)))"); } [Fact] @@ -324,7 +324,7 @@ public void can_handle_datetime_comparison_with_negative_timezone() { var input = """SpecificDate == 2022-07-01T00:00:03-02:00"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922376030000000, 00:00:00)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, -02:00:00)))"); } [Fact] @@ -332,7 +332,7 @@ public void can_handle_datetime_comparison_with_timezone_no_minutes() { var input = """SpecificDate == 2022-07-01T00:00:03+02"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922232030000000, 00:00:00)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 02:00:00)))"); } [Fact] diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 454107e..e0a05f5 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -344,6 +344,11 @@ from trailingSpaces in Parse.WhiteSpace.Many() select LogicalOperator.GetByOperatorString(match.Operator))); } + // Npgsql only accepts a DateTimeOffset parameter with offset 0, so a parameter gets the same instant in UTC. + // A literal keeps its offset, like v1.14.2. + private static DateTimeOffset ToParameterOffset(DateTimeOffset value) + => FilterValue.Parameterize ? value.ToUniversalTime() : value; + private static readonly Dictionary> TypeConversionFunctions = new() { { typeof(string), value => value }, @@ -358,7 +363,7 @@ from trailingSpaces in Parse.WhiteSpace.Many() { typeof(short), value => short.Parse(value, CultureInfo.InvariantCulture) }, { typeof(byte), value => byte.Parse(value, CultureInfo.InvariantCulture) }, { typeof(DateTime), value => DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal) }, - { typeof(DateTimeOffset), value => DateTimeOffset.Parse(value).ToUniversalTime() }, + { typeof(DateTimeOffset), value => ToParameterOffset(DateTimeOffset.Parse(value)) }, { typeof(DateOnly), value => DateOnly.Parse(value) }, { typeof(TimeOnly), value => TimeOnly.Parse(value) }, { typeof(TimeSpan), value => TimeSpan.Parse(value) }, @@ -519,8 +524,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ { var dtStyle = right.EndsWith("Z") ? DateTimeStyles.AdjustToUniversal : DateTimeStyles.AssumeLocal; var dto = DateTimeOffset.Parse(right, CultureInfo.InvariantCulture, dtStyle); - // Npgsql only accepts a DateTimeOffset parameter with offset 0. The UTC value is the same instant. - return FilterValue.Create(dto.ToUniversalTime(), rawType); + return FilterValue.Create(ToParameterOffset(dto), rawType); } if (targetType == typeof(DateOnly)) From 2fa061a1c682f0880f66e3606b73b3219b2146d2 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:28:33 +0300 Subject: [PATCH 16/24] test(filter): give the filtered recipe a unique title can_filter_enumerable filtered two fake recipes by the title of the first one. AutoBogus fills the title with one random word, so both recipes sometimes had the same title and the test found two rows. --- QueryKit.UnitTests/EnumerableFilteringTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QueryKit.UnitTests/EnumerableFilteringTests.cs b/QueryKit.UnitTests/EnumerableFilteringTests.cs index dc2d1b3..88a476d 100644 --- a/QueryKit.UnitTests/EnumerableFilteringTests.cs +++ b/QueryKit.UnitTests/EnumerableFilteringTests.cs @@ -11,7 +11,7 @@ public class EnumerableFilteringTests() public async Task can_filter_enumerable() { // Arrange - var recipeOne = new FakeRecipeBuilder().Build(); + var recipeOne = new FakeRecipeBuilder().WithTitle(Guid.NewGuid().ToString()).Build(); var recipeTwo = new FakeRecipeBuilder().Build(); var listOfRecipes = new List { recipeOne, recipeTwo }; From b49ad2b37d8b39a2342532e41f7e8ebd10599cd2 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:33:04 +0300 Subject: [PATCH 17/24] fix(filter): read the zone before the fraction and drop a short quoted time fraction again Restores the break parts of 5c84ef6. A date time value with the zone before the fraction, for example 2024-01-15T08:00:00Z.5, parses again. A quoted time keeps the v1.14.2 fraction rule: milliseconds need 3 digits and microseconds need 6. The parts that v1.14.2 rejected stay: a fraction before the zone, 7 fraction digits, an unquoted time fraction, and a time list. --- .../Tests/FilterParsingRegressionTests.cs | 14 ++--- .../FilterParsingRegressionTests.cs | 53 +++++++++++-------- QueryKit/FilterParser.cs | 51 +++++++++++++----- 3 files changed, 77 insertions(+), 41 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs index 82d0c0c..3f9045f 100644 --- a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs +++ b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs @@ -142,11 +142,13 @@ public async Task date_time_offset_value_with_offset_matches_same_instant(string } [Theory] - [InlineData("SpecificDateTime == 2024-01-15T08:00:00.500Z")] - [InlineData("SpecificDate == 2024-01-15T10:00:00.5+02:00")] - [InlineData("Time == 08:30:00.5")] - [InlineData("Time == \"08:30:00.5\"")] - public async Task fractional_seconds_are_kept(string valueFilter) + [InlineData("SpecificDateTime == 2024-01-15T08:00:00.500Z", true)] + [InlineData("SpecificDateTime == 2024-01-15T08:00:00Z.5", true)] + [InlineData("SpecificDate == 2024-01-15T10:00:00.5+02:00", true)] + [InlineData("Time == 08:30:00.5", true)] + [InlineData("Time == \"08:30:00.500\"", true)] + [InlineData("Time == \"08:30:00.5\"", false)] + public async Task fractional_second_value_matches_by_its_fraction(string valueFilter, bool expectFractionPerson) { // Arrange var testingServiceScope = new TestingServiceScope(); @@ -172,7 +174,7 @@ public async Task fractional_seconds_are_kept(string valueFilter) var people = await queryablePeople.ApplyQueryKitFilter(input).ToListAsync(); // Assert - people.Select(x => x.Id).Should().Equal(fakePersonOne.Id); + people.Select(x => x.Id).Should().Equal(expectFractionPerson ? fakePersonOne.Id : fakePersonTwo.Id); } [Theory] diff --git a/QueryKit.UnitTests/FilterParsingRegressionTests.cs b/QueryKit.UnitTests/FilterParsingRegressionTests.cs index 14ec340..90ee00d 100644 --- a/QueryKit.UnitTests/FilterParsingRegressionTests.cs +++ b/QueryKit.UnitTests/FilterParsingRegressionTests.cs @@ -137,35 +137,46 @@ public void enum_list_value_is_split_into_items() [InlineData("SpecificDateTime ^^ [2024-01-15T08:00:00.5Z]")] [InlineData("SpecificDate == 2024-01-15T10:00:00.5+02:00")] [InlineData("SpecificDate == 2024-01-15T08:00:00.5000000Z")] + [InlineData("SpecificDateTime == 2024-01-15T08:00:00Z.5")] + [InlineData("SpecificDate == 2024-01-15T10:00:00+02:00.500")] [InlineData("Time == 08:30:00.5")] - [InlineData("Time == \"08:30:00.5\"")] - [InlineData("Time == \"08:30:00.50\"")] + [InlineData("Time == \"08:30:00.500\"")] [InlineData("Time ^^ [08:30:00.5]")] public void fractional_seconds_are_kept(string input) { - var people = new[] - { - new TestingPerson - { - Title = "match", - SpecificDateTime = new DateTime(2024, 1, 15, 8, 0, 0, 500, DateTimeKind.Utc), - SpecificDate = new DateTimeOffset(2024, 1, 15, 8, 0, 0, 500, TimeSpan.Zero), - Time = new TimeOnly(8, 30, 0, 500), - }, - new TestingPerson - { - Title = "whole second", - SpecificDateTime = new DateTime(2024, 1, 15, 8, 0, 0, DateTimeKind.Utc), - SpecificDate = new DateTimeOffset(2024, 1, 15, 8, 0, 0, TimeSpan.Zero), - Time = new TimeOnly(8, 30, 0), - }, - }; - - var result = people.AsQueryable().ApplyQueryKitFilter(input).ToList(); + var result = FractionalSecondPeople().AsQueryable().ApplyQueryKitFilter(input).ToList(); result.Select(x => x.Title).Should().Equal("match"); } + [Theory] + [InlineData("Time == \"08:30:00.5\"")] + [InlineData("Time == \"08:30:00.50\"")] + public void quoted_time_with_fewer_than_three_fraction_digits_drops_the_fraction(string input) + { + var result = FractionalSecondPeople().AsQueryable().ApplyQueryKitFilter(input).ToList(); + + result.Select(x => x.Title).Should().Equal("whole second"); + } + + private static TestingPerson[] FractionalSecondPeople() => new[] + { + new TestingPerson + { + Title = "match", + SpecificDateTime = new DateTime(2024, 1, 15, 8, 0, 0, 500, DateTimeKind.Utc), + SpecificDate = new DateTimeOffset(2024, 1, 15, 8, 0, 0, 500, TimeSpan.Zero), + Time = new TimeOnly(8, 30, 0, 500), + }, + new TestingPerson + { + Title = "whole second", + SpecificDateTime = new DateTime(2024, 1, 15, 8, 0, 0, DateTimeKind.Utc), + SpecificDate = new DateTimeOffset(2024, 1, 15, 8, 0, 0, TimeSpan.Zero), + Time = new TimeOnly(8, 30, 0), + }, + }; + [Fact] public void time_fraction_keeps_microseconds() { diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index e0a05f5..845e910 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -214,12 +214,14 @@ private static readonly Parser DoubleQuoteParser private static readonly Parser DateTimeTimeParser = Parse.Regex(@"T\d{2}:\d{2}:\d{2}").Text().Optional().Select(x => x.GetOrElse("")); private static readonly Parser DateTimeMicrosParser = Parse.Regex(@"\.\d{1,7}").Text().Optional().Select(x => x.GetOrElse("")); private static readonly Parser DateTimeZoneParser = Parse.Regex(@"Z|[+-]\d{2}(:\d{2})?").Text().Optional().Select(x => x.GetOrElse("")); + // v1.14.2 read the zone before the fraction, so 2022-07-01T00:00:02Z.5 is a valid value. A zone after the fraction is also valid. private static readonly Parser DateTimeFormatParser = from dateFormat in Parse.Regex(@"\d{4}-\d{2}-\d{2}").Text() from timeFormat in DateTimeTimeParser + from zoneBeforeMicros in DateTimeZoneParser from micros in DateTimeMicrosParser - from timeZone in DateTimeZoneParser - select dateFormat + timeFormat + micros + timeZone; + from zoneAfterMicros in zoneBeforeMicros == "" ? DateTimeZoneParser : Parse.Return("") + select dateFormat + timeFormat + micros + zoneBeforeMicros + zoneAfterMicros; // A number with a '.' decimal point, or with the decimal separator of the current culture. // The longer match wins, so '4.5' parses in every culture and '4,5' still parses in a culture that uses ','. @@ -373,7 +375,7 @@ private static DateTimeOffset ToParameterOffset(DateTimeOffset value) { typeof(sbyte), value => sbyte.Parse(value, CultureInfo.InvariantCulture) }, }; - private static Expression CreateRightExpr(Expression leftExpr, string right, ComparisonOperator op, + private static Expression CreateRightExpr(Expression leftExpr, string right, bool rightIsQuotedLiteral, ComparisonOperator op, IQueryKitConfiguration? config = null, string? propertyPath = null) { var targetType = leftExpr.Type; @@ -444,10 +446,10 @@ private static Expression CreateRightExpr(Expression leftExpr, string right, Com } } - return CreateRightExprFromType(targetType, right, op); + return CreateRightExprFromType(targetType, right, rightIsQuotedLiteral, op); } - private static Expression CreateRightExprFromType(Type leftExprType, string right, ComparisonOperator op) + private static Expression CreateRightExprFromType(Type leftExprType, string right, bool rightIsQuotedLiteral, ComparisonOperator op) { var isEnumerable = IsEnumerable(leftExprType); var targetType = leftExprType; @@ -458,7 +460,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ return FilterValue.Create(intVal, typeof(int)); } targetType = targetType.GetGenericArguments()[0]; - return CreateRightExprFromType(targetType, right, op); + return CreateRightExprFromType(targetType, right, rightIsQuotedLiteral, op); } var rawType = targetType; @@ -537,9 +539,30 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ { var time = TimeOnly.Parse(right, CultureInfo.InvariantCulture); - var fractionalTicks = time.Ticks % TimeSpan.TicksPerSecond; - var millisecond = (int)(fractionalTicks / TimeSpan.TicksPerMillisecond); - var microsecond = (int)(fractionalTicks % TimeSpan.TicksPerMillisecond / 10); + int millisecond = 0, microsecond = 0; + if (rightIsQuotedLiteral) + { + // Like v1.14.2, the milliseconds of a quoted value need at least 3 fraction digits and the microseconds need at least 6. + if (right.Contains('.')) + { + var fractionalSeconds = right.Split('.')[1]; + if (fractionalSeconds.Length >= 3) + { + millisecond = int.Parse(fractionalSeconds.Substring(0, 3)); + } + if (fractionalSeconds.Length >= 6) + { + microsecond = int.Parse(fractionalSeconds.Substring(3, 3)); + } + } + } + else + { + // v1.14.2 did not accept an unquoted fraction, so an unquoted value keeps its full fraction. + var fractionalTicks = time.Ticks % TimeSpan.TicksPerSecond; + millisecond = (int)(fractionalTicks / TimeSpan.TicksPerMillisecond); + microsecond = (int)(fractionalTicks % TimeSpan.TicksPerMillisecond / 10); + } // One microsecond is 10 ticks. The TimeOnly constructor with microseconds needs .NET 7. var value = new TimeOnly(time.Hour, time.Minute, time.Second, millisecond) @@ -769,12 +792,12 @@ private static Parser ComparisonExprParser(ParameterExpression pa if (temp.op.IsStringComparisonOperator()) { var guidStringExpr = HandleGuidConversion(leftExpr, leftExpr.Type); - return temp.op.GetExpression(guidStringExpr, CreateRightExpr(leftExpr, temp.right, temp.op, config, guidPropertyPath), + return temp.op.GetExpression(guidStringExpr, CreateRightExpr(leftExpr, temp.right, temp.rightIsQuotedLiteral, temp.op, config, guidPropertyPath), config?.DbContextType, ResolveCaseMode(guidPropertyPath, config)); } // For non-string operators, use direct GUID comparison - return temp.op.GetExpression(leftExpr, CreateRightExpr(leftExpr, temp.right, temp.op, config, guidPropertyPath), + return temp.op.GetExpression(leftExpr, CreateRightExpr(leftExpr, temp.right, temp.rightIsQuotedLiteral, temp.op, config, guidPropertyPath), config?.DbContextType); } @@ -884,7 +907,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa } } - var rightExpr = CreateRightExpr(leftExprForComparison, temp.right, temp.op, config, propertyPath); + var rightExpr = CreateRightExpr(leftExprForComparison, temp.right, temp.rightIsQuotedLiteral, temp.op, config, propertyPath); // Handle nested collection filtering if (leftExprForComparison is MethodCallExpression methodCall && IsNestedCollectionExpression(methodCall)) @@ -1049,7 +1072,7 @@ private static Parser PropertyListComparisonExprParser( .SelectMany(properties => comparisonOperatorParser, (properties, op) => new { properties, op }) .SelectMany(temp => rightSideValueParser, - (temp, rightValue) => new { temp.properties, temp.op, right = rightValue.Value }) + (temp, rightValue) => new { temp.properties, temp.op, right = rightValue.Value, rightIsQuotedLiteral = rightValue.IsQuotedLiteral }) .Select(temp => { if (!temp.properties.Any()) @@ -1096,7 +1119,7 @@ private static Parser PropertyListComparisonExprParser( leftExpr = HandleGuidConversion(leftExpr, leftExpr.Type); } - var rightExpr = CreateRightExpr(leftExpr, temp.right, temp.op, config, fullPropPath); + var rightExpr = CreateRightExpr(leftExpr, temp.right, temp.rightIsQuotedLiteral, temp.op, config, fullPropPath); var comparison = temp.op.GetExpression(leftExpr, rightExpr, config?.DbContextType, ResolveCaseMode(fullPropPath, config)); // Combine with AND for negative operators, OR for positive operators From 0abd1f89a263a32ac46c6917742fde89f9227647 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:34:23 +0300 Subject: [PATCH 18/24] fix(operators): remove the null check from case-sensitive string operators again Restores aff9638. The case-sensitive @=, _=, _-= operators and their negations give the v1.14.2 expression text again. In memory, a null property throws NullReferenceException, like v1.14.2. On Postgres, the results do not change. A string operator on a collection property throws ArgumentException again, like v1.14.2, and not ParsingException. --- QueryKit.UnitTests/FilterParserTests.cs | 16 +++++----- .../FilterParsingRegressionTests.cs | 18 +++++------ QueryKit.UnitTests/OperatorAliasTests.cs | 2 +- QueryKit/Operators/ComparisonOperator.cs | 30 ++++--------------- 4 files changed, 24 insertions(+), 42 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 9db4d38..3121e05 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -42,7 +42,7 @@ public void complex_with_lots_of_types() var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToDisplayString().Should() - .Be(""""x => (((((((((x.Title != null) AndAlso x.Title.ToLower().Contains("waffle & chicken".ToLower())) AndAlso (x.Age > 30)) OrElse (x.Id == aa648248-cb69-4217-ac95-d7484795afb2)) OrElse (x.Title == "lamb")) OrElse (x.Title == null)) AndAlso ((x.Age < 18) OrElse ((x.BirthMonth == new Nullable`1(January)) AndAlso ((x.Title != null) AndAlso x.Title.StartsWith("ally"))))) OrElse (x.Rating > 3.5)) OrElse ((x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00))) AndAlso ((x.Date == new Nullable`1(new DateOnly(2022, 7, 1))) OrElse (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0))))))""""); + .Be(""""x => (((((((((x.Title != null) AndAlso x.Title.ToLower().Contains("waffle & chicken".ToLower())) AndAlso (x.Age > 30)) OrElse (x.Id == aa648248-cb69-4217-ac95-d7484795afb2)) OrElse (x.Title == "lamb")) OrElse (x.Title == null)) AndAlso ((x.Age < 18) OrElse ((x.BirthMonth == new Nullable`1(January)) AndAlso x.Title.StartsWith("ally")))) OrElse (x.Rating > 3.5)) OrElse ((x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00))) AndAlso ((x.Date == new Nullable`1(new DateOnly(2022, 7, 1))) OrElse (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0))))))""""); } [Fact] @@ -173,7 +173,7 @@ public void starts_with_operator() { var input = """Title _= "lam" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso x.Title.StartsWith(\"lam\"))"); + filterExpression.ToDisplayString().Should().Be("x => x.Title.StartsWith(\"lam\")"); } [Fact] @@ -189,7 +189,7 @@ public void ends_with_operator() { var input = """Title _-= "b" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso x.Title.EndsWith(\"b\"))"); + filterExpression.ToDisplayString().Should().Be("x => x.Title.EndsWith(\"b\")"); } [Fact] @@ -372,7 +372,7 @@ public void multiple_properties_and_operators() { var input = """Title _= "lamb" && Age >= 25 && Rating < 4.5 && SpecificDate <= 2022-07-01T00:00:03Z && Time == 00:00:03"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => ((((((x.Title != null) AndAlso x.Title.StartsWith(\"lamb\")) AndAlso (x.Age >= 25)) AndAlso (x.Rating < 4.5)) AndAlso (x.SpecificDate <= new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00)))) AndAlso (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0))))"); + filterExpression.ToDisplayString().Should().Be("x => ((((x.Title.StartsWith(\"lamb\") AndAlso (x.Age >= 25)) AndAlso (x.Rating < 4.5)) AndAlso (x.SpecificDate <= new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00)))) AndAlso (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0))))"); } [Fact] @@ -380,7 +380,7 @@ public void complex_filter_with_nested_parentheses() { var input = """(Title == "lamb" && ((Age >= 25 && Rating < 4.5) || (SpecificDate <= 2022-07-01T00:00:03Z && Time == 00:00:03)) && (Favorite == true || Email.Value _= "example"))"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("""x => (((x.Title == "lamb") AndAlso (((x.Age >= 25) AndAlso (x.Rating < 4.5)) OrElse ((x.SpecificDate <= new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00))) AndAlso (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0)))))) AndAlso ((x.Favorite == True) OrElse ((x.Email.Value != null) AndAlso x.Email.Value.StartsWith("example"))))"""); + filterExpression.ToDisplayString().Should().Be("""x => (((x.Title == "lamb") AndAlso (((x.Age >= 25) AndAlso (x.Rating < 4.5)) OrElse ((x.SpecificDate <= new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00))) AndAlso (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0)))))) AndAlso ((x.Favorite == True) OrElse x.Email.Value.StartsWith("example")))"""); } [Fact] @@ -412,7 +412,7 @@ public void ends_with_works() { var input = """Title _-= "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso x.Title.EndsWith(\"lamb\"))"); + filterExpression.ToDisplayString().Should().Be("x => x.Title.EndsWith(\"lamb\")"); } [Fact] @@ -428,7 +428,7 @@ public void contains_is_case_sensitive() { var input = """Title @= "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso x.Title.Contains(\"lamb\"))"); + filterExpression.ToDisplayString().Should().Be("x => x.Title.Contains(\"lamb\")"); } [Fact] @@ -436,7 +436,7 @@ public void not_contains_works() { var input = """Title !@= "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => ((x.Title == null) OrElse Not(x.Title.Contains(\"lamb\")))"); + filterExpression.ToDisplayString().Should().Be("x => Not(x.Title.Contains(\"lamb\"))"); } [Fact] diff --git a/QueryKit.UnitTests/FilterParsingRegressionTests.cs b/QueryKit.UnitTests/FilterParsingRegressionTests.cs index 90ee00d..0e0ead4 100644 --- a/QueryKit.UnitTests/FilterParsingRegressionTests.cs +++ b/QueryKit.UnitTests/FilterParsingRegressionTests.cs @@ -204,13 +204,13 @@ public void int_property_compares_to_decimal_property(string input, string expec } [Theory] - [InlineData("""Title @= "am" """, new[] { "lamb" })] - [InlineData("""Title _= "la" """, new[] { "lamb" })] - [InlineData("""Title _-= "mb" """, new[] { "lamb" })] - [InlineData("""Title !@= "am" """, new[] { "null", "other" })] - [InlineData("""Title !_= "la" """, new[] { "null", "other" })] - [InlineData("""Title !_-= "mb" """, new[] { "null", "other" })] - public void case_sensitive_string_operator_handles_null_property(string input, string[] expectedFirstNames) + [InlineData("""Title @= "am" """)] + [InlineData("""Title _= "la" """)] + [InlineData("""Title _-= "mb" """)] + [InlineData("""Title !@= "am" """)] + [InlineData("""Title !_= "la" """)] + [InlineData("""Title !_-= "mb" """)] + public void case_sensitive_string_operator_on_null_property_throws_in_memory(string input) { var people = new[] { @@ -219,9 +219,9 @@ public void case_sensitive_string_operator_handles_null_property(string input, s new TestingPerson { Title = "other", FirstName = "other" }, }; - var result = people.AsQueryable().ApplyQueryKitFilter(input).ToList(); + var act = () => people.AsQueryable().ApplyQueryKitFilter(input).ToList(); - result.Select(x => x.FirstName).Should().Equal(expectedFirstNames); + act.Should().Throw(); } public static IEnumerable ComparisonOperatorFactories() => diff --git a/QueryKit.UnitTests/OperatorAliasTests.cs b/QueryKit.UnitTests/OperatorAliasTests.cs index 68c83ff..234d86f 100644 --- a/QueryKit.UnitTests/OperatorAliasTests.cs +++ b/QueryKit.UnitTests/OperatorAliasTests.cs @@ -153,7 +153,7 @@ public void can_use_contains_not_case_sensitive() config.CaseInsensitiveAppendix = "$"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title != null) AndAlso x.Title.Contains("titilating"))"""); + filterExpression.ToDisplayString().Should().Be($"""x => x.Title.Contains("titilating")"""); } [Fact] diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 0875d29..f3b7178 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -383,10 +383,7 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.AndAlso(nullCheck, containsCall); } - // null doesn't contain anything, so we need: left != null && left.Contains(right) - return Expression.AndAlso( - Expression.NotEqual(left, Expression.Constant(null, typeof(string))), - Expression.Call(left, typeof(string).GetMethod("Contains", new[] { typeof(string) })!, right)); + return Expression.Call(left, typeof(string).GetMethod("Contains", new[] { typeof(string) })!, right); } } @@ -418,10 +415,7 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.AndAlso(nullCheck, startsWithCall); } - // null doesn't start with anything, so we need: left != null && left.StartsWith(right) - return Expression.AndAlso( - Expression.NotEqual(left, Expression.Constant(null, typeof(string))), - Expression.Call(left, typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!, right)); + return Expression.Call(left, typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!, right); } } @@ -453,10 +447,7 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.AndAlso(nullCheck, endsWithCall); } - // null doesn't end with anything, so we need: left != null && left.EndsWith(right) - return Expression.AndAlso( - Expression.NotEqual(left, Expression.Constant(null, typeof(string))), - Expression.Call(left, typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!, right)); + return Expression.Call(left, typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!, right); } } @@ -488,10 +479,7 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.OrElse(nullCheck, notContainsCall); } - // null doesn't contain anything, so it should be included: left == null || !left.Contains(right) - return Expression.OrElse( - Expression.Equal(left, Expression.Constant(null, typeof(string))), - Expression.Not(Expression.Call(left, typeof(string).GetMethod("Contains", new[] { typeof(string) })!, right))); + return Expression.Not(Expression.Call(left, typeof(string).GetMethod("Contains", new[] { typeof(string) })!, right)); } } @@ -523,10 +511,7 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.OrElse(nullCheck, notStartsWithCall); } - // null doesn't start with anything, so it should be included: left == null || !left.StartsWith(right) - return Expression.OrElse( - Expression.Equal(left, Expression.Constant(null, typeof(string))), - Expression.Not(Expression.Call(left, typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!, right))); + return Expression.Not(Expression.Call(left, typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!, right)); } } @@ -558,10 +543,7 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.OrElse(nullCheck, notEndsWithCall); } - // null doesn't end with anything, so it should be included: left == null || !left.EndsWith(right) - return Expression.OrElse( - Expression.Equal(left, Expression.Constant(null, typeof(string))), - Expression.Not(Expression.Call(left, typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!, right))); + return Expression.Not(Expression.Call(left, typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!, right)); } } From 0761e534e128ade4fa62fb33072931562011e87f Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:35:13 +0300 Subject: [PATCH 19/24] fix(operators): ignore usesAll in the operator factories again Restores c948532. The public ComparisonOperator factories accept usesAll but do not give it to the constructor, like v1.14.2. An operator from a factory matches any item of a collection. --- .../Tests/FilterParsingRegressionTests.cs | 4 +- .../FilterParsingRegressionTests.cs | 8 ++-- QueryKit/Operators/ComparisonOperator.cs | 48 +++++++++---------- 3 files changed, 30 insertions(+), 30 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs index 3f9045f..99bf6bd 100644 --- a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs +++ b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs @@ -242,7 +242,7 @@ public async Task case_sensitive_string_operator_handles_null_property(string va } [Fact] - public async Task comparison_operator_factory_with_uses_all_matches_every_item() + public async Task comparison_operator_factory_with_uses_all_matches_any_item() { // Arrange var testingServiceScope = new TestingServiceScope(); @@ -269,7 +269,7 @@ public async Task comparison_operator_factory_with_uses_all_matches_every_item() .ToListAsync(); // Assert - recipes.Select(x => x.Id).Should().Equal(fakeRecipeOne.Id); + recipes.Select(x => x.Id).Should().BeEquivalentTo(new[] { fakeRecipeOne.Id, fakeRecipeTwo.Id }); } [Fact] diff --git a/QueryKit.UnitTests/FilterParsingRegressionTests.cs b/QueryKit.UnitTests/FilterParsingRegressionTests.cs index 0e0ead4..f45595e 100644 --- a/QueryKit.UnitTests/FilterParsingRegressionTests.cs +++ b/QueryKit.UnitTests/FilterParsingRegressionTests.cs @@ -232,13 +232,13 @@ public static IEnumerable ComparisonOperatorFactories() => [Theory] [MemberData(nameof(ComparisonOperatorFactories))] - public void comparison_operator_factory_keeps_uses_all(string factoryName) + public void comparison_operator_factory_ignores_uses_all(string factoryName) { var factory = typeof(ComparisonOperator).GetMethod(factoryName, BindingFlags.Public | BindingFlags.Static)!; var comparisonOperator = (ComparisonOperator)factory.Invoke(null, new object[] { true, true })!; - comparisonOperator.UsesAll.Should().BeTrue(); + comparisonOperator.UsesAll.Should().BeFalse(); comparisonOperator.CaseInsensitive.Should().BeTrue(); } @@ -249,7 +249,7 @@ public void comparison_operator_factory_has_one_test_case_per_operator_type() } [Fact] - public void comparison_operator_factory_with_uses_all_builds_all_expression() + public void comparison_operator_factory_with_uses_all_builds_any_expression() { Expression>> ingredientNames = x => x.Ingredients.Select(y => y.Name); @@ -258,7 +258,7 @@ public void comparison_operator_factory_with_uses_all_builds_all_expression() var filterExpression = Expression.Lambda>(body, ingredientNames.Parameters); filterExpression.ToDisplayString().Should() - .Be(FilterParser.ParseFilter("""Ingredients.Name %== "waffle" """).ToDisplayString()); + .Be(FilterParser.ParseFilter("""Ingredients.Name == "waffle" """).ToDisplayString()); } [Theory] diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index f3b7178..0d51811 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -34,30 +34,30 @@ public abstract class ComparisonOperator : SmartEnum public static ComparisonOperator CaseSensitiveHasOperator = new HasType(); public static ComparisonOperator CaseSensitiveDoesNotHaveOperator = new DoesNotHaveType(); - public static ComparisonOperator EqualsOperator(bool caseInsensitive = false, bool usesAll = false) => new EqualsType(caseInsensitive, usesAll); - public static ComparisonOperator NotEqualsOperator(bool caseInsensitive = false, bool usesAll = false) => new NotEqualsType(caseInsensitive, usesAll); - public static ComparisonOperator GreaterThanOperator(bool caseInsensitive = false, bool usesAll = false) => new GreaterThanType(caseInsensitive, usesAll); - public static ComparisonOperator LessThanOperator(bool caseInsensitive = false, bool usesAll = false) => new LessThanType(caseInsensitive, usesAll); - public static ComparisonOperator GreaterThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new GreaterThanOrEqualType(caseInsensitive, usesAll); - public static ComparisonOperator LessThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new LessThanOrEqualType(caseInsensitive, usesAll); - public static ComparisonOperator ContainsOperator(bool caseInsensitive = false, bool usesAll = false) => new ContainsType(caseInsensitive, usesAll); - public static ComparisonOperator StartsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new StartsWithType(caseInsensitive, usesAll); - public static ComparisonOperator EndsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new EndsWithType(caseInsensitive, usesAll); - public static ComparisonOperator NotContainsOperator(bool caseInsensitive = false, bool usesAll = false) => new NotContainsType(caseInsensitive, usesAll); - public static ComparisonOperator NotStartsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new NotStartsWithType(caseInsensitive, usesAll); - public static ComparisonOperator NotEndsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new NotEndsWithType(caseInsensitive, usesAll); - public static ComparisonOperator InOperator(bool caseInsensitive = false, bool usesAll = false) => new InType(caseInsensitive, usesAll); - public static ComparisonOperator NotInOperator(bool caseInsensitive = false, bool usesAll = false) => new NotInType(caseInsensitive, usesAll); - public static ComparisonOperator SoundsLikeOperator(bool caseInsensitive = false, bool usesAll = false) => new SoundsLikeType(caseInsensitive, usesAll); - public static ComparisonOperator DoesNotSoundLikeOperator(bool caseInsensitive = false, bool usesAll = false) => new DoesNotSoundLikeType(caseInsensitive, usesAll); - public static ComparisonOperator HasCountEqualToOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountEqualToType(caseInsensitive, usesAll); - public static ComparisonOperator HasCountNotEqualToOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountNotEqualToType(caseInsensitive, usesAll); - public static ComparisonOperator HasCountGreaterThanOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountGreaterThanType(caseInsensitive, usesAll); - public static ComparisonOperator HasCountLessThanOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountLessThanType(caseInsensitive, usesAll); - public static ComparisonOperator HasCountGreaterThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountGreaterThanOrEqualType(caseInsensitive, usesAll); - public static ComparisonOperator HasCountLessThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountLessThanOrEqualType(caseInsensitive, usesAll); - public static ComparisonOperator HasOperator(bool caseInsensitive = false, bool usesAll = false) => new HasType(caseInsensitive, usesAll); - public static ComparisonOperator DoesNotHaveOperator(bool caseInsensitive = false, bool usesAll = false) => new DoesNotHaveType(caseInsensitive, usesAll); + public static ComparisonOperator EqualsOperator(bool caseInsensitive = false, bool usesAll = false) => new EqualsType(caseInsensitive); + public static ComparisonOperator NotEqualsOperator(bool caseInsensitive = false, bool usesAll = false) => new NotEqualsType(caseInsensitive); + public static ComparisonOperator GreaterThanOperator(bool caseInsensitive = false, bool usesAll = false) => new GreaterThanType(caseInsensitive); + public static ComparisonOperator LessThanOperator(bool caseInsensitive = false, bool usesAll = false) => new LessThanType(caseInsensitive); + public static ComparisonOperator GreaterThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new GreaterThanOrEqualType(caseInsensitive); + public static ComparisonOperator LessThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new LessThanOrEqualType(caseInsensitive); + public static ComparisonOperator ContainsOperator(bool caseInsensitive = false, bool usesAll = false) => new ContainsType(caseInsensitive); + public static ComparisonOperator StartsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new StartsWithType(caseInsensitive); + public static ComparisonOperator EndsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new EndsWithType(caseInsensitive); + public static ComparisonOperator NotContainsOperator(bool caseInsensitive = false, bool usesAll = false) => new NotContainsType(caseInsensitive); + public static ComparisonOperator NotStartsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new NotStartsWithType(caseInsensitive); + public static ComparisonOperator NotEndsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new NotEndsWithType(caseInsensitive); + public static ComparisonOperator InOperator(bool caseInsensitive = false, bool usesAll = false) => new InType(caseInsensitive); + public static ComparisonOperator NotInOperator(bool caseInsensitive = false, bool usesAll = false) => new NotInType(caseInsensitive); + public static ComparisonOperator SoundsLikeOperator(bool caseInsensitive = false, bool usesAll = false) => new SoundsLikeType(caseInsensitive); + public static ComparisonOperator DoesNotSoundLikeOperator(bool caseInsensitive = false, bool usesAll = false) => new DoesNotSoundLikeType(caseInsensitive); + public static ComparisonOperator HasCountEqualToOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountEqualToType(caseInsensitive); + public static ComparisonOperator HasCountNotEqualToOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountNotEqualToType(caseInsensitive); + public static ComparisonOperator HasCountGreaterThanOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountGreaterThanType(caseInsensitive); + public static ComparisonOperator HasCountLessThanOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountLessThanType(caseInsensitive); + public static ComparisonOperator HasCountGreaterThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountGreaterThanOrEqualType(caseInsensitive); + public static ComparisonOperator HasCountLessThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountLessThanOrEqualType(caseInsensitive); + public static ComparisonOperator HasOperator(bool caseInsensitive = false, bool usesAll = false) => new HasType(caseInsensitive); + public static ComparisonOperator DoesNotHaveOperator(bool caseInsensitive = false, bool usesAll = false) => new DoesNotHaveType(caseInsensitive); public static ComparisonOperator GetByOperatorString(string op, bool caseInsensitive = false, bool usesAll = false) { From e6d55b325689629d2a3c4bc12d4c4ff1e18ba406 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:36:09 +0300 Subject: [PATCH 20/24] fix(operators): read a constant list in case-insensitive in and not-in again Restores the v1.14.2 list read that 5db8c4b (PR 110) removed. InOperator(true) and NotInOperator(true) threw NullReferenceException when a caller passed the list as a ConstantExpression. They read the constant list again, like v1.14.2. --- .../FilterParsingRegressionTests.cs | 20 +++++++++++++++++++ QueryKit/Operators/ComparisonOperator.cs | 6 ++++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/QueryKit.UnitTests/FilterParsingRegressionTests.cs b/QueryKit.UnitTests/FilterParsingRegressionTests.cs index f45595e..4bb5df8 100644 --- a/QueryKit.UnitTests/FilterParsingRegressionTests.cs +++ b/QueryKit.UnitTests/FilterParsingRegressionTests.cs @@ -248,6 +248,26 @@ public void comparison_operator_factory_has_one_test_case_per_operator_type() ComparisonOperatorFactories().Should().HaveCount(24); } + [Theory] + [InlineData(false, new[] { "lamb" })] + [InlineData(true, new[] { "null", "other" })] + public void case_insensitive_in_operator_factory_reads_a_constant_list(bool notIn, string[] expectedFirstNames) + { + var people = new[] + { + new TestingPerson { Title = null, FirstName = "null" }, + new TestingPerson { Title = "Lamb", FirstName = "lamb" }, + new TestingPerson { Title = "other", FirstName = "other" }, + }; + Expression> title = x => x.Title; + var comparisonOperator = notIn ? ComparisonOperator.NotInOperator(true) : ComparisonOperator.InOperator(true); + + var body = comparisonOperator.GetExpression(title.Body, Expression.Constant(new List { "LAMB" }), null); + var filterExpression = Expression.Lambda>(body, title.Parameters); + + people.AsQueryable().Where(filterExpression).Select(x => x.FirstName).Should().Equal(expectedFirstNames); + } + [Fact] public void comparison_operator_factory_with_uses_all_builds_any_expression() { diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 0d51811..789ef3f 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -588,7 +588,8 @@ public override Expression GetExpression(Expression left, Expression right, T var listType = typeof(List); var caseList = Activator.CreateInstance(listType); - var originalList = values as IEnumerable; + // A caller can pass the list as a constant, like v1.14.2. + var originalList = (values ?? ((ConstantExpression)right).Value) as IEnumerable; foreach (var value in originalList!) { listType.GetMethod("Add")!.Invoke(caseList, new[] { caseMode == CaseInsensitiveMode.Upper ? value.ToUpper() : value.ToLower() }); @@ -833,7 +834,8 @@ public override Expression GetExpression(Expression left, Expression right, T var listType = typeof(List); var caseList = Activator.CreateInstance(listType); - var originalList = values as IEnumerable; + // A caller can pass the list as a constant, like v1.14.2. + var originalList = (values ?? ((ConstantExpression)right).Value) as IEnumerable; foreach (var value in originalList!) { listType.GetMethod("Add")!.Invoke(caseList, new[] { caseMode == CaseInsensitiveMode.Upper ? value.ToUpper() : value.ToLower() }); From 19125d10d7b532465042481a2f426082b38da635 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:36:38 +0300 Subject: [PATCH 21/24] fix(operators): remove the obsolete mark from ArithmeticOperator.FromSymbol Completes the C restore. d507c87 added FromSymbol back with an Obsolete attribute. A v1.14.2 consumer that builds with warnings as errors got CS0618. FromSymbol has no attribute again, like v1.14.2. --- QueryKit.UnitTests/ArithmeticOperatorTests.cs | 4 ---- QueryKit/Operators/ArithmeticOperator.cs | 1 - 2 files changed, 5 deletions(-) diff --git a/QueryKit.UnitTests/ArithmeticOperatorTests.cs b/QueryKit.UnitTests/ArithmeticOperatorTests.cs index e53be63..e5444c2 100644 --- a/QueryKit.UnitTests/ArithmeticOperatorTests.cs +++ b/QueryKit.UnitTests/ArithmeticOperatorTests.cs @@ -13,9 +13,7 @@ public class ArithmeticOperatorTests [InlineData("%", "%")] public void from_symbol_returns_the_operator_for_the_symbol(string symbol, string expectedSymbol) { -#pragma warning disable CS0618 // FromSymbol stays for v1.14.2 compatibility var op = ArithmeticOperator.FromSymbol(symbol); -#pragma warning restore CS0618 op.Should().NotBeNull(); op!.Symbol.Should().Be(expectedSymbol); @@ -24,9 +22,7 @@ public void from_symbol_returns_the_operator_for_the_symbol(string symbol, strin [Fact] public void from_symbol_returns_null_for_an_unknown_symbol() { -#pragma warning disable CS0618 // FromSymbol stays for v1.14.2 compatibility var op = ArithmeticOperator.FromSymbol("^"); -#pragma warning restore CS0618 op.Should().BeNull(); } diff --git a/QueryKit/Operators/ArithmeticOperator.cs b/QueryKit/Operators/ArithmeticOperator.cs index 0fa6452..70be3fb 100644 --- a/QueryKit/Operators/ArithmeticOperator.cs +++ b/QueryKit/Operators/ArithmeticOperator.cs @@ -24,7 +24,6 @@ protected ArithmeticOperator(string symbol, int precedence) public static ArithmeticOperator Divide => new DivideOperator(); public static ArithmeticOperator Modulo => new ModuloOperator(); - [Obsolete("QueryKit does not use FromSymbol. The next major version removes it.")] public static ArithmeticOperator? FromSymbol(string symbol) => symbol switch { "+" => Add, From 22798f00d8a4eccb81a784aa6dc39cf5b240d330 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:36:51 +0300 Subject: [PATCH 22/24] fix(operators): restore the v1.14.2 message for has on a non-collection Restores the message that de350f7 (PR 109) changed. The has operator on a property that is not a collection throws 'DoesNotHaveType is only supported for collections' again, like v1.14.2. The exception type does not change. --- QueryKit.UnitTests/FilterParserTests.cs | 4 ++-- QueryKit/Operators/ComparisonOperator.cs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 3121e05..200e8e0 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -817,12 +817,12 @@ public void primitive_collection_does_not_have_case_insensitive() } [Fact] - public void has_type_throws_correct_message_on_non_collection_property() + public void has_type_on_non_collection_property_throws_the_v1_14_2_message() { var input = """Title ^$ "winner" """; var act = () => FilterParser.ParseFilter(input); act.Should().Throw() - .WithMessage("HasType is only supported for collections"); + .WithMessage("DoesNotHaveType is only supported for collections"); } [Fact] diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 789ef3f..18834f8 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -766,7 +766,7 @@ public override Expression GetExpression(Expression left, Expression right, T return GetCollectionExpression(left, right, Expression.Equal, UsesAll); } - throw new QueryKitParsingException("HasType is only supported for collections"); + throw new QueryKitParsingException("DoesNotHaveType is only supported for collections"); } } From bc70b3a28f9bc2241622fe4ba98e3875309dc073 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:41:30 +0300 Subject: [PATCH 23/24] fix(filter): resolve a derived property or custom operation query name with a hyphen or a space again The grammar reads the identifier path first, like v1.14.2. If the path is not a property and unknown properties are not allowed, the grammar tries the query names of derived properties and custom operations, longest first. v1.14.2 threw for these inputs, so no filter that v1.14.2 accepted gives a different result. --- QueryKit.UnitTests/PropertyResolverTests.cs | 34 ++++++++++- QueryKit/FilterParser.cs | 67 ++++++++++++++++++--- 2 files changed, 91 insertions(+), 10 deletions(-) diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 4dbb50b..33c94b7 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -542,7 +542,7 @@ public void query_name_with_a_hyphen_in_a_property_list_throws() } [Fact] - public void derived_property_query_name_with_a_hyphen_throws() + public void derived_property_query_name_with_a_hyphen_resolves_to_its_expression() { var input = """full-name == "Ann Lee" """; var config = new QueryKitConfiguration(config => @@ -550,9 +550,37 @@ public void derived_property_query_name_with_a_hyphen_throws() config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full-name"); }); - var act = () => FilterParser.ParseFilter(input, config); + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (((x.FirstName + " ") + x.LastName) == "Ann Lee")"""); + } + + [Fact] + public void custom_operation_query_name_with_a_space_resolves_to_its_operation() + { + var input = """is adult == true"""; + var config = new QueryKitConfiguration(config => + { + config.CustomOperation((x, op, value) => x.Age > 17).HasQueryName("is adult"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); - act.Should().ThrowExactly().WithMessage("*'full'*"); + filterExpression.ToDisplayString().Should().Be("x => Invoke((entity, op, value) => (Convert(entity, TestingPerson).Age > Convert(17, Nullable`1)), Convert(x, Object), ==, True)"); + } + + [Fact] + public void derived_property_query_name_does_not_match_the_start_of_a_longer_name() + { + var input = """FirstName == "Ann" """; + var config = new QueryKitConfiguration(config => + { + config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("first"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "Ann")"""); } [Fact] diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 845e910..b77608b 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -925,19 +925,72 @@ private static Parser ComparisonExprParser(ParameterExpression pa private static Parser CreateLeftExprParser(Type entityType, IQueryKitConfiguration? config) { var leftPropertyParser = IdentifierPathParser.Token(); - - return leftPropertyParser.Select(left => + var queryNameParser = DerivedOrCustomOperationQueryNameParser(config).Token(); + return input => { - var reference = PropertyResolver.Resolve(entityType, left, config); - if (reference.Kind == PropertyReferenceKind.Unknown && config?.AllowUnknownProperties != true) + var left = leftPropertyParser(input); + var reference = left.WasSuccessful ? PropertyResolver.Resolve(entityType, left.Value, config) : null; + if (reference != null && (reference.Kind != PropertyReferenceKind.Unknown || config?.AllowUnknownProperties == true)) { - throw new UnknownFilterPropertyException(reference.UnknownSegment!); + return Result.Success(reference, left.Remainder); } - return reference; - }); + // v1.14.2 did not accept the text here, so a derived property or custom operation query name can not change an accepted filter. + var queryName = queryNameParser(input); + if (queryName.WasSuccessful) + { + return Result.Success(PropertyResolver.Resolve(entityType, queryName.Value, config), queryName.Remainder); + } + + if (reference == null) + { + return Result.Failure(left.Remainder, left.Message, left.Expectations); + } + + throw new UnknownFilterPropertyException(reference.UnknownSegment!); + }; + } + + // The rewrite before the parse does not replace the query name of a derived property or a custom operation, + // so the grammar reads it when the identifier path is not a property. This lets the query name hold any text (for example `full-name` or `full name`). + // Longer query names are tried first, so a query name that starts with another query name still matches. + private static Parser DerivedOrCustomOperationQueryNameParser(IQueryKitConfiguration? config) + { + Parser parser = i => Result.Failure(i, "no query name", Array.Empty()); + var mappings = config?.PropertyMappings; + if (mappings == null) + { + return parser; + } + + var queryNames = mappings.DerivedPropertyMappings.Values.Concat(mappings.CustomOperationMappings.Values) + .Select(info => info.QueryName) + .Where(queryName => !string.IsNullOrEmpty(queryName)) + .Select(queryName => queryName!) + .Distinct(StringComparer.InvariantCultureIgnoreCase) + .OrderByDescending(queryName => queryName.Length); + foreach (var queryName in queryNames) + { + parser = parser.Or(WholeQueryName(queryName)); + } + + return parser; } + // A query name is a whole name: the next character can not continue a property path. + private static Parser WholeQueryName(string queryName) => input => + { + var result = Parse.IgnoreCase(queryName).Text()(input); + if (!result.WasSuccessful || result.Remainder.AtEnd || !IsPropertyPathChar(result.Remainder.Current)) + { + return result; + } + + return Result.Failure(input, $"Query name '{queryName}' must not be followed by '{result.Remainder.Current}'", new[] { queryName }); + }; + + private static bool IsPropertyPathChar(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '.'; + private static Expression CreateLeftExpr(ParameterExpression parameter, PropertyReference reference, IQueryKitConfiguration? config) { var propertyExpression = reference.Kind == PropertyReferenceKind.DerivedProperty From 01b06c1bcb3782ba1facc1d2149b63cd45f300e6 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 00:42:12 +0300 Subject: [PATCH 24/24] test: mark nullable property selectors in test configs as not null The Property selector returns object, so a selector for a nullable property gave warning CS8603. The 40 selectors now use the null-forgiving operator, and the build has no warnings. --- .../Tests/DatabaseFilteringTests.cs | 2 +- .../Tests/PropertyResolverTests.cs | 4 +- .../EnumerableFilteringTests.cs | 2 +- .../FilterParserAllocationTests.cs | 8 +-- QueryKit.UnitTests/PropertyResolverTests.cs | 64 +++++++++---------- 5 files changed, 40 insertions(+), 40 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs index 8d3fd32..8a7af25 100644 --- a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs +++ b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs @@ -4301,7 +4301,7 @@ public async Task can_apply_query_kit_data() SortOrder = "score desc", Configuration = new QueryKitConfiguration(config => { - config.Property(x => x.Rating).HasQueryName("score"); + config.Property(x => x.Rating!).HasQueryName("score"); }) }; diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 8513412..3c7f298 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -81,7 +81,7 @@ public async Task prevented_property_clause_under_or_does_not_return_every_row() var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Rating).PreventFilter(); + config.Property(x => x.Rating!).PreventFilter(); }); // Act @@ -110,7 +110,7 @@ public async Task query_name_that_is_not_a_plain_identifier_filters_by_its_prope var input = $"""{queryName} == "{firstName}" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName(queryName); + config.Property(x => x.FirstName!).HasQueryName(queryName); }); // Act diff --git a/QueryKit.UnitTests/EnumerableFilteringTests.cs b/QueryKit.UnitTests/EnumerableFilteringTests.cs index 88a476d..b65729f 100644 --- a/QueryKit.UnitTests/EnumerableFilteringTests.cs +++ b/QueryKit.UnitTests/EnumerableFilteringTests.cs @@ -248,7 +248,7 @@ public void can_apply_query_kit_data_to_enumerable() SortOrder = "score desc", Configuration = new QueryKitConfiguration(config => { - config.Property(x => x.Rating).HasQueryName("score"); + config.Property(x => x.Rating!).HasQueryName("score"); }) }; diff --git a/QueryKit.UnitTests/FilterParserAllocationTests.cs b/QueryKit.UnitTests/FilterParserAllocationTests.cs index ad8fa3c..89e7c47 100644 --- a/QueryKit.UnitTests/FilterParserAllocationTests.cs +++ b/QueryKit.UnitTests/FilterParserAllocationTests.cs @@ -22,10 +22,10 @@ public void property_aliases_do_not_build_a_regex_on_each_parse() { var config = new QueryKitConfiguration(c => { - c.Property(x => x.Title).HasQueryName("name"); - c.Property(x => x.Age).HasQueryName("years"); - c.Property(x => x.Rating).HasQueryName("score"); - c.Property(x => x.FirstName).HasQueryName("first"); + c.Property(x => x.Title!).HasQueryName("name"); + c.Property(x => x.Age!).HasQueryName("years"); + c.Property(x => x.Rating!).HasQueryName("score"); + c.Property(x => x.FirstName!).HasQueryName("first"); c.Property(x => x.Id).HasQueryName("key"); }); diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 33c94b7..9ffb715 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -29,7 +29,7 @@ public void prevented_property_clause_is_true_equals_true_by_default() var input = """FirstName == "Ann" || Title == "s" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).PreventFilter(); + config.Property(x => x.Title!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -43,8 +43,8 @@ public void property_list_with_only_prevented_properties_is_true_by_default() var input = """(Title, FirstName) == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).PreventFilter(); - config.Property(x => x.FirstName).PreventFilter(); + config.Property(x => x.Title!).PreventFilter(); + config.Property(x => x.FirstName!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -120,7 +120,7 @@ public void prevented_property_clause_is_removed_under_or() var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Rating).PreventFilter(); + config.Property(x => x.Rating!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -150,8 +150,8 @@ public void property_list_with_only_prevented_properties_is_removed() var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Title).PreventFilter(); - config.Property(x => x.FirstName).PreventFilter(); + config.Property(x => x.Title!).PreventFilter(); + config.Property(x => x.FirstName!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -166,7 +166,7 @@ public void prevented_property_in_arithmetic_is_still_filtered() var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Age).PreventFilter(); + config.Property(x => x.Age!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -181,7 +181,7 @@ public void prevented_property_on_the_right_side_of_arithmetic_is_still_filtered var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Rating).PreventFilter(); + config.Property(x => x.Rating!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -210,7 +210,7 @@ public void prevented_property_on_the_right_side_is_still_compared() var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Title).PreventFilter(); + config.Property(x => x.Title!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -224,7 +224,7 @@ public void prevented_property_in_a_list_in_another_case_is_still_filtered() var input = """(title, FirstName) == "x" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).PreventFilter(); + config.Property(x => x.Title!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -239,7 +239,7 @@ public void prevented_property_removes_the_clause_in_any_case() var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Title).PreventFilter(); + config.Property(x => x.Title!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -254,7 +254,7 @@ public void prevented_property_with_a_query_name_is_still_filtered_by_its_member var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Title).HasQueryName("t").PreventFilter(); + config.Property(x => x.Title!).HasQueryName("t").PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -268,7 +268,7 @@ public void property_in_a_list_uses_its_case_insensitive_mode_in_any_case() var input = """(title) @=* "x" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).HasCaseInsensitiveMode(CaseInsensitiveMode.Upper); + config.Property(x => x.Title!).HasCaseInsensitiveMode(CaseInsensitiveMode.Upper); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -282,7 +282,7 @@ public void prevented_sort_property_is_skipped_in_any_case() var input = "title, Age desc"; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).PreventSort(); + config.Property(x => x.Title!).PreventSort(); }); var sortExpressions = SortParser.ParseSort(input, config); @@ -297,7 +297,7 @@ public void prevented_sort_property_with_a_query_name_still_sorts_by_its_member_ var input = "title desc"; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).HasQueryName("t").PreventSort(); + config.Property(x => x.Title!).HasQueryName("t").PreventSort(); }); var sortExpressions = SortParser.ParseSort(input, config); @@ -370,7 +370,7 @@ public void query_name_in_a_property_list_throws() var input = """(name, FirstName) == "x" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).HasQueryName("name"); + config.Property(x => x.Title!).HasQueryName("name"); }); var act = () => FilterParser.ParseFilter(input, config); @@ -384,7 +384,7 @@ public void query_name_of_a_prevented_property_in_a_property_list_throws() var input = """(hidden, Title) == "x" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName("hidden").PreventFilter().PreventSort(); + config.Property(x => x.FirstName!).HasQueryName("hidden").PreventFilter().PreventSort(); }); var act = () => FilterParser.ParseFilter(input, config); @@ -398,7 +398,7 @@ public void query_name_in_arithmetic_throws() var input = """(stars + 0) > 3"""; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Rating).HasQueryName("stars"); + config.Property(x => x.Rating!).HasQueryName("stars"); }); var act = () => FilterParser.ParseFilter(input, config); @@ -417,7 +417,7 @@ public void query_name_that_is_not_a_plain_identifier_resolves_to_its_property(s var input = $"""{queryName} == "Ann" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName(queryName); + config.Property(x => x.FirstName!).HasQueryName(queryName); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -431,7 +431,7 @@ public void query_name_with_a_hyphen_resolves_in_every_case() var input = """FIRST-NAME == "Ann" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName("first-name"); + config.Property(x => x.FirstName!).HasQueryName("first-name"); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -445,7 +445,7 @@ public void query_name_with_a_hyphen_in_a_value_is_replaced() var input = """Title == "first-name == x" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName("first-name"); + config.Property(x => x.FirstName!).HasQueryName("first-name"); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -460,7 +460,7 @@ public void query_name_with_a_hyphen_before_an_operator_alias_filters_by_its_pro var config = new QueryKitConfiguration(config => { config.EqualsOperator = "eq"; - config.Property(x => x.FirstName).HasQueryName("first-name"); + config.Property(x => x.FirstName!).HasQueryName("first-name"); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -474,7 +474,7 @@ public void query_name_on_the_right_side_is_a_value() var input = """Title == first"""; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName("first"); + config.Property(x => x.FirstName!).HasQueryName("first"); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -488,7 +488,7 @@ public void query_name_with_a_hyphen_sorts_by_its_property() var input = "first-name desc"; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName("first-name"); + config.Property(x => x.FirstName!).HasQueryName("first-name"); }); var sortExpressions = SortParser.ParseSort(input, config); @@ -504,8 +504,8 @@ public void longer_query_name_wins_over_a_query_name_it_starts_with() var input = """first name == "Ann" && first == "Lee" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName("first name"); - config.Property(x => x.LastName).HasQueryName("first"); + config.Property(x => x.FirstName!).HasQueryName("first name"); + config.Property(x => x.LastName!).HasQueryName("first"); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -519,7 +519,7 @@ public void query_name_does_not_match_the_start_of_a_longer_property_name() var input = """FirstName == "Ann" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).HasQueryName("first"); + config.Property(x => x.Title!).HasQueryName("first"); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -533,7 +533,7 @@ public void query_name_with_a_hyphen_in_a_property_list_throws() var input = """(first-name, Title) == "x" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName("first-name"); + config.Property(x => x.FirstName!).HasQueryName("first-name"); }); var act = () => FilterParser.ParseFilter(input, config); @@ -589,7 +589,7 @@ public void query_name_in_a_value_is_replaced() var input = """FirstName == "name == x" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).HasQueryName("name"); + config.Property(x => x.Title!).HasQueryName("name"); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -604,7 +604,7 @@ public void property_prevented_for_filter_and_sort_throws_by_its_query_name() var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); + config.Property(x => x.Title!).HasQueryName("name").PreventFilter().PreventSort(); }); var act = () => FilterParser.ParseFilter(input, config); @@ -620,7 +620,7 @@ public void property_prevented_for_filter_and_sort_throws_by_its_query_name_befo var config = new QueryKitConfiguration(config => { config.EqualsOperator = "eq"; - config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); + config.Property(x => x.Title!).HasQueryName("name").PreventFilter().PreventSort(); }); var act = () => FilterParser.ParseFilter(input, config); @@ -636,7 +636,7 @@ public void property_prevented_for_filter_and_sort_is_removed_by_its_member_name var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); + config.Property(x => x.Title!).HasQueryName("name").PreventFilter().PreventSort(); }); var filterExpression = FilterParser.ParseFilter(input, config);