From c6ce19dde468f5609d034ef2d7a81bb43b1188f9 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:04:36 +0300 Subject: [PATCH 01/11] fix(parser): reject filters over a configurable nesting depth and length limit Deeply nested parentheses in a filter string can exhaust CPU and memory during parsing, or overflow the call stack, before any grammar rule runs. A crafted filter of a few thousand nested groups can hang a process indefinitely. Check the input length and parenthesis nesting depth before the parser sees the string, and reject input over a limit. The limits are configurable through QueryKitSettings, with safe defaults enabled out of the box (depth 32, length 5000). --- QueryKit.UnitTests/ParseLimitsTests.cs | 104 ++++++++++++++++++ .../Configuration/QueryKitConfiguration.cs | 6 + QueryKit/Configuration/QueryKitSettings.cs | 5 + .../QueryKitInputLengthExceededException.cs | 9 ++ .../QueryKitNestingDepthExceededException.cs | 9 ++ QueryKit/FilterParser.cs | 35 ++++++ 6 files changed, 168 insertions(+) create mode 100644 QueryKit.UnitTests/ParseLimitsTests.cs create mode 100644 QueryKit/Exceptions/QueryKitInputLengthExceededException.cs create mode 100644 QueryKit/Exceptions/QueryKitNestingDepthExceededException.cs diff --git a/QueryKit.UnitTests/ParseLimitsTests.cs b/QueryKit.UnitTests/ParseLimitsTests.cs new file mode 100644 index 0000000..01d06b8 --- /dev/null +++ b/QueryKit.UnitTests/ParseLimitsTests.cs @@ -0,0 +1,104 @@ +namespace QueryKit.UnitTests; + +using QueryKit.Configuration; +using QueryKit.Exceptions; +using FluentAssertions; +using WebApiTestProject.Entities; + +public class ParseLimitsTests +{ + [Fact] + public void filter_within_default_nesting_depth_parses() + { + var input = new string('(', 5) + """Title == "salt" """ + new string(')', 5); + + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.Should().NotBeNull(); + } + + [Fact] + public void filter_over_default_nesting_depth_throws() + { + var input = new string('(', QueryKitSettings.DefaultMaxNestingDepth + 1) + + """Title == "salt" """ + + new string(')', QueryKitSettings.DefaultMaxNestingDepth + 1); + + var act = () => FilterParser.ParseFilter(input); + act.Should().Throw() + .WithMessage($"*depth of {QueryKitSettings.DefaultMaxNestingDepth + 1}*maximum allowed depth of {QueryKitSettings.DefaultMaxNestingDepth}*"); + } + + [Fact] + public void filter_over_configured_nesting_depth_throws() + { + var input = new string('(', 3) + """Title == "salt" """ + new string(')', 3); + var config = new QueryKitConfiguration(settings => + { + settings.MaxNestingDepth = 2; + }); + + var act = () => FilterParser.ParseFilter(input, config); + act.Should().Throw() + .WithMessage("*depth of 3*maximum allowed depth of 2*"); + } + + [Fact] + public void filter_within_configured_nesting_depth_parses() + { + var input = new string('(', 3) + """Title == "salt" """ + new string(')', 3); + var config = new QueryKitConfiguration(settings => + { + settings.MaxNestingDepth = 3; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + filterExpression.Should().NotBeNull(); + } + + [Fact] + public void filter_within_default_input_length_parses() + { + var input = """Title == "salt" """; + + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.Should().NotBeNull(); + } + + [Fact] + public void filter_over_default_input_length_throws() + { + var padding = new string('a', QueryKitSettings.DefaultMaxInputLength); + var input = $"""Title == "{padding}" """; + + var act = () => FilterParser.ParseFilter(input); + act.Should().Throw() + .WithMessage($"*length of {input.Length}*maximum allowed length of {QueryKitSettings.DefaultMaxInputLength}*"); + } + + [Fact] + public void filter_over_configured_input_length_throws() + { + var input = """Title == "salt and pepper" """; + var config = new QueryKitConfiguration(settings => + { + settings.MaxInputLength = 10; + }); + + var act = () => FilterParser.ParseFilter(input, config); + act.Should().Throw() + .WithMessage($"*length of {input.Length}*maximum allowed length of 10*"); + } + + [Fact] + public void filter_within_configured_input_length_parses() + { + var input = """Title == "salt" """; + var config = new QueryKitConfiguration(settings => + { + settings.MaxInputLength = input.Length; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + filterExpression.Should().NotBeNull(); + } +} diff --git a/QueryKit/Configuration/QueryKitConfiguration.cs b/QueryKit/Configuration/QueryKitConfiguration.cs index 1b867c3..219a8b9 100644 --- a/QueryKit/Configuration/QueryKitConfiguration.cs +++ b/QueryKit/Configuration/QueryKitConfiguration.cs @@ -33,6 +33,8 @@ public interface IQueryKitConfiguration public string HasOperator { get; set; } public string DoesNotHaveOperator { get; set; } public int? MaxPropertyDepth { get; set; } + public int MaxNestingDepth { get; set; } + public int MaxInputLength { get; set; } public CaseInsensitiveMode CaseInsensitiveComparison { get; set; } } @@ -69,6 +71,8 @@ public class QueryKitConfiguration : IQueryKitConfiguration public bool AllowUnknownProperties { get; set; } = false; public Type? DbContextType { get; set; } public int? MaxPropertyDepth { get; set; } + public int MaxNestingDepth { get; set; } + public int MaxInputLength { get; set; } public CaseInsensitiveMode CaseInsensitiveComparison { get; set; } public QueryKitConfiguration(Action configureSettings) @@ -108,6 +112,8 @@ public QueryKitConfiguration(Action configureSettings) HasOperator = settings.HasOperator; DoesNotHaveOperator = settings.DoesNotHaveOperator; MaxPropertyDepth = settings.MaxPropertyDepth; + MaxNestingDepth = settings.MaxNestingDepth; + MaxInputLength = settings.MaxInputLength; CaseInsensitiveComparison = settings.CaseInsensitiveComparison; } } \ No newline at end of file diff --git a/QueryKit/Configuration/QueryKitSettings.cs b/QueryKit/Configuration/QueryKitSettings.cs index 07a1d11..3d26890 100644 --- a/QueryKit/Configuration/QueryKitSettings.cs +++ b/QueryKit/Configuration/QueryKitSettings.cs @@ -5,6 +5,9 @@ namespace QueryKit.Configuration; public class QueryKitSettings { + public const int DefaultMaxNestingDepth = 32; + public const int DefaultMaxInputLength = 5000; + public QueryKitPropertyMappings PropertyMappings { get; set; } = new QueryKitPropertyMappings(); public string EqualsOperator { get; set; } = ComparisonOperator.EqualsOperator().Operator(); public string NotEqualsOperator { get; set; } = ComparisonOperator.NotEqualsOperator().Operator(); @@ -36,6 +39,8 @@ public class QueryKitSettings public bool AllowUnknownProperties { get; set; } public Type? DbContextType { get; set; } public int? MaxPropertyDepth { get; set; } + public int MaxNestingDepth { get; set; } = DefaultMaxNestingDepth; + public int MaxInputLength { get; set; } = DefaultMaxInputLength; public CaseInsensitiveMode CaseInsensitiveComparison { get; set; } = CaseInsensitiveMode.Lower; public QueryKitPropertyMapping Property(Expression>? propertySelector) diff --git a/QueryKit/Exceptions/QueryKitInputLengthExceededException.cs b/QueryKit/Exceptions/QueryKitInputLengthExceededException.cs new file mode 100644 index 0000000..c35d268 --- /dev/null +++ b/QueryKit/Exceptions/QueryKitInputLengthExceededException.cs @@ -0,0 +1,9 @@ +namespace QueryKit.Exceptions; + +public sealed class QueryKitInputLengthExceededException : QueryKitException +{ + public QueryKitInputLengthExceededException(int length, int maxLength) + : base($"The filter has a length of {length}, which exceeds the maximum allowed length of {maxLength}.") + { + } +} diff --git a/QueryKit/Exceptions/QueryKitNestingDepthExceededException.cs b/QueryKit/Exceptions/QueryKitNestingDepthExceededException.cs new file mode 100644 index 0000000..f0486e5 --- /dev/null +++ b/QueryKit/Exceptions/QueryKitNestingDepthExceededException.cs @@ -0,0 +1,9 @@ +namespace QueryKit.Exceptions; + +public sealed class QueryKitNestingDepthExceededException : QueryKitException +{ + public QueryKitNestingDepthExceededException(int depth, int maxDepth) + : base($"The filter has a nesting depth of {depth}, which exceeds the maximum allowed depth of {maxDepth}.") + { + } +} diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index eeaa459..7e74634 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -20,6 +20,8 @@ public static class FilterParser /// Returns a Func delegate that represents a lambda expression that applies the filter defined by the input parameter. public static Expression> ParseFilter(string input, IQueryKitConfiguration? config = null) { + EnsureWithinParseLimits(input, config); + input = config?.ReplaceLogicalAliases(input) ?? input; input = config?.ReplaceComparisonAliases(input) ?? input; input = config?.PropertyMappings?.ReplaceAliasesWithPropertyPaths(input) ?? input; @@ -53,6 +55,39 @@ private static Expression ReplaceDerivedProperties(Expression expr, IQueryKitCon return new ParameterReplacer(parameter).Visit(expr); } + // Runs before the grammar sees the input, so a hostile filter (deeply nested parentheses, + // or an oversized `in` list) is rejected with a QueryKitException instead of overflowing the + // call stack or exhausting CPU and memory during parsing. + private static void EnsureWithinParseLimits(string input, IQueryKitConfiguration? config) + { + var maxLength = config?.MaxInputLength ?? QueryKitSettings.DefaultMaxInputLength; + if (input.Length > maxLength) + { + throw new QueryKitInputLengthExceededException(input.Length, maxLength); + } + + // Counts every '(' and ')', including ones inside quoted values. QueryKit supports several + // quoting styles (plain and raw-string style with 3+ quote marks), so a scanner that tries + // to skip "quoted" spans could misjudge one of them and undercount real nesting. Counting + // everything can only reject too much, never too little. + var maxDepth = config?.MaxNestingDepth ?? QueryKitSettings.DefaultMaxNestingDepth; + var depth = 0; + foreach (var c in input) + { + if (c == '(') + { + depth++; + if (depth > maxDepth) + { + throw new QueryKitNestingDepthExceededException(depth, maxDepth); + } + } + else if (c == ')') + { + depth--; + } + } + } private static readonly Parser Identifier = from first in Parse.Letter.Once() From 3ed920f51791834704ff7d48f67896f14dcb4441 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:05:23 +0300 Subject: [PATCH 02/11] test(parser): use a fixed property name instead of a random word in unknown-property tests Bogus can generate the word "id", a real property on TestingPerson, about once every 182 runs. When it does, the test asserts an UnknownFilterPropertyException that never gets thrown, and the test fails at random. Use a fixed name that can never collide with a real property. --- QueryKit.UnitTests/FilterParserTests.cs | 3 +-- QueryKit.UnitTests/SortParserTests.cs | 4 +--- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 02921cd..24c9199 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -482,8 +482,7 @@ public void equals_doesnt_fail_with_non_string_types() [Fact] public void can_throw_error_when_property_not_recognized() { - var faker = new Faker(); - var propertyName = faker.Lorem.Word(); + var propertyName = "NotARealProperty"; var input = $"""{propertyName} == 25"""; var act = () => FilterParser.ParseFilter(input); act.Should().Throw() diff --git a/QueryKit.UnitTests/SortParserTests.cs b/QueryKit.UnitTests/SortParserTests.cs index 954882d..15849e7 100644 --- a/QueryKit.UnitTests/SortParserTests.cs +++ b/QueryKit.UnitTests/SortParserTests.cs @@ -1,7 +1,6 @@ namespace QueryKit.UnitTests; using System.Linq.Expressions; -using Bogus; using Configuration; using Exceptions; using FluentAssertions; @@ -265,8 +264,7 @@ public void can_prevent_sort() [Fact] public void can_throw_error_when_property_not_recognized() { - var faker = new Faker(); - var propertyName = faker.Lorem.Word(); + var propertyName = "NotARealProperty"; var input = $"""Title, {propertyName}, Age desc"""; var act = () => SortParser.ParseSort(input); act.Should().Throw() From 7ecabe3f8c65004989a554b78fda273827cf3533 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:06:06 +0300 Subject: [PATCH 03/11] fix(operators): correct the HasType error message The HasType operator (^$) raised an error message that named DoesNotHaveType instead of itself, copied from the operator below it. --- QueryKit.UnitTests/FilterParserTests.cs | 9 +++++++++ QueryKit/Operators/ComparisonOperator.cs | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 24c9199..55725cc 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -753,6 +753,15 @@ public void primitive_collection_does_not_have_case_insensitive() .Be(""""x => x.Tags.Any(z => (z.ToLower() != "winner".ToLower()))""""); } + [Fact] + public void has_type_throws_correct_message_on_non_collection_property() + { + var input = """Title ^$ "winner" """; + var act = () => FilterParser.ParseFilter(input); + act.Should().Throw() + .WithMessage("HasType is only supported for collections"); + } + [Fact] public void can_throw_exception_when_invalid_enum_value() { diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 2c8dbb0..0d4e468 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -765,7 +765,7 @@ public override Expression GetExpression(Expression left, Expression right, T return GetCollectionExpression(left, right, Expression.Equal, UsesAll); } - throw new QueryKitParsingException("DoesNotHaveType is only supported for collections"); + throw new QueryKitParsingException("HasType is only supported for collections"); } } From dc336e39eec2679f81eba29ff354875180e630f2 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:08:07 +0300 Subject: [PATCH 04/11] chore(operators): remove unused ArithmeticOperator.FromSymbol No code in the library or the tests calls this method. --- QueryKit/Operators/ArithmeticOperator.cs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/QueryKit/Operators/ArithmeticOperator.cs b/QueryKit/Operators/ArithmeticOperator.cs index e06c735..90c1f9b 100644 --- a/QueryKit/Operators/ArithmeticOperator.cs +++ b/QueryKit/Operators/ArithmeticOperator.cs @@ -23,16 +23,6 @@ protected ArithmeticOperator(string symbol, int precedence) public static ArithmeticOperator Multiply => new MultiplyOperator(); public static ArithmeticOperator Divide => new DivideOperator(); public static ArithmeticOperator Modulo => new ModuloOperator(); - - public static ArithmeticOperator? FromSymbol(string symbol) => symbol switch - { - "+" => Add, - "-" => Subtract, - "*" => Multiply, - "/" => Divide, - "%" => Modulo, - _ => null - }; } internal class AddOperator : ArithmeticOperator From 974729b4f4a3966e8c47c1f4854250f07db0c521 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:33:15 +0300 Subject: [PATCH 05/11] test(filter): remove debug console output from a HasConversion test Use ToDisplayString() instead of ToString() so the assertion still sees the literal value after PR 110 parameterized filter values. --- QueryKit.UnitTests/FilterParserTests.cs | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 55725cc..e1948d1 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -793,12 +793,8 @@ public void can_filter_with_has_conversion_configuration() // The expression should be created successfully (not throw an exception) filterExpression.Should().NotBeNull(); - // Let's see what the actual expression looks like var expressionString = filterExpression.ToDisplayString(); - - // Debug output - this should show us the actual expression - Console.WriteLine($"Generated expression: {expressionString}"); - + // The expression should be created and contain the key elements expressionString.Should().NotBeNullOrEmpty(); expressionString.Should().Contain("x.Email"); From 41c46de1f5f22c71c2536ade4202c78d5d7e55db Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:17:43 +0300 Subject: [PATCH 06/11] test(conversion): remove three out-of-date skipped HasConversion tests --- .../Tests/DatabaseFilteringTests.cs | 34 ------------------- .../CustomFilterPropertyTests.cs | 25 -------------- 2 files changed, 59 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs index b67a17a..b7c3d00 100644 --- a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs +++ b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs @@ -842,40 +842,6 @@ public async Task return_no_records_when_no_match() people.Count.Should().Be(0); } - // var people = testingServiceScope.DbContext().People - // .Where(x => x.Email == fakePersonOne.Email) - // .OrderBy(x => x.Email) - // .ToList(); - // TODO needs to have `Email` not `Email.Value` if using `HasConversion` - [Fact(Skip = "Will need something like this if i want to support HasConversion in efcore.")] - public async Task can_filter_with_child_props() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var faker = new Faker(); - var fakePersonOne = new FakeTestingPersonBuilder() - .WithEmail(faker.Internet.Email()) - .Build(); - var fakePersonTwo = new FakeTestingPersonBuilder() - .Build(); - await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); - - var input = $"""email == "{fakePersonOne.Email.Value}" """; - - // Act - var queryablePeople = testingServiceScope.DbContext().People; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.Email!.Value!).HasQueryName("email"); - }); - var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config); - var people = await appliedQueryable.ToListAsync(); - - // Assert - people.Count.Should().Be(1); - people[0].Id.Should().Be(fakePersonOne.Id); - } - [Fact] public async Task can_filter_with_alias_and_in_operator() { diff --git a/QueryKit.UnitTests/CustomFilterPropertyTests.cs b/QueryKit.UnitTests/CustomFilterPropertyTests.cs index d5f472e..917f4df 100644 --- a/QueryKit.UnitTests/CustomFilterPropertyTests.cs +++ b/QueryKit.UnitTests/CustomFilterPropertyTests.cs @@ -35,31 +35,6 @@ public void can_have_custom_child_prop_name_ownsone() filterExpression.ToDisplayString().Should().Be($"""x => (x.PhysicalAddress.State == "{value}")"""); } - [Fact(Skip = "Will need something like this if i want to support HasConversion in efcore.")] - public void can_have_child_prop_name_for_efcore_HasConversion() - { - var faker = new Faker(); - var value = faker.Lorem.Word(); - var input = $"""Email.Value == "{value}" """; - var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be($"""x => (x.Email == "{value}")"""); - } - - [Fact(Skip = "Will need something like this if i want to support HasConversion in efcore.")] - public void can_have_custom_child_prop_name_for_efcore_HasConversion() - { - var faker = new Faker(); - var value = faker.Lorem.Word(); - var input = $"""email == "{value}" """; - - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.Email).HasQueryName("email"); - }); - var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => (x.Email == "{value}")"""); - } - [Fact] public void can_have_custom_prop_name_for_string() { From 9733341029c90178a39a37870b0e41e2434d4177 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:17:45 +0300 Subject: [PATCH 07/11] docs(agents): add net10.0 to the supported target framework list --- CLAUDE.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e36e4a5..9f99b56 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -47,7 +47,7 @@ dotnet test --collect:"XPlat Code Coverage" ### Packaging ```bash -# Pack NuGet package (already configured for multi-targeting: net6.0, net7.0, net8.0, net9.0) +# Pack NuGet package (already configured for multi-targeting: net6.0, net7.0, net8.0, net9.0, net10.0) dotnet pack --configuration Release ``` @@ -58,7 +58,7 @@ dotnet pack --configuration Release ## Development Notes -- The library supports multiple .NET versions (net6.0 through net9.0) +- The library supports multiple .NET versions (net6.0 through net10.0) - Integration tests use PostgreSQL via Testcontainers for realistic database scenarios - Filter syntax supports complex expressions with parentheses, logical operators (&&, ||), and extensive comparison operators - Property mappings allow aliasing entity properties to different query names From 2c8fbf3a2fafc608d164a56b3cf177d3d728dadd Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:12:38 +0300 Subject: [PATCH 08/11] docs(readme): fix case-insensitive appendix in custom operator example Use eqi (matching the configured appendix) instead of eq$ so the example actually parses. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 3766aad..3664040 100644 --- a/README.md +++ b/README.md @@ -707,7 +707,7 @@ public class CustomQueryKitConfiguration : QueryKitConfiguration // --- -var input = """Title eq$ "Pancakes" and Rating gt 10"""; +var input = """Title eqi "Pancakes" and Rating gt 10"""; var config = new CustomQueryKitConfiguration(); var filterExpression = FilterParser.ParseFilter(input, config); ``` From 07d8f9e56fac32c7e2acbd4bce86457d98062427 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:12:43 +0300 Subject: [PATCH 09/11] docs(readme): fix two non-compiling code snippets The filter example used a malformed verbatim-interpolated string, and the sort example mixed interpolation with an unclosed raw string. Both examples now compile and run as written. --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 3664040..15e584a 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ var config = new QueryKitConfiguration(config => .PreventSort(); }); var people = _dbContext.People - .ApplyQueryKitFilter(@$"first == "Jane" && Age < 10", config) + .ApplyQueryKitFilter("""first == "Jane" && Age < 10""", config) .ToList(); ``` @@ -924,7 +924,7 @@ Sorting is set up to create an expression using the property names you have on y * `PreventSort()` to prevent filtering on a given property ```c# -var input = $"""Age desc, first""; +var input = "Age desc, first"; var config = new QueryKitConfiguration(config => { config.Property(x => x.FirstName) From 691e44269b993534b572727a99daac74d065d0f1 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:25:15 +0300 Subject: [PATCH 10/11] ci(workflows): run the test step against the Release build The build step used --configuration Release, but the test step had no configuration flag, so it built and ran a fresh Debug build instead. The test step now passes --configuration Release, so CI tests the same build that dotnet pack ships. --- .github/workflows/querykit-integration-tests.yaml | 2 +- .github/workflows/querykit-unit-tests.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/querykit-integration-tests.yaml b/.github/workflows/querykit-integration-tests.yaml index 9937d0a..ce441ad 100644 --- a/.github/workflows/querykit-integration-tests.yaml +++ b/.github/workflows/querykit-integration-tests.yaml @@ -20,4 +20,4 @@ jobs: run: dotnet build --configuration Release --no-restore - name: Test working-directory: QueryKit.IntegrationTests - run: dotnet test --no-restore --verbosity minimal + run: dotnet test --no-restore --configuration Release --verbosity minimal diff --git a/.github/workflows/querykit-unit-tests.yaml b/.github/workflows/querykit-unit-tests.yaml index 4072956..221a83e 100644 --- a/.github/workflows/querykit-unit-tests.yaml +++ b/.github/workflows/querykit-unit-tests.yaml @@ -20,4 +20,4 @@ jobs: run: dotnet build --configuration Release --no-restore - name: Test working-directory: QueryKit.UnitTests - run: dotnet test --no-restore --verbosity minimal + run: dotnet test --no-restore --configuration Release --verbosity minimal From 175df75781a837ba3a029665a4d9702865880afa Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:27:14 +0300 Subject: [PATCH 11/11] docs(readme): correct the claim that an unquoted string value throws Title == salt does not throw a ParsingException. QueryKit reads the unquoted word as the literal text salt. --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 15e584a..ac4778d 100644 --- a/README.md +++ b/README.md @@ -973,7 +973,7 @@ If you want to capture errors to easily throw a `400`, you can add error handlin * A `QueryKitException` is the base class for all of the exceptions listed below. This can be caught to catch any exception thrown by QueryKit. -* A `ParsingException` will be thrown when there is an invalid operator or bad syntax is used (e.g. not using double quotes around a string or guid). +* A `ParsingException` will be thrown when there is an invalid operator or bad syntax is used. Note that an unquoted string value, such as `Title == salt`, does not throw. QueryKit reads it as the literal text `salt`. * An `UnknownFilterPropertyException` will be thrown if a property is not recognized during filtering * A `SortParsingException` will be thrown if a property or operation is not recognized during sorting * A `QueryKitDbContextTypeException` will be thrown when trying to use a `DbContext` specific workflow without passing that context (e.g. SoundEx)