From 7f9db3f2ff18d7e0f025e01db82374eb00f261b8 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Wed, 30 Sep 2026 17:04:30 +0300 Subject: [PATCH] perf(filter)!: send filter values as parameters by default QueryKit wrote each filter value into the SQL as a literal. Each new value made new SQL text, so EF Core compiled a new query and the database made a new plan. ParameterizeFilterValues is now true by default, so one filter shape uses one cached plan. Set it to false to get the v1.14.2 literals. BREAKING CHANGE: Filter values are now field reads that EF Core sends as SQL parameters. The expression text and the SQL text change, and two filters that differ only in values print the same expression text. In-lists become parameters, which use OPENJSON on SQL Server and need compatibility level 130 or more. Set ParameterizeFilterValues to false to keep the v1.14.2 behavior. --- .../Tests/FilterParameterTests.cs | 16 ++++++++-------- .../Tests/InListQueryCacheTests.cs | 10 ++++++---- QueryKit.UnitTests/AliasCultureTests.cs | 6 +++--- QueryKit.UnitTests/CustomFilterPropertyTests.cs | 2 +- QueryKit.UnitTests/DotNumberCultureTests.cs | 2 +- QueryKit.UnitTests/FilterParserTests.cs | 14 +++++++------- QueryKit.UnitTests/FilterValueParameterTests.cs | 16 ++++++++-------- .../Configuration/IQueryKitFilterBehavior.cs | 2 +- QueryKit/Configuration/QueryKitSettings.cs | 6 +++--- QueryKit/FilterParser.cs | 2 +- README.md | 4 ++-- 11 files changed, 41 insertions(+), 39 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/FilterParameterTests.cs b/QueryKit.IntegrationTests/Tests/FilterParameterTests.cs index e630b88..e4c5105 100644 --- a/QueryKit.IntegrationTests/Tests/FilterParameterTests.cs +++ b/QueryKit.IntegrationTests/Tests/FilterParameterTests.cs @@ -8,8 +8,8 @@ namespace QueryKit.IntegrationTests.Tests; public class FilterParameterTests() : TestBase { - private static readonly QueryKitConfiguration ParameterizedConfig = - new(settings => settings.ParameterizeFilterValues = true); + private static readonly QueryKitConfiguration LiteralConfig = + new(settings => settings.ParameterizeFilterValues = false); [Theory] [InlineData("""Title == "lamb" """, """Title == "chicken" """)] @@ -48,7 +48,7 @@ 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, ParameterizedConfig); + var query = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input); var people = await query.ToListAsync(); SqlWithoutParameterValues(query).Should().Contain("= ANY (@"); @@ -60,18 +60,18 @@ public async Task in_list_is_one_array_parameter_and_still_filters() [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) + public void filter_values_are_sql_literals_when_parameters_are_off(string input, string expectedLiteral) { var testingServiceScope = new TestingServiceScope(); - var sql = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input).ToQueryString(); + var sql = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input, LiteralConfig).ToQueryString(); sql.Should().NotContain("@"); sql.Should().Contain(expectedLiteral); } [Fact] - public async Task in_list_is_a_literal_list_by_default_and_still_filters() + public async Task in_list_is_a_literal_list_when_parameters_are_off_and_still_filters() { var testingServiceScope = new TestingServiceScope(); var lamb = new FakeTestingPersonBuilder().WithTitle($"lamb {Guid.NewGuid()}").Build(); @@ -80,7 +80,7 @@ public async Task in_list_is_a_literal_list_by_default_and_still_filters() await testingServiceScope.InsertAsync(lamb, chicken, beef); var input = $"""Title ^^ ["{lamb.Title}", "{chicken.Title}"]"""; - var query = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input); + var query = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input, LiteralConfig); var people = await query.ToListAsync(); query.ToQueryString().Should().Contain($"IN ('{lamb.Title}', '{chicken.Title}')"); @@ -88,7 +88,7 @@ public async Task in_list_is_a_literal_list_by_default_and_still_filters() } private static string SqlWithoutParameterValues(TestingServiceScope testingServiceScope, string input) - => SqlWithoutParameterValues(testingServiceScope.DbContext().People.ApplyQueryKitFilter(input, ParameterizedConfig)); + => SqlWithoutParameterValues(testingServiceScope.DbContext().People.ApplyQueryKitFilter(input)); // ToQueryString() writes each parameter value in a "-- @p='...'" comment line before the SQL. private static string SqlWithoutParameterValues(IQueryable query) diff --git a/QueryKit.IntegrationTests/Tests/InListQueryCacheTests.cs b/QueryKit.IntegrationTests/Tests/InListQueryCacheTests.cs index a37f5f9..fe268ab 100644 --- a/QueryKit.IntegrationTests/Tests/InListQueryCacheTests.cs +++ b/QueryKit.IntegrationTests/Tests/InListQueryCacheTests.cs @@ -4,11 +4,12 @@ namespace QueryKit.IntegrationTests.Tests; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Caching.Memory; +using QueryKit.Configuration; using SharedTestingHelper.Fakes; using WebApiTestProject.Database; -// In the default constant mode, EF Core finds a compiled query for an in-list with Equals and -// GetHashCode on the list constant. These tests use one DbContextOptions with its own query cache. +// In the constant mode (ParameterizeFilterValues = false), EF Core finds a compiled query for an in-list +// with Equals and GetHashCode on the list constant. These tests use one DbContextOptions with its own query cache. public class InListQueryCacheTests() : TestBase { [Theory] @@ -100,6 +101,7 @@ private static async Task NoZoneRequestFails(QueryCache cache, string input) private sealed class QueryCache { + private static readonly QueryKitConfiguration ConstantMode = new(settings => settings.ParameterizeFilterValues = false); private readonly DbContextOptions _options; private readonly RecordingMemoryCache _memoryCache = new(); private int _compilations; @@ -124,13 +126,13 @@ public QueryCache(TestingServiceScope testingServiceScope) public string QueryString(string input) { using var context = new TestingDbContext(_options); - return context.People.ApplyQueryKitFilter(input).ToQueryString(); + return context.People.ApplyQueryKitFilter(input, ConstantMode).ToQueryString(); } public async Task> Ids(string input) { await using var context = new TestingDbContext(_options); - return await context.People.ApplyQueryKitFilter(input).Select(x => x.Id).ToListAsync(); + return await context.People.ApplyQueryKitFilter(input, ConstantMode).Select(x => x.Id).ToListAsync(); } } diff --git a/QueryKit.UnitTests/AliasCultureTests.cs b/QueryKit.UnitTests/AliasCultureTests.cs index ab3cadc..82d0681 100644 --- a/QueryKit.UnitTests/AliasCultureTests.cs +++ b/QueryKit.UnitTests/AliasCultureTests.cs @@ -24,7 +24,7 @@ public void query_name_matches_in_en_us_after_a_tr_tr_parse() turkish.Should().Throw(); var filterExpression = WithCulture("en-US", () => FilterParser.ParseFilter(input, config)); - filterExpression.ToString().Should().Be("x => (x.Rating > 3)"); + filterExpression.ToDisplayString().Should().Be("x => (x.Rating > 3)"); } [Fact] @@ -37,7 +37,7 @@ public void query_name_does_not_match_in_tr_tr_after_an_en_us_parse() var input = "TIPBETA > 3"; var filterExpression = WithCulture("en-US", () => FilterParser.ParseFilter(input, config)); - filterExpression.ToString().Should().Be("x => (x.Rating > 3)"); + filterExpression.ToDisplayString().Should().Be("x => (x.Rating > 3)"); var turkish = () => WithCulture("tr-TR", () => FilterParser.ParseFilter(input, config)); turkish.Should().Throw(); @@ -53,7 +53,7 @@ public void operator_alias_does_not_match_in_tr_tr_after_an_en_us_parse() var input = """Title EŞITGAMMA "Pancakes" """; var filterExpression = WithCulture("en-US", () => FilterParser.ParseFilter(input, config)); - filterExpression.ToString().Should().Be("""x => (x.Title == "Pancakes")"""); + filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "Pancakes")"""); var turkish = () => WithCulture("tr-TR", () => FilterParser.ParseFilter(input, config)); turkish.Should().Throw(); diff --git a/QueryKit.UnitTests/CustomFilterPropertyTests.cs b/QueryKit.UnitTests/CustomFilterPropertyTests.cs index e3dbbce..70ed347 100644 --- a/QueryKit.UnitTests/CustomFilterPropertyTests.cs +++ b/QueryKit.UnitTests/CustomFilterPropertyTests.cs @@ -173,7 +173,7 @@ public void can_have_custom_prop_name_with_in_operator() }); var filterExpression = FilterParser.ParseFilter(input, config); filterExpression.ToDisplayString().Should() - .Be("""x => value(QueryKit.InListValues`1[System.String]).Contains(x.Title)"""); + .Be("""x => value(System.Collections.Generic.List`1[System.String]).Contains(x.Title)"""); } [Theory] diff --git a/QueryKit.UnitTests/DotNumberCultureTests.cs b/QueryKit.UnitTests/DotNumberCultureTests.cs index 89c99f4..e006f88 100644 --- a/QueryKit.UnitTests/DotNumberCultureTests.cs +++ b/QueryKit.UnitTests/DotNumberCultureTests.cs @@ -47,7 +47,7 @@ public void integer_value_still_filters_in_a_comma_culture() { var filterExpression = WithCulture("de-DE", () => FilterParser.ParseFilter("Rating > 4")); - filterExpression.ToString().Should().Be("x => (x.Rating > 4)"); + filterExpression.ToDisplayString().Should().Be("x => (x.Rating > 4)"); } private static TResult WithCulture(string cultureName, Func action) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 200e8e0..57af242 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.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 == 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] @@ -216,7 +216,7 @@ public void simple_in_operator_for_nullable_int() var input = """Age ^^ [20, 30, 40]"""; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToDisplayString().Should() - .Be(""""x => value(QueryKit.InListValues`1[System.Nullable`1[System.Int32]]).Contains(x.Age)""""); + .Be(""""x => value(System.Collections.Generic.List`1[System.Nullable`1[System.Int32]]).Contains(x.Age)""""); } [Fact] @@ -225,7 +225,7 @@ public void simple_in_operator_for_guid() var input = """Id ^^ ["6d623e92-d2cf-4496-a2df-f49fa77328ee"]"""; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToDisplayString().Should() - .Be(""""x => value(QueryKit.InListValues`1[System.Guid]).Contains(x.Id)""""); + .Be(""""x => value(System.Collections.Generic.List`1[System.Guid]).Contains(x.Id)""""); } [Fact] @@ -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(637922304030000000, 01:00:00)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922268030000000, 00: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(637922304030000000, 01:00:00)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922268030000000, 00: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(637922304030000000, -02:00:00)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922376030000000, 00: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(637922304030000000, 02:00:00)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922232030000000, 00:00:00)))"); } [Fact] diff --git a/QueryKit.UnitTests/FilterValueParameterTests.cs b/QueryKit.UnitTests/FilterValueParameterTests.cs index 6f0e0a4..b111f03 100644 --- a/QueryKit.UnitTests/FilterValueParameterTests.cs +++ b/QueryKit.UnitTests/FilterValueParameterTests.cs @@ -7,8 +7,8 @@ namespace QueryKit.UnitTests; public class FilterValueParameterTests { - private static readonly QueryKitConfiguration ParameterizedConfig = - new(settings => settings.ParameterizeFilterValues = true); + private static readonly QueryKitConfiguration LiteralConfig = + new(settings => settings.ParameterizeFilterValues = false); [Theory] [InlineData("""Title == "lamb" """)] @@ -31,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, ParameterizedConfig); + var filterExpression = FilterParser.ParseFilter(input); var constants = new ConstantCollector(); constants.Visit(filterExpression); @@ -50,9 +50,9 @@ public void filter_values_are_field_reads_so_ef_core_sends_them_as_parameters(st [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) + public void filter_values_are_constants_when_parameters_are_off(string input, string expected) { - var filterExpression = FilterParser.ParseFilter(input); + var filterExpression = FilterParser.ParseFilter(input, LiteralConfig); filterExpression.ToString().Should().Be(expected); } @@ -64,9 +64,9 @@ public void filter_values_are_constants_by_default(string input, string expected [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) + public void filter_values_do_not_use_the_holder_when_parameters_are_off(string input) { - var filterExpression = FilterParser.ParseFilter(input); + var filterExpression = FilterParser.ParseFilter(input, LiteralConfig); var constants = new ConstantCollector(); constants.Visit(filterExpression); @@ -79,7 +79,7 @@ public void filter_values_do_not_use_the_holder_by_default(string input) [Fact] public void filter_values_keep_their_value_and_type() { - var filterExpression = FilterParser.ParseFilter("""Time == "00:00:03.123456" """, ParameterizedConfig); + var filterExpression = FilterParser.ParseFilter("""Time == "00:00:03.123456" """); var comparison = (BinaryExpression)filterExpression.Body; var read = (MemberExpression)comparison.Right; diff --git a/QueryKit/Configuration/IQueryKitFilterBehavior.cs b/QueryKit/Configuration/IQueryKitFilterBehavior.cs index ff3e842..b5fcb2c 100644 --- a/QueryKit/Configuration/IQueryKitFilterBehavior.cs +++ b/QueryKit/Configuration/IQueryKitFilterBehavior.cs @@ -2,7 +2,7 @@ namespace QueryKit.Configuration; /// /// The settings that control how the filter parser treats constant values and ignored clauses. A configuration -/// that does not implement this interface uses the defaults: values sent as constants, and +/// that does not implement this interface uses the defaults: values sent as parameters, and /// for an ignored clause. /// public interface IQueryKitFilterBehavior diff --git a/QueryKit/Configuration/QueryKitSettings.cs b/QueryKit/Configuration/QueryKitSettings.cs index e346083..cf600ce 100644 --- a/QueryKit/Configuration/QueryKitSettings.cs +++ b/QueryKit/Configuration/QueryKitSettings.cs @@ -51,10 +51,10 @@ public class QueryKitSettings 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. + /// When true (the default), filter values are field reads that EF Core sends as SQL parameters. When false, + /// filter values are constants that EF Core writes into the SQL as literals, the same as v1.14.2. /// - public bool ParameterizeFilterValues { get; set; } + public bool ParameterizeFilterValues { get; set; } = true; /// /// What the filter parser does with a clause on a prevented or unknown property. The default is diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 07ead9d..f356292 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -29,7 +29,7 @@ public static Expression> ParseFilter(string input, IQueryKitCo var parameter = Expression.Parameter(typeof(T), "x"); Expression expr; var parameterizeBefore = FilterValue.Parameterize; - FilterValue.Parameterize = config is IQueryKitFilterBehavior { ParameterizeFilterValues: true }; + FilterValue.Parameterize = (config as IQueryKitFilterBehavior)?.ParameterizeFilterValues ?? true; var maxNestingDepthBefore = _maxNestingDepth; var nestingDepthBefore = _nestingDepth; var queryNameOverUnknownBefore = _queryNameOverUnknown; diff --git a/README.md b/README.md index 96f5341..e8e5365 100644 --- a/README.md +++ b/README.md @@ -796,12 +796,12 @@ Setting `MaxPropertyDepth = 0` only allows root-level properties. A `null` value #### Parameterize Filter Values -By default (`ParameterizeFilterValues = false`), QueryKit writes each filter value into the SQL as a literal constant. Set `ParameterizeFilterValues` to `true` to send filter values as SQL parameters instead. Parameters let EF Core reuse one compiled query and one database plan across calls that differ only in their filter values. +By default (`ParameterizeFilterValues = true`), QueryKit sends filter values as SQL parameters. Parameters let EF Core reuse one compiled query and one database plan across calls that differ only in their filter values. Set `ParameterizeFilterValues` to `false` to write each filter value into the SQL as a literal constant, like v1.14.2. ```csharp var config = new QueryKitConfiguration(config => { - config.ParameterizeFilterValues = true; + config.ParameterizeFilterValues = false; }); var filterExpression = FilterParser.ParseFilter(input, config); ```