From e9fd7503b39dd305473802a5d0cf792556da9fe8 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 16:26:03 +0300 Subject: [PATCH 1/5] fix(config): move the parse limits off IQueryKitConfiguration IQueryKitConfiguration got MaxNestingDepth and MaxInputLength after v1.14.2. A class that implements the interface directly did not compile, and a library built against v1.14.2 failed with TypeLoadException. The limits are now on the new interface IQueryKitParseLimits, which QueryKitConfiguration implements. A configuration without this interface uses the default limits of 32 levels and 5000 characters. --- QueryKit.UnitTests/ParseLimitsTests.cs | 76 +++++++++++++++++++ .../Configuration/IQueryKitParseLimits.cs | 12 +++ .../Configuration/QueryKitConfiguration.cs | 4 +- QueryKit/FilterParser.cs | 4 +- 4 files changed, 91 insertions(+), 5 deletions(-) create mode 100644 QueryKit/Configuration/IQueryKitParseLimits.cs diff --git a/QueryKit.UnitTests/ParseLimitsTests.cs b/QueryKit.UnitTests/ParseLimitsTests.cs index 01d06b8..f5c16ab 100644 --- a/QueryKit.UnitTests/ParseLimitsTests.cs +++ b/QueryKit.UnitTests/ParseLimitsTests.cs @@ -101,4 +101,80 @@ public void filter_within_configured_input_length_parses() var filterExpression = FilterParser.ParseFilter(input, config); filterExpression.Should().NotBeNull(); } + + [Fact] + public void configuration_that_implements_only_the_interface_uses_the_default_limits() + { + var config = new InterfaceOnlyConfiguration(); + + var filterExpression = FilterParser.ParseFilter("""Title == "salt" """, config); + filterExpression.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}*"); + } + + [Fact] + public void configuration_that_implements_the_parse_limits_uses_its_own_limits() + { + var config = new InterfaceOnlyConfigurationWithLimits { MaxNestingDepth = 2, MaxInputLength = 100 }; + var input = new string('(', 3) + """Title == "salt" """ + new string(')', 3); + + var act = () => FilterParser.ParseFilter(input, config); + act.Should().Throw() + .WithMessage("*depth of 3*maximum allowed depth of 2*"); + } + + private sealed class InterfaceOnlyConfigurationWithLimits : InterfaceOnlyConfiguration, IQueryKitParseLimits + { + public int MaxNestingDepth { get; set; } + public int MaxInputLength { get; set; } + } + + // Implements only the IQueryKitConfiguration members of v1.14.2. This class does not compile + // when the interface gets a new member. + private class InterfaceOnlyConfiguration : IQueryKitConfiguration + { + public QueryKitPropertyMappings PropertyMappings { get; } = new(); + public string EqualsOperator { get; set; } = "=="; + public string NotEqualsOperator { get; set; } = "!="; + public string GreaterThanOperator { get; set; } = ">"; + public string LessThanOperator { get; set; } = "<"; + public string GreaterThanOrEqualOperator { get; set; } = ">="; + public string LessThanOrEqualOperator { get; set; } = "<="; + public string ContainsOperator { get; set; } = "@="; + public string StartsWithOperator { get; set; } = "_="; + public string EndsWithOperator { get; set; } = "_-="; + public string NotContainsOperator { get; set; } = "!@="; + public string NotStartsWithOperator { get; set; } = "!_="; + public string NotEndsWithOperator { get; set; } = "!_-="; + public string InOperator { get; set; } = "^^"; + public string NotInOperator { get; set; } = "!^^"; + public string SoundsLikeOperator { get; set; } = "~~"; + public string DoesNotSoundLikeOperator { get; set; } = "!~"; + public string CaseInsensitiveAppendix { get; set; } = "*"; + public string AndOperator { get; set; } = "&&"; + public string OrOperator { get; set; } = "||"; + public bool AllowUnknownProperties { get; set; } + public Type? DbContextType { get; set; } + public string HasCountEqualToOperator { get; set; } = "#=="; + public string HasCountNotEqualToOperator { get; set; } = "#!="; + public string HasCountGreaterThanOperator { get; set; } = "#>"; + public string HasCountLessThanOperator { get; set; } = "#<"; + public string HasCountGreaterThanOrEqualOperator { get; set; } = "#>="; + public string HasCountLessThanOrEqualOperator { get; set; } = "#<="; + public string HasOperator { get; set; } = "^$"; + public string DoesNotHaveOperator { get; set; } = "!^$"; + public int? MaxPropertyDepth { get; set; } + public CaseInsensitiveMode CaseInsensitiveComparison { get; set; } = CaseInsensitiveMode.Lower; + } } diff --git a/QueryKit/Configuration/IQueryKitParseLimits.cs b/QueryKit/Configuration/IQueryKitParseLimits.cs new file mode 100644 index 0000000..32e80f0 --- /dev/null +++ b/QueryKit/Configuration/IQueryKitParseLimits.cs @@ -0,0 +1,12 @@ +namespace QueryKit.Configuration; + +/// +/// The limits that the filter parser applies before it reads a filter. A configuration that does not +/// implement this interface uses and +/// . +/// +public interface IQueryKitParseLimits +{ + int MaxNestingDepth { get; } + int MaxInputLength { get; } +} diff --git a/QueryKit/Configuration/QueryKitConfiguration.cs b/QueryKit/Configuration/QueryKitConfiguration.cs index 219a8b9..6dd9c8b 100644 --- a/QueryKit/Configuration/QueryKitConfiguration.cs +++ b/QueryKit/Configuration/QueryKitConfiguration.cs @@ -33,12 +33,10 @@ 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; } } -public class QueryKitConfiguration : IQueryKitConfiguration +public class QueryKitConfiguration : IQueryKitConfiguration, IQueryKitParseLimits { public QueryKitPropertyMappings PropertyMappings { get; } public string EqualsOperator { get; set; } diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 37bac4d..e7d48b9 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -64,7 +64,7 @@ private static Expression ReplaceDerivedProperties(Expression expr, IQueryKitCon // call stack or exhausting CPU and memory during parsing. private static void EnsureWithinParseLimits(string input, IQueryKitConfiguration? config) { - var maxLength = config?.MaxInputLength ?? QueryKitSettings.DefaultMaxInputLength; + var maxLength = (config as IQueryKitParseLimits)?.MaxInputLength ?? QueryKitSettings.DefaultMaxInputLength; if (input.Length > maxLength) { throw new QueryKitInputLengthExceededException(input.Length, maxLength); @@ -74,7 +74,7 @@ private static void EnsureWithinParseLimits(string input, IQueryKitConfiguration // 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 maxDepth = (config as IQueryKitParseLimits)?.MaxNestingDepth ?? QueryKitSettings.DefaultMaxNestingDepth; var depth = 0; foreach (var c in input) { From f434cc97f77104bc81ee955c7d4481c4f65a24be Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 16:26:25 +0300 Subject: [PATCH 2/5] fix(operators): add ArithmeticOperator.FromSymbol back as obsolete v1.14.2 had the public method ArithmeticOperator.FromSymbol. Its removal broke the source and the binaries of callers. The method is back with the same body. It is marked obsolete, because QueryKit does not use it and the next major version removes it. --- QueryKit.UnitTests/ArithmeticOperatorTests.cs | 33 +++++++++++++++++++ QueryKit/Operators/ArithmeticOperator.cs | 11 +++++++ 2 files changed, 44 insertions(+) create mode 100644 QueryKit.UnitTests/ArithmeticOperatorTests.cs diff --git a/QueryKit.UnitTests/ArithmeticOperatorTests.cs b/QueryKit.UnitTests/ArithmeticOperatorTests.cs new file mode 100644 index 0000000..e53be63 --- /dev/null +++ b/QueryKit.UnitTests/ArithmeticOperatorTests.cs @@ -0,0 +1,33 @@ +namespace QueryKit.UnitTests; + +using FluentAssertions; +using QueryKit.Operators; + +public class ArithmeticOperatorTests +{ + [Theory] + [InlineData("+", "+")] + [InlineData("-", "-")] + [InlineData("*", "*")] + [InlineData("/", "/")] + [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); + } + + [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 90c1f9b..0fa6452 100644 --- a/QueryKit/Operators/ArithmeticOperator.cs +++ b/QueryKit/Operators/ArithmeticOperator.cs @@ -23,6 +23,17 @@ protected ArithmeticOperator(string symbol, int precedence) public static ArithmeticOperator Multiply => new MultiplyOperator(); 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, + "-" => Subtract, + "*" => Multiply, + "/" => Divide, + "%" => Modulo, + _ => null + }; } internal class AddOperator : ArithmeticOperator From 2d62b881efa55e7199c5ad0161d731eb7992f581 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 16:28:31 +0300 Subject: [PATCH 3/5] fix(filter): make filter values constants unless ParameterizeFilterValues is on After v1.14.2, each filter value became a field read that EF Core sends as a SQL parameter. This changed the expression text, the SQL text, and the in-list SQL. On SQL Server at compatibility level 120 or less, in-list filters failed. The new setting ParameterizeFilterValues is false by default. When it is false, filter values are the same constants and constructor calls as in v1.14.2. When it is true, filter values are parameters, as before this change. --- .../Tests/FilterParameterTests.cs | 40 ++++++++++- QueryKit.UnitTests/FilterParserTests.cs | 2 +- .../FilterValueParameterTests.cs | 43 +++++++++++- .../Configuration/QueryKitConfiguration.cs | 2 + QueryKit/Configuration/QueryKitSettings.cs | 6 ++ QueryKit/Expressions/ArithmeticExpression.cs | 2 +- QueryKit/FilterParser.cs | 28 +++++--- QueryKit/FilterValue.cs | 68 ++++++++++++++++++- QueryKit/Operators/ComparisonOperator.cs | 8 +-- 9 files changed, 177 insertions(+), 22 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/FilterParameterTests.cs b/QueryKit.IntegrationTests/Tests/FilterParameterTests.cs index 0b2689e..e630b88 100644 --- a/QueryKit.IntegrationTests/Tests/FilterParameterTests.cs +++ b/QueryKit.IntegrationTests/Tests/FilterParameterTests.cs @@ -2,11 +2,15 @@ namespace QueryKit.IntegrationTests.Tests; using FluentAssertions; using Microsoft.EntityFrameworkCore; +using QueryKit.Configuration; using SharedTestingHelper.Fakes; using WebApiTestProject.Entities; public class FilterParameterTests() : TestBase { + private static readonly QueryKitConfiguration ParameterizedConfig = + new(settings => settings.ParameterizeFilterValues = true); + [Theory] [InlineData("""Title == "lamb" """, """Title == "chicken" """)] [InlineData("""Title @=* "lamb" """, """Title @=* "chicken" """)] @@ -44,15 +48,47 @@ public async Task in_list_is_one_array_parameter_and_still_filters() await testingServiceScope.InsertAsync(lamb, chicken, beef); var input = $"""Title ^^* ["{lamb.Title!.ToUpper()}", "{chicken.Title}"]"""; - var query = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input); + var query = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input, ParameterizedConfig); var people = await query.ToListAsync(); SqlWithoutParameterValues(query).Should().Contain("= ANY (@"); people.Select(x => x.Id).Should().BeEquivalentTo(new[] { lamb.Id, chicken.Id }); } + [Theory] + [InlineData("""Title == "lamb" """, "'lamb'")] + [InlineData("Age > 30", "> 30")] + [InlineData("Date == 2022-07-01", "DATE '2022-07-01'")] + [InlineData("(Age + 5) > 30", "+ 5")] + public void filter_values_are_sql_literals_by_default(string input, string expectedLiteral) + { + var testingServiceScope = new TestingServiceScope(); + + var sql = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input).ToQueryString(); + + sql.Should().NotContain("@"); + sql.Should().Contain(expectedLiteral); + } + + [Fact] + public async Task in_list_is_a_literal_list_by_default_and_still_filters() + { + var testingServiceScope = new TestingServiceScope(); + var lamb = new FakeTestingPersonBuilder().WithTitle($"lamb {Guid.NewGuid()}").Build(); + var chicken = new FakeTestingPersonBuilder().WithTitle($"chicken {Guid.NewGuid()}").Build(); + var beef = new FakeTestingPersonBuilder().WithTitle($"beef {Guid.NewGuid()}").Build(); + await testingServiceScope.InsertAsync(lamb, chicken, beef); + + var input = $"""Title ^^ ["{lamb.Title}", "{chicken.Title}"]"""; + var query = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input); + var people = await query.ToListAsync(); + + query.ToQueryString().Should().Contain($"IN ('{lamb.Title}', '{chicken.Title}')"); + people.Select(x => x.Id).Should().BeEquivalentTo(new[] { lamb.Id, chicken.Id }); + } + private static string SqlWithoutParameterValues(TestingServiceScope testingServiceScope, string input) - => SqlWithoutParameterValues(testingServiceScope.DbContext().People.ApplyQueryKitFilter(input)); + => SqlWithoutParameterValues(testingServiceScope.DbContext().People.ApplyQueryKitFilter(input, ParameterizedConfig)); // ToQueryString() writes each parameter value in a "-- @p='...'" comment line before the SQL. private static string SqlWithoutParameterValues(IQueryable query) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 420ec16..a72fadd 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 == 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 != 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))))))""""); } [Fact] diff --git a/QueryKit.UnitTests/FilterValueParameterTests.cs b/QueryKit.UnitTests/FilterValueParameterTests.cs index 8c48fc5..6f0e0a4 100644 --- a/QueryKit.UnitTests/FilterValueParameterTests.cs +++ b/QueryKit.UnitTests/FilterValueParameterTests.cs @@ -2,10 +2,14 @@ namespace QueryKit.UnitTests; using System.Linq.Expressions; using FluentAssertions; +using QueryKit.Configuration; using WebApiTestProject.Entities; public class FilterValueParameterTests { + private static readonly QueryKitConfiguration ParameterizedConfig = + new(settings => settings.ParameterizeFilterValues = true); + [Theory] [InlineData("""Title == "lamb" """)] [InlineData("""Title @=* "waffle" """)] @@ -27,7 +31,7 @@ public class FilterValueParameterTests [InlineData("(Age + 5) > 30")] public void filter_values_are_field_reads_so_ef_core_sends_them_as_parameters(string input) { - var filterExpression = FilterParser.ParseFilter(input); + var filterExpression = FilterParser.ParseFilter(input, ParameterizedConfig); var constants = new ConstantCollector(); constants.Visit(filterExpression); @@ -37,10 +41,45 @@ public void filter_values_are_field_reads_so_ef_core_sends_them_as_parameters(st value.GetType().GetGenericTypeDefinition().Should().Be(typeof(FilterValue<>))); } + [Theory] + [InlineData("""Title == "lamb" """, """x => (x.Title == "lamb")""")] + [InlineData("Age > 30", "x => (x.Age > 30)")] + [InlineData("BirthMonth == \"January\"", "x => (x.BirthMonth == new Nullable`1(January))")] + [InlineData("SpecificDate == 2022-07-01T00:00:03Z", + "x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00)))")] + [InlineData("Date == 2022-07-01", "x => (x.Date == new Nullable`1(new DateOnly(2022, 7, 1)))")] + [InlineData("""Time == "00:00:03.123456" """, "x => (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 123, 456)))")] + [InlineData("(Age + 5) > 30", "x => ((x.Age + Convert(5, Nullable`1)) > Convert(30, Nullable`1))")] + public void filter_values_are_constants_by_default(string input, string expected) + { + var filterExpression = FilterParser.ParseFilter(input); + + filterExpression.ToString().Should().Be(expected); + } + + [Theory] + [InlineData("""Title == "lamb" """)] + [InlineData("SpecificDateTime > 2022-07-01T00:00:03")] + [InlineData("""Title ^^ ["lamb", "chicken"]""")] + [InlineData("""Title ^^* ["lamb", "chicken"]""")] + [InlineData("Age ^^ [18, 30]")] + [InlineData("(Age + 5) > 30")] + public void filter_values_do_not_use_the_holder_by_default(string input) + { + var filterExpression = FilterParser.ParseFilter(input); + + var constants = new ConstantCollector(); + constants.Visit(filterExpression); + + constants.Values.Should().NotBeEmpty(); + constants.Values.Should().NotContain(value => + value.GetType().IsGenericType && value.GetType().GetGenericTypeDefinition() == typeof(FilterValue<>)); + } + [Fact] public void filter_values_keep_their_value_and_type() { - var filterExpression = FilterParser.ParseFilter("""Time == "00:00:03.123456" """); + var filterExpression = FilterParser.ParseFilter("""Time == "00:00:03.123456" """, ParameterizedConfig); var comparison = (BinaryExpression)filterExpression.Body; var read = (MemberExpression)comparison.Right; diff --git a/QueryKit/Configuration/QueryKitConfiguration.cs b/QueryKit/Configuration/QueryKitConfiguration.cs index 6dd9c8b..2f14f77 100644 --- a/QueryKit/Configuration/QueryKitConfiguration.cs +++ b/QueryKit/Configuration/QueryKitConfiguration.cs @@ -72,6 +72,7 @@ public class QueryKitConfiguration : IQueryKitConfiguration, IQueryKitParseLimit public int MaxNestingDepth { get; set; } public int MaxInputLength { get; set; } public CaseInsensitiveMode CaseInsensitiveComparison { get; set; } + public bool ParameterizeFilterValues { get; set; } public QueryKitConfiguration(Action configureSettings) { @@ -113,5 +114,6 @@ public QueryKitConfiguration(Action configureSettings) MaxNestingDepth = settings.MaxNestingDepth; MaxInputLength = settings.MaxInputLength; CaseInsensitiveComparison = settings.CaseInsensitiveComparison; + ParameterizeFilterValues = settings.ParameterizeFilterValues; } } \ No newline at end of file diff --git a/QueryKit/Configuration/QueryKitSettings.cs b/QueryKit/Configuration/QueryKitSettings.cs index 3d26890..eeb2aa1 100644 --- a/QueryKit/Configuration/QueryKitSettings.cs +++ b/QueryKit/Configuration/QueryKitSettings.cs @@ -43,6 +43,12 @@ public class QueryKitSettings public int MaxInputLength { get; set; } = DefaultMaxInputLength; public CaseInsensitiveMode CaseInsensitiveComparison { get; set; } = CaseInsensitiveMode.Lower; + /// + /// When true, filter values are field reads that EF Core sends as SQL parameters. When false (the default), + /// filter values are constants that EF Core writes into the SQL as literals. + /// + public bool ParameterizeFilterValues { get; set; } + public QueryKitPropertyMapping Property(Expression>? propertySelector) { return PropertyMappings.Property(propertySelector); diff --git a/QueryKit/Expressions/ArithmeticExpression.cs b/QueryKit/Expressions/ArithmeticExpression.cs index c492dfd..799b31d 100644 --- a/QueryKit/Expressions/ArithmeticExpression.cs +++ b/QueryKit/Expressions/ArithmeticExpression.cs @@ -159,7 +159,7 @@ public LiteralArithmeticExpression(object value, Type valueType) public override Expression ToLinqExpression(ParameterExpression parameter, Type entityType) { - return FilterValue.Parameter(Value, ValueType); + return FilterValue.Create(Value, ValueType); } public override Type GetExpressionType(Type entityType) diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index e7d48b9..ba12735 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -25,6 +25,8 @@ public static Expression> ParseFilter(string input, IQueryKitCo var parameter = Expression.Parameter(typeof(T), "x"); Expression expr; + var parameterizeBefore = FilterValue.Parameterize; + FilterValue.Parameterize = config is QueryKitConfiguration { ParameterizeFilterValues: true }; try { expr = ExprParser(parameter, config).End().Parse(input); @@ -45,6 +47,10 @@ public static Expression> ParseFilter(string input, IQueryKitCo { throw new ParsingException(e); } + finally + { + FilterValue.Parameterize = parameterizeBefore; + } return Expression.Lambda>(expr, parameter); } @@ -453,7 +459,7 @@ private static Expression CreateRightExpr(Expression leftExpr, string right, Com var stringCtor = (underlyingType ?? leftExpr.Type).GetConstructor(new[] { typeof(string) }); if (stringCtor != null) { - Expression constructed = Expression.New(stringCtor, FilterValue.Parameter(right, typeof(string))); + Expression constructed = Expression.New(stringCtor, FilterValue.Create(right, typeof(string))); return underlyingType == null ? constructed : Expression.Convert(constructed, leftExpr.Type); } } @@ -474,7 +480,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ { if (op.IsCountOperator() && (int.TryParse(right, out var intVal) || int.TryParse(right, NumberStyles.Integer, CultureInfo.InvariantCulture, out intVal))) { - return FilterValue.Parameter(intVal, typeof(int)); + return FilterValue.Create(intVal, typeof(int)); } targetType = targetType.GetGenericArguments()[0]; return CreateRightExprFromType(targetType, right, op); @@ -536,7 +542,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ dt = DateTime.SpecifyKind(dt, DateTimeKind.Utc); } - return FilterValue.Parameter(dt, rawType); + return FilterValue.Create(dt, rawType); } if (targetType == typeof(DateTimeOffset)) @@ -544,13 +550,13 @@ 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.Parameter(dto.ToUniversalTime(), rawType); + return FilterValue.Create(dto.ToUniversalTime(), rawType); } if (targetType == typeof(DateOnly)) { var date = DateOnly.Parse(right, CultureInfo.InvariantCulture); - return FilterValue.Parameter(date, rawType); + return FilterValue.Create(date, rawType); } if (targetType == typeof(TimeOnly)) @@ -564,7 +570,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ // One microsecond is 10 ticks. The TimeOnly constructor with microseconds needs .NET 7. var value = new TimeOnly(time.Hour, time.Minute, time.Second, millisecond) .Add(TimeSpan.FromTicks(microsecond * 10)); - return FilterValue.Parameter(value, rawType); + return FilterValue.Create(value, rawType); } if (targetType == typeof(Guid)) @@ -573,16 +579,16 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ // For equality/comparison operators, we can compare GUIDs directly (more efficient and EF-friendly) if (op.IsStringComparisonOperator()) { - return FilterValue.Parameter(right, typeof(string)); + return FilterValue.Create(right, typeof(string)); } // Parse the GUID for direct comparison var guidValue = Guid.Parse(right); - return FilterValue.Parameter(guidValue, typeof(Guid)); + return FilterValue.Create(guidValue, typeof(Guid)); } var convertedValue = conversionFunction(right); - return FilterValue.Parameter(convertedValue, leftExprType); + return FilterValue.Create(convertedValue, leftExprType); } if (rawType.IsEnum || (Nullable.GetUnderlyingType(rawType)?.IsEnum ?? false)) @@ -621,7 +627,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ { throw new InvalidOperationException($"Unsupported value '{right}' for type '{targetType.Name}'"); } - return FilterValue.Parameter(enumValue, rawType); + return FilterValue.Create(enumValue, rawType); } // for some complex derived expressions @@ -634,7 +640,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ if (bool.TryParse(right, out var boolVal)) { - return FilterValue.Parameter(boolVal, typeof(bool)); + return FilterValue.Create(boolVal, typeof(bool)); } } diff --git a/QueryKit/FilterValue.cs b/QueryKit/FilterValue.cs index 764e7de..d459436 100644 --- a/QueryKit/FilterValue.cs +++ b/QueryKit/FilterValue.cs @@ -17,10 +17,76 @@ public FilterValue(T value) internal static class FilterValue { - public static Expression Parameter(object? value, Type type) + // The parser sets this for one parse when ParameterizeFilterValues is on. Parsing is synchronous, + // so the value belongs to the thread that parses. + [ThreadStatic] private static bool _parameterize; + + public static bool Parameterize + { + get => _parameterize; + set => _parameterize = value; + } + + // Returns a field read on a FilterValue holder when parameters are on. Otherwise returns the + // same literal expression as v1.14.2. + public static Expression Create(object? value, Type type) { + if (!_parameterize) + { + return Literal(value, type); + } + var holderType = typeof(FilterValue<>).MakeGenericType(type); var holder = Activator.CreateInstance(holderType, value); return Expression.Field(Expression.Constant(holder, holderType), nameof(FilterValue.Value)); } + + // Dates and times are constructor calls, and a nullable enum wraps its constant in a Nullable + // constructor. Every other value is a constant. + private static Expression Literal(object? value, Type type) + { + var underlying = Nullable.GetUnderlyingType(type); + var valueType = underlying ?? type; + + Expression? created = value switch + { + DateTime dt when valueType == typeof(DateTime) + => New(valueType, new[] { typeof(long), typeof(DateTimeKind) }, dt.Ticks, dt.Kind), + DateTimeOffset dto when valueType == typeof(DateTimeOffset) + => New(valueType, new[] { typeof(long), typeof(TimeSpan) }, dto.Ticks, dto.Offset), + DateOnly date when valueType == typeof(DateOnly) + => New(valueType, new[] { typeof(int), typeof(int), typeof(int) }, date.Year, date.Month, date.Day), + TimeOnly time when valueType == typeof(TimeOnly) => NewTimeOnly(time), + not null when underlying is { IsEnum: true } => Expression.Constant(value, underlying), + _ => null + }; + + if (created == null) + { + return Expression.Constant(value, type); + } + + return underlying == null + ? created + : 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) + { + var ctor = typeof(TimeOnly).GetConstructor(new[] { typeof(int), typeof(int), typeof(int), typeof(int), typeof(int) }); + if (ctor == null) + { + return null; + } + + var fractionalTicks = time.Ticks % TimeSpan.TicksPerSecond; + var millisecond = (int)(fractionalTicks / TimeSpan.TicksPerMillisecond); + var microsecond = (int)(fractionalTicks % TimeSpan.TicksPerMillisecond / 10); + return Expression.New(ctor, new object[] { time.Hour, time.Minute, time.Second, millisecond, microsecond } + .Select(arg => Expression.Constant(arg))); + } + + private static NewExpression New(Type type, Type[] parameterTypes, params object[] args) + => Expression.New(type.GetConstructor(parameterTypes)!, args.Select(arg => Expression.Constant(arg))); } diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 97c959a..a561f50 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -589,7 +589,7 @@ public override Expression GetExpression(Expression left, Expression right, T } values = list; - right = FilterValue.Parameter(list, listType); + right = FilterValue.Create(list, listType); } // Get the Contains method with the correct generic type @@ -611,7 +611,7 @@ public override Expression GetExpression(Expression left, Expression right, T { listType.GetMethod("Add")!.Invoke(caseList, new[] { caseMode == CaseInsensitiveMode.Upper ? value.ToUpper() : value.ToLower() }); } - right = FilterValue.Parameter(caseList, listType); + right = FilterValue.Create(caseList, listType); var caseLeft = Expression.Call(left, typeof(string).GetMethod(caseMethodName, Type.EmptyTypes)!); var containsCall = Expression.Call(right, containsMethod, caseLeft); @@ -834,7 +834,7 @@ public override Expression GetExpression(Expression left, Expression right, T } values = list; - right = FilterValue.Parameter(list, listType); + right = FilterValue.Create(list, listType); } // Get the Contains method with the correct generic type @@ -856,7 +856,7 @@ public override Expression GetExpression(Expression left, Expression right, T { listType.GetMethod("Add")!.Invoke(caseList, new[] { caseMode == CaseInsensitiveMode.Upper ? value.ToUpper() : value.ToLower() }); } - right = FilterValue.Parameter(caseList, listType); + right = FilterValue.Create(caseList, listType); var caseLeft = Expression.Call(left, typeof(string).GetMethod(caseMethodName, Type.EmptyTypes)!); var containsExpression = Expression.Call(right, containsMethod, caseLeft); From 03a7627010f5165d5780b0814efc5b016e1804e8 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 16:32:31 +0300 Subject: [PATCH 4/5] fix(filter): replace ignored clauses with true == true by default v1.14.2 replaced a clause on a prevented or unknown property with (true == true). Main removed the clause, which changes the rows that an OR returns. The new setting IgnoredClauseBehavior has the default ReplaceWithTrue, which gives the v1.14.2 behavior again. The value Remove keeps the behavior of main. --- .../Tests/PropertyResolverTests.cs | 32 ++++++++++ .../CustomFilterPropertyTests.cs | 10 +-- QueryKit.UnitTests/PropertyResolverTests.cs | 61 +++++++++++++++++++ .../Configuration/IgnoredClauseBehavior.cs | 14 +++++ .../Configuration/QueryKitConfiguration.cs | 2 + QueryKit/Configuration/QueryKitSettings.cs | 6 ++ QueryKit/FilterParser.cs | 23 ++++--- 7 files changed, 136 insertions(+), 12 deletions(-) create mode 100644 QueryKit/Configuration/IgnoredClauseBehavior.cs diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 590601b..1dba6d7 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -11,6 +11,33 @@ namespace QueryKit.IntegrationTests.Tests; public class PropertyResolverTests : TestBase { + [Fact] + public async Task unknown_property_clause_under_or_is_true_by_default() + { + // 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 == "x" || Age > 100)"""; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().ContainSingle(x => x.Id == fakePerson.Id); + } + [Fact] public async Task unknown_property_clause_under_or_does_not_return_every_row() { @@ -26,6 +53,7 @@ public async Task unknown_property_clause_under_or_does_not_return_every_row() var input = $"""Title == "{title}" && (Nope == "x" || Age > 100)"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.AllowUnknownProperties = true; }); @@ -54,6 +82,7 @@ public async Task prevented_property_clause_under_or_does_not_return_every_row() var input = $"""Title == "{title}" && (Rating == 1 || Age > 100)"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Rating).PreventFilter(); }); @@ -82,6 +111,7 @@ public async Task prevented_property_clause_by_its_query_name_under_or_does_not_ 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(); }); @@ -138,6 +168,7 @@ public async Task prevented_property_on_the_right_side_is_not_compared() var input = $"""Title == "{title}" && (FirstName == LastName || Age > 100)"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.LastName).PreventFilter(); }); @@ -398,6 +429,7 @@ public async Task unknown_property_in_arithmetic_removes_the_clause_when_unknown var input = $"""Title == "{title}" && ((Nope + 1) > 3 || Age > 100)"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.AllowUnknownProperties = true; }); diff --git a/QueryKit.UnitTests/CustomFilterPropertyTests.cs b/QueryKit.UnitTests/CustomFilterPropertyTests.cs index a41cbdf..70ed347 100644 --- a/QueryKit.UnitTests/CustomFilterPropertyTests.cs +++ b/QueryKit.UnitTests/CustomFilterPropertyTests.cs @@ -142,7 +142,7 @@ public void can_have_custom_prop_excluded_from_filter() config.Property(x => x.Id).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "{stringValue}")"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (True == True))"""); } [Fact] @@ -159,7 +159,7 @@ public void can_have_custom_prop_excluded_from_filter_with_custom_propname() config.Property(x => x.Id).HasQueryName("identifier").PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "{stringValue}")"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (True == True))"""); } [Fact] @@ -243,7 +243,7 @@ public void can_have_derived_prop_work_with_collection_filters() } [Fact] - public void filter_prevented_props_are_removed_regardless_of_comparison() + public void filter_prevented_props_always_have_true_equals_true_regardless_of_comparison() { var faker = new Faker(); var filterOperator = faker.PickRandom(ComparisonOperator.List.Where(x => x != ComparisonOperator.EqualsOperator()).ToList()); @@ -255,7 +255,7 @@ public void filter_prevented_props_are_removed_regardless_of_comparison() config.Property(x => x.Id).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => True"); + filterExpression.ToDisplayString().Should().Be("x => (True == True)"); } [Fact] @@ -284,6 +284,6 @@ public void can_handle_nonexistent_property() config.AllowUnknownProperties = true; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => True"); + filterExpression.ToDisplayString().Should().Be("x => (True == True)"); } } \ No newline at end of file diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 90cc61d..41e8431 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -9,12 +9,56 @@ namespace QueryKit.UnitTests; public class PropertyResolverTests { + [Fact] + public void unknown_property_clause_is_true_equals_true_by_default() + { + var input = """Nope == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => ((True == True) OrElse (x.Age > 100))"); + } + + [Fact] + 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(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => ((x.FirstName == "Ann") OrElse (True == True))"""); + } + + [Fact] + 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(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (True OrElse (x.Age > 100))"); + } + [Fact] public void unknown_property_clause_is_removed_under_or() { var input = """Nope == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.AllowUnknownProperties = true; }); @@ -29,6 +73,7 @@ public void unknown_property_clause_is_removed_under_and() var input = """Age > 100 && Nope == "x" """; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.AllowUnknownProperties = true; }); @@ -43,6 +88,7 @@ public void prevented_property_clause_is_removed_under_or() var input = """Rating == 1 || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Rating).PreventFilter(); }); @@ -57,6 +103,7 @@ public void removed_clause_in_a_group_is_removed_from_the_group() var input = """Title == "a" && (Nope == "x" || Age > 100)"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.AllowUnknownProperties = true; }); @@ -71,6 +118,7 @@ public void property_list_with_only_prevented_properties_is_removed() var input = """(Title, FirstName) == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Title).PreventFilter(); config.Property(x => x.FirstName).PreventFilter(); }); @@ -86,6 +134,7 @@ public void prevented_property_in_arithmetic_removes_the_clause() var input = """(Age + 0) > 10 || Title == "a" """; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Age).PreventFilter(); }); @@ -100,6 +149,7 @@ public void prevented_property_on_the_right_side_of_arithmetic_removes_the_claus var input = """(Age + 0) > (Rating * 2)"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Rating).PreventFilter(); }); @@ -128,6 +178,7 @@ public void prevented_property_on_the_right_side_removes_the_clause() var input = """FirstName == Title || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Title).PreventFilter(); }); @@ -142,6 +193,7 @@ 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(); }); @@ -170,6 +222,7 @@ public void prevented_property_removes_the_clause_in_any_case() var input = """title == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Title).PreventFilter(); }); @@ -184,6 +237,7 @@ public void prevented_property_with_a_query_name_removes_the_clause_when_written var input = """title == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Title).HasQueryName("t").PreventFilter(); }); @@ -241,6 +295,7 @@ public void prevented_derived_property_removes_the_clause() var input = """full == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventFilter(); }); @@ -269,6 +324,7 @@ public void prevented_custom_operation_removes_the_clause() var input = """adult == true || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.CustomOperation((x, op, value) => x.Age > 17).HasQueryName("adult").PreventFilter(); }); @@ -340,6 +396,7 @@ public void property_prevented_for_filter_and_sort_is_removed_by_its_query_name( var input = """name == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); }); @@ -354,6 +411,7 @@ public void property_prevented_for_filter_and_sort_is_removed_by_its_member_name var input = """Title == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); }); @@ -405,6 +463,7 @@ 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(); }); @@ -419,6 +478,7 @@ public void unknown_property_in_arithmetic_removes_the_clause_when_unknown_prope var input = """(Nope + 1) > 3 || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.AllowUnknownProperties = true; }); @@ -433,6 +493,7 @@ public void unknown_property_on_the_right_side_of_arithmetic_removes_the_clause_ var input = """(Age + 0) > Nope || Title == "a" """; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.AllowUnknownProperties = true; }); diff --git a/QueryKit/Configuration/IgnoredClauseBehavior.cs b/QueryKit/Configuration/IgnoredClauseBehavior.cs new file mode 100644 index 0000000..742e1e6 --- /dev/null +++ b/QueryKit/Configuration/IgnoredClauseBehavior.cs @@ -0,0 +1,14 @@ +namespace QueryKit.Configuration; + +/// +/// Controls what the filter parser does with a clause that it ignores: a clause on a property that has +/// PreventFilter, or on an unknown property when AllowUnknownProperties is true. +/// +public enum IgnoredClauseBehavior +{ + /// Default. Replaces the clause with (true == true). Under an OR, the whole OR is then true. + ReplaceWithTrue = 0, + + /// Removes the clause. A logical operator with a removed side keeps only its other side. + Remove = 1 +} diff --git a/QueryKit/Configuration/QueryKitConfiguration.cs b/QueryKit/Configuration/QueryKitConfiguration.cs index 2f14f77..ac2ef03 100644 --- a/QueryKit/Configuration/QueryKitConfiguration.cs +++ b/QueryKit/Configuration/QueryKitConfiguration.cs @@ -73,6 +73,7 @@ public class QueryKitConfiguration : IQueryKitConfiguration, IQueryKitParseLimit public int MaxInputLength { get; set; } public CaseInsensitiveMode CaseInsensitiveComparison { get; set; } public bool ParameterizeFilterValues { get; set; } + public IgnoredClauseBehavior IgnoredClauseBehavior { get; set; } public QueryKitConfiguration(Action configureSettings) { @@ -115,5 +116,6 @@ public QueryKitConfiguration(Action configureSettings) MaxInputLength = settings.MaxInputLength; CaseInsensitiveComparison = settings.CaseInsensitiveComparison; ParameterizeFilterValues = settings.ParameterizeFilterValues; + IgnoredClauseBehavior = settings.IgnoredClauseBehavior; } } \ No newline at end of file diff --git a/QueryKit/Configuration/QueryKitSettings.cs b/QueryKit/Configuration/QueryKitSettings.cs index eeb2aa1..e79ba3c 100644 --- a/QueryKit/Configuration/QueryKitSettings.cs +++ b/QueryKit/Configuration/QueryKitSettings.cs @@ -49,6 +49,12 @@ public class QueryKitSettings /// public bool ParameterizeFilterValues { get; set; } + /// + /// What the filter parser does with a clause on a prevented or unknown property. The default is + /// , the same as v1.14.2. + /// + public IgnoredClauseBehavior IgnoredClauseBehavior { get; set; } = IgnoredClauseBehavior.ReplaceWithTrue; + public QueryKitPropertyMapping Property(Expression>? propertySelector) { return PropertyMappings.Property(propertySelector); diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index ba12735..fa65e5f 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -689,7 +689,7 @@ private static Parser ArithmeticComparisonExprParser(ParameterExp var rightArithmetic = ResolveArithmeticProperties(temp.rightSide, typeof(T), config); if (leftArithmetic == null || rightArithmetic == null) { - return RemovedClauseExpression.Instance; + return IgnoredClause(config); } var leftExpr = leftArithmetic.ToLinqExpression(parameter, typeof(T)); @@ -808,7 +808,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa { if (!temp.reference.CanFilter) { - return RemovedClauseExpression.Instance; + return IgnoredClause(config); } return CreateCustomOperationExpression(parameter, temp.reference.Mapping!, temp.op, temp.right); @@ -816,13 +816,13 @@ private static Parser ComparisonExprParser(ParameterExpression pa if (temp.reference.Kind == PropertyReferenceKind.Unknown) { - return RemovedClauseExpression.Instance; + return IgnoredClause(config); } var leftExpr = CreateLeftExpr(parameter, temp.reference, config); if (leftExpr is RemovedClauseExpression) { - return leftExpr; + return IgnoredClause(config); } if (leftExpr.Type == typeof(Guid) || leftExpr.Type == typeof(Guid?)) @@ -854,7 +854,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa { if (!PropertyResolver.Resolve(parameter.Type, temp.right, config).CanFilter) { - return RemovedClauseExpression.Instance; + return IgnoredClause(config); } var rightPropertyExpr = CreateRightPropertyExpr(parameter, temp.right, config); @@ -1186,8 +1186,8 @@ private static Parser PropertyListComparisonExprParser( : Expression.OrElse(result, comparison); } - // If all properties were filtered out, remove the clause - return result ?? RemovedClauseExpression.Instance; + // If all properties were filtered out, the clause is ignored. v1.14.2 used true here, not true == true. + return result ?? (RemovesIgnoredClauses(config) ? RemovedClauseExpression.Instance : Expression.Constant(true)); }); } @@ -1223,6 +1223,15 @@ private static Parser OrExprParser(ParameterExpression parameter, CombineClauses ); + private static bool RemovesIgnoredClauses(IQueryKitConfiguration? config) + => config is QueryKitConfiguration { IgnoredClauseBehavior: IgnoredClauseBehavior.Remove }; + + // A clause on a prevented or unknown property. By default it becomes true == true, the same as v1.14.2. + private static Expression IgnoredClause(IQueryKitConfiguration? config) + => RemovesIgnoredClauses(config) + ? RemovedClauseExpression.Instance + : Expression.Equal(Expression.Constant(true), Expression.Constant(true)); + // A removed clause has no effect, so the operator keeps only the other side private static Expression CombineClauses(LogicalOperator op, Expression left, Expression right) { From 9ed37a59fbfc64f11d9663a9660a081b70385c95 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 16:33:01 +0300 Subject: [PATCH 5/5] fix(config): replace a query name after a dot in alias replacement again In v1.14.2, the public ReplaceAliasesWithPropertyPaths replaced a query name also after a dot. Main skipped it, which changed the result for direct callers. The parser does not call this method, so filters do not change. --- QueryKit.UnitTests/PropertyResolverTests.cs | 4 ++-- QueryKit/QueryKitPropertyMappings.cs | 5 ++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 41e8431..0d665c3 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -421,7 +421,7 @@ public void property_prevented_for_filter_and_sort_is_removed_by_its_member_name } [Fact] - public void alias_replacement_does_not_replace_a_query_name_in_a_nested_path() + public void alias_replacement_replaces_a_query_name_in_a_nested_path() { var config = new QueryKitConfiguration(config => { @@ -430,7 +430,7 @@ public void alias_replacement_does_not_replace_a_query_name_in_a_nested_path() var input = config.PropertyMappings.ReplaceAliasesWithPropertyPaths("""Author.Name == "x" && name == "y" """); - input.Should().Be("""Author.Name == "x" && Title == "y" """); + input.Should().Be("""Author.Title == "x" && Title == "y" """); } [Fact] diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index 216231e..a0aec17 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -136,9 +136,8 @@ public string ReplaceAliasesWithPropertyPaths(string input) { // 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). A query name - // after a dot is a segment of a nested path, so it is not replaced. - var regex = AliasRegexCache.Get($@"(?