perf: parameterize filter values and cache parsers and regexes - #110
Merged
Merged
Conversation
can_filter_on_projections_nested and can_filter_on_child_entity_with_config filter on a random one-word author name. The tests share one database, so another test can insert an author with the same word. Then the filter returns two recipes and the test fails. Use a GUID as the name, the same as can_filter_on_projections_nested_complex.
Each filter value was an Expression.Constant, so EF Core wrote it into the SQL as a literal. Each new value made a new SQL text. EF Core compiled a new query, and Postgres made a new plan, for each value. Put each value in a FilterValue<T> holder and read its field. EF Core treats this read as a captured variable and sends the value as a parameter. The in-lists become one array parameter (= ANY (@value) on Npgsql). Arithmetic literals, dates, enums, bools, and guids are parameters too. Null stays a constant, so the SQL keeps IS NULL. Nullable enum values are now one value of the nullable type. The expression prints January, not new Nullable`1(January). The unit tests print holder reads inline with ToDisplayString().
The tests share one database, so other people with random ids are in the table. A random guid contains "9edb" in about 1 of 2600 cases, so the filter sometimes matched a second person. An 8-character fragment makes a random match improbable.
The Sprache parsers were properties, so each parse built them again. A parser in a second from clause was also built again inside a lambda on each parse. The parsers are now static readonly fields in dependency order. The recursive arithmetic parser still goes through Parse.Ref. The alias replacement built a new Regex for each query name and operator, and for each operator alias, on each parse. A static cache now keeps one Regex for each pattern. The patterns come only from the configuration. Age > 25 went from about 75 us and 167 KB to about 34 us and 55 KB for each parse. A filter with five property aliases went from about 300 us and 781 KB to about 50 us and 103 KB.
This was referenced Sep 29, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This PR contains the two performance findings of the QueryKit review. Each finding is one commit. Two more commits correct flaky integration tests.
perf(filter): send filter values to ef core as parameters. Each filter value was anExpression.Constant, so EF Core wrote the value into the SQL as a literal. Each new value made a new SQL text, a new EF Core query compilation, and a new Postgres plan. Now each value is a field read on a small holder object (FilterValue<T>). EF Core sends a field read on a captured object as a parameter, the same as a C# closure variable.perf(parser): cache sprache parsers and alias regexes. The Sprache parsers were=>properties, so each parse built all of them again. A parser in a secondfromclause was also built again inside a lambda on each parse. The alias replacement built a newRegexfor each query name and each of the 24 operators, and for each operator alias, on each parse. Now the parsers arestatic readonlyfields in dependency order, and a static cache keeps oneRegexfor each pattern.test(integration): give authors unique names in alias filter testsandtest(integration): use a longer guid fragment in the guid contains test. The integration tests share one database. Random author names and random guids sometimes matched a second row, so these tests failed in some runs.1. Filter values are SQL parameters
The
verify-querykitharness ran each filter on the in-memory target and on Postgres, before the fix and after the fix (labelsperf-param-*andperf-param-*-fixed). The titles are the same on both targets before and after. The Postgres SQL changed as follows:r."Title" = 'Pancakes'r."Title" = @Valuer."Rating" > 3r."Rating" > @Valuer."Title" IN ('Pancakes', 'Salt Bread')r."Title" = ANY (@Value)lower(r."Title") IN ('pancakes', 'salt bread')lower(r."Title") = ANY (@Value)lower(r."Title") LIKE '%salt%' AND r."Price" < 10.5lower(r."Title") LIKE @ToLower_contains AND r."Price" < @Valuer."CreatedAt" > TIMESTAMPTZ '2024-02-01T00:00:00Z' AND r."DateOfOrigin" < DATE '1960-01-01'r."CreatedAt" > @Value AND r."DateOfOrigin" < @Value0r."IsVegetarian" AND r."Visibility" = 2r."IsVegetarian" = @Value AND r."Visibility" = @Value0i."Name" = 'salt',i0."Stock" > 4i."Name" = @Value,i0."Stock" > @Value0cardinality(r."Tags") > 1 AND 'bread' = ANY (r."Tags")cardinality(r."Tags") > @Value AND @Value0 = ANY (r."Tags")r."Rating" * 2 > 7r."Rating" * @Value > @Value0Notes:
nullvalue stays a constant, so the SQL still usesIS NULL.^^list is one array parameter (= ANY (@Value)), so lists of different lengths share one SQL text.Expression.ToString()output. A field read prints asvalue(QueryKit.FilterValue1[...]).Value, so the newToDisplayString()test helper puts the values back inline. One expectation changed fromx.BirthMonth == new Nullable1(January)tox.BirthMonth == January, because the helper prints a nullable enum value directly.Regression tests:
FilterParameterTests(integration): 15 pairs of filters that differ only in the value must give the same SQL text, and the SQL must contain@. One more test makes sure that^^*uses= ANY (@and returns the correct rows. Without the fix, all 16 tests fail.FilterValueParameterTests(unit): each value in the expression is aFilterValue<T>read, the value keeps its type, andnullstays a constant.2. Parsers and alias regexes are built once
A static field can only use the fields that are declared above it, so the fields are in dependency order. The recursive arithmetic parser still goes through
Parse.Ref.LogicalOperatorParseris public, so it stays a property ({ get; } =) for binary compatibility.RawStringLiteralParserstill builds its inner parser on each parse, because that parser depends on the number of opening quotes.The regex cache is keyed by the full pattern string. The patterns come only from the configuration (query names and operator aliases), never from the filter text, so the number of entries stays small. The static
Regexmethods have a built-in cache of only 15 patterns, and five aliases with 24 operators need 120 patterns.Parse time and allocations for each parse, from a Release benchmark on .NET 10 (2000 warm-up parses, then 20000 measured parses):
Age > 25eq,gt,and)The harness ran six filters before and after the change (labels
perf-cache-*andperf-cache-*-fixed): a simple filter, nested groups with arithmetic, dates with a list and a guid, property aliases, word operators, and a bad input. The titles and the Postgres SQL are the same before and after on both targets. The bad inputTitle ==gives the sameParsingExceptionmessage: "Unexpected end of input reached; expected null or numeric character or . or " or [ or letter (Line 1, Column 10)".Regression test:
FilterParserAllocationTests(unit) measures the bytes that one parse allocates. Each budget is about two times the bytes after the fix. Without the fix, all three tests fail:3. Flaky integration tests
can_filter_on_projections_nestedandcan_filter_on_child_entity_with_configused a random one-word author name. Another test sometimes made an author with the same name. The tests now use a guid as the author name.can_filter_by_guid_containsfiltered on"9edb". A random guid contains these 4 characters in about 1 of 2600 cases, and a run inserts hundreds of people. The test failed in 1 of 5 runs with "Expected people.Count to be 1, but found 2". The test now uses"9edb-a3ec", the same form ascan_filter_by_nullable_guid_contains.Test results
Each commit passes both suites. At the last commit,
QueryKit.UnitTestshas 227 passed and 2 skipped, andQueryKit.IntegrationTestshas 228 passed and 1 skipped.Related work
The dos-limits, property-resolver, and parser-conversion-bugs PRs also change
FilterParser.csandQueryKitPropertyMappings.cs. When one of them merges first, this branch needs a rebase that keeps both changes.