Skip to content

fix(filter): resolve every property reference with one resolver - #113

Merged
pdevito3 merged 14 commits into
mainfrom
fm/qk-property-resolver
Sep 29, 2026
Merged

pdevito3 merged 14 commits into
mainfrom
fm/qk-property-resolver

Conversation

@pdevito3

Copy link
Copy Markdown
Owner

Summary

This PR adds one resolver for every property reference in a filter or a sort. The resolver maps a query name to its member path, matches member names without case, checks the depth, and gives the property settings. Before this PR, each part of the parser had its own lookup, so the lookups did not agree. As a result, PreventFilter(), PreventSort(), query names, and HasMaxDepth() did not apply in all positions.

Each commit fixes one finding and adds regression tests. Unit and integration tests pass at every commit.

Commits

# Commit Finding
1 d4e2cb3 test(filter): use fixed or unique data in tests that failed at random Six tests that failed at random.
2 89d17cb refactor(filter): resolve filter property references in one place No behavior change. All scenarios are the same as on origin/main.
3 91debe7 fix(filter): remove unknown and prevented clauses instead of using true An unknown or prevented clause under `
4 797ff0f fix(filter): check permission and depth for properties in arithmetic PreventFilter() and MaxPropertyDepth did not apply in arithmetic.
5 6140275 fix(filter): check permission for a property on the right side PreventFilter() did not apply to a property on the right side.
6 5008c93 fix(filter): look up property settings by the resolved member path PreventFilter() did not apply when the case was different, for example title.
7 d5f27a0 fix(sort): look up the sort permission by the resolved member path PreventSort() did not apply when the case was different.
8 3b05ab4 fix(filter): apply prevent settings to derived properties and custom operations PreventFilter() and PreventSort() had no effect on derived properties and custom operations.
9 9f3b973 fix(filter): resolve query names in property lists and arithmetic A query name did not resolve in a property list or in arithmetic. The regex also replaced a query name inside Author.Name.
10 459544e fix(filter): keep a query name in a nested path in alias replacement ReplaceAliasesWithPropertyPaths replaced a query name after a dot. Unit test proof only, because the parser does not call it now.
11 ff9b5c3 fix(filter): accept a nested property path on the right side A nested path on the right side, for example Rating > Author.Score, was a ParsingException.
12 64f7487 fix(config): apply a property max depth only to its own path HasMaxDepth() on Author also applied to AuthorNote.
13 50a1631 fix(filter): treat an unknown property in arithmetic as an unknown property An unknown property in arithmetic threw ArgumentException, also with AllowUnknownProperties.
14 b21b41a fix(filter): remove a prevented clause that uses its query name A prevented property threw when the filter used its query name. The dead OrderBy(x => x) fallback is deleted.

Behavior changes

  • An unknown property (with AllowUnknownProperties) or a prevented property now removes its clause. Before, the clause became true. Under ||, a true clause returned every row.
  • A prevented property is now removed in every position: left side, right side, arithmetic, property lists, derived properties, and custom operations. It is also removed when the filter writes it with a different case or with its query name.
  • A property with both PreventFilter() and PreventSort() no longer throws InvalidOperationException when the filter uses its query name. The parser removes the clause, the same as for the member name. A prevented sort stays ignored. Feature request: setting to ignore or throw for filters and sorts on prevented properties #112 asks for a setting to throw instead.
  • The right side of a comparison can now be a nested property path, for example Rating > Author.Score. Before, this was a ParsingException.
  • HasMaxDepth() on Author no longer applies to AuthorNote. The depth check now matches a full path segment, not a string prefix.
  • An unknown property in arithmetic no longer throws ArgumentException. With AllowUnknownProperties, the parser removes the clause. Without it, the parser throws UnknownFilterPropertyException. A derived property or a custom operation in arithmetic gets the same result, because arithmetic supports only members.

Notes for review

  • An unquoted dotted value that is not a property, for example Title == a.b, is now a string value. Before, it was a ParsingException. A single unquoted word already had this behavior. Next major version: decide behavior for an unquoted string value that is not a property #111 tracks the decision for the next major version.
  • When every clause of a filter is removed, the filter is x => True. This matches the old behavior for a removed clause at the top level.
  • The public ReplaceAliasesWithPropertyPaths method still throws for a property with both settings. The parser does not call it any more. Its regex fix (a query name after a dot is not replaced) has a unit test.
  • Exception types that change: ArgumentException becomes UnknownFilterPropertyException in arithmetic. ParsingException becomes QueryKitPropertyDepthExceededException for a right-side path that is too deep. InvalidOperationException (inside ParsingException) is removed for a prevented query name.

Flaky tests

The first commit fixes six tests that failed at random:

  • Three unit tests used a random Lorem word as an unknown property. The Lorem word list contains "id", which matches TestingPerson.Id. The tests now use fixed names.
  • Three integration tests filtered on a random value in a database that other tests share. Another row with the same value made them fail. The tests now use a unique value.

Without this commit, can_filter_by_string_for_nested_collection failed at random on an unrelated commit in this PR.

Proof with verify-querykit

Each finding was reproduced with the verify-querykit skill against the in-memory provider and Postgres, before and after its commit. Memory and Postgres agreed in every run.

The harness model has these additions for this proof. They are not part of this PR:

  • Author.Score (Julia 4, Gordon 3, Anonymous 0) and an owned AuthorNote.Text.
  • The seed recipes are Pancakes (Julia, rating 5), Beef Stew (Gordon, 3), Salt Bread (Julia, 4), and Plain Water (Anonymous, 1).

Configuration presets:

Preset Settings
none No configuration.
allow-unknown AllowUnknownProperties = true.
max-depth-0 MaxPropertyDepth = 0.
prevent Title has query name t, PreventFilter(), and PreventSort(). Rating has PreventFilter().
prevent-derived Derived property headline with PreventFilter() and PreventSort(). Custom operation total_stock_above with PreventFilter().
numeric-alias Query names: Rating as stars, Author.Score as score, Title as name.
aliases Query names: Title as name, Author.Name as chef. Rating has PreventFilter(). Price has PreventSort().
right-path-prevent Author.Score has PreventFilter().
max-depth-prefix MaxPropertyDepth = 0. Author has HasMaxDepth(1). AuthorNote has no override.

Results, from origin/main to the commit that changed each scenario. "Rows" is the recipe titles from memory and Postgres, which were the same.

Scenario Command origin/main After Changed at
unknown-or-clause --filter 'Nope == "x" || Rating > 100' --config allow-unknown [Pancakes, Beef Stew, Salt Bread, Plain Water] [] (no rows) 91debe7
unknown-and-clause --filter 'Nope == "x" && Rating > 3' --config allow-unknown [Pancakes, Salt Bread] [Pancakes, Salt Bread] no change (control)
prevented-or-clause --filter 'Rating == 1 || Price > 100' --config prevent [Pancakes, Beef Stew, Salt Bread, Plain Water] [] (no rows) 91debe7
arith-prevented --filter '(Rating + 0) > 3' --config prevent [Pancakes, Salt Bread] [Pancakes, Beef Stew, Salt Bread, Plain Water] 797ff0f
arith-depth --filter '(Author.Score + 0) > 1' --config max-depth-0 [Pancakes, Beef Stew, Salt Bread] QueryKitPropertyDepthExceededException 797ff0f
right-prevented --filter 'Price > Rating' --config prevent [Beef Stew] [Pancakes, Beef Stew, Salt Bread, Plain Water] 6140275
list-case --filter '(title, Directions) @=* "salt"' --config prevent [Salt Bread] [] (no rows) 5008c93
alias-case-filter --filter 'title == "Pancakes"' --config prevent [Pancakes] [Pancakes, Beef Stew, Salt Bread, Plain Water] 5008c93
alias-case-sort --sort 'title desc' --config prevent [Salt Bread, Plain Water, Pancakes, Beef Stew] [Pancakes, Beef Stew, Salt Bread, Plain Water] d5f27a0
derived-prevent-filter --filter 'headline @=* "Julia"' --config prevent-derived [Pancakes, Salt Bread] [Pancakes, Beef Stew, Salt Bread, Plain Water] 3b05ab4
derived-prevent-sort --sort 'headline desc' --config prevent-derived [Salt Bread, Plain Water, Pancakes, Beef Stew] [Pancakes, Beef Stew, Salt Bread, Plain Water] 3b05ab4
customop-prevent --filter 'total_stock_above > 20' --config prevent-derived [Beef Stew] [Pancakes, Beef Stew, Salt Bread, Plain Water] 3b05ab4
alias-list --filter '(name, Directions) @=* "salt"' --config numeric-alias UnknownFilterPropertyException [Salt Bread] 9f3b973
alias-arith --filter '(stars + 0) > 3' --config numeric-alias ArgumentException [Pancakes, Salt Bread] 9f3b973
alias-arith-nested --filter '(score + 0) > 3' --config numeric-alias ArgumentException [Pancakes, Salt Bread] 9f3b973
alias-nested-segment --filter 'Author.Name == "Julia Child"' --config aliases UnknownFilterPropertyException [Pancakes, Salt Bread] 9f3b973
right-path --filter 'Rating > Author.Score' ParsingException [Pancakes, Plain Water] ff9b5c3
right-path-depth --filter 'Rating > Author.Score' --config max-depth-0 ParsingException QueryKitPropertyDepthExceededException ff9b5c3
right-path-prevented --filter 'Rating > Author.Score || Price > 100' --config right-path-prevent ParsingException [] (no rows) ff9b5c3
maxdepth-prefix --filter 'AuthorNote.Text == "note 1"' --config max-depth-prefix [Pancakes] QueryKitPropertyDepthExceededException 64f7487
maxdepth-prefix-owner --filter 'Author.Name == "Julia Child"' --config max-depth-prefix [Pancakes, Salt Bread] [Pancakes, Salt Bread] no change (control)
arith-unknown --filter '(Nope + 1) > 3 || Title == "Pancakes"' --config allow-unknown ArgumentException [Pancakes] 50a1631
arith-unknown-strict --filter '(Nope + 1) > 3 || Title == "Pancakes"' --config none ArgumentException UnknownFilterPropertyException 50a1631
control-prevented-exact --filter 'Title == "Pancakes"' --config prevent [Pancakes, Beef Stew, Salt Bread, Plain Water] [Pancakes, Beef Stew, Salt Bread, Plain Water] no change (control)
control-prevented-alias --filter 't == "Pancakes"' --config prevent InvalidOperationException [Pancakes, Beef Stew, Salt Bread, Plain Water] 9f3b973, b21b41a
control-prevented-alias-or --filter 't == "Pancakes" || Price > 100' --config prevent InvalidOperationException [] (no rows) 9f3b973, b21b41a
control-prevented-sort-exact --sort 'Title desc' --config prevent [Pancakes, Beef Stew, Salt Bread, Plain Water] [Pancakes, Beef Stew, Salt Bread, Plain Water] no change (control)
control-prevented-rating --filter 'Rating > 3' --config prevent [Pancakes, Beef Stew, Salt Bread, Plain Water] [Pancakes, Beef Stew, Salt Bread, Plain Water] no change (control)

control-prevented-alias and control-prevented-alias-or changed twice. At 9f3b973, the InvalidOperationException came inside a ParsingException. At b21b41a, the parser removes the clause.

HasMaxDepth() has no integration test, because no entity in the test database has two navigations where one name starts with the other. The harness gives the Postgres proof.

Out of scope

No version change.

Three unit tests used a random Lorem word as an unknown property. The Lorem word list contains "id", which matches TestingPerson.Id, so the tests failed at random. The tests now use fixed names.

Three integration tests filtered on a random state name or a random preparation text in a database that other tests share. Another row with the same value made the tests fail at random. The tests now use a unique value.
Add PropertyResolver. It resolves a property reference to a member path, a derived property, a custom operation, or an unknown property. The left side of a comparison and the property list now use it, and they share one builder for member expressions.

Remove CreatePropertyExpressionFromPath, which was a copy of the left-side walker, and the custom operation placeholder string. Remove the custom operation branch in the property list parser, because it could not run. The builder uses the resolved member names, so a collection element member in a different case no longer throws a NullReferenceException.
A clause on an unknown property (with AllowUnknownProperties) or on a prevented property became the constant true. Under an OR operator, this made the full filter true. For example, Nope == "x" || Age > 100 returned every row.

The parser now removes the clause. A logical operator with a removed side becomes its other side. When the parser removes every clause, the filter is x => True.
Arithmetic comparisons now resolve each property through the property resolver. A property with PreventFilter removes the clause, and MaxPropertyDepth applies to the property path.
A comparison such as FirstName == Title did not check PreventFilter on Title. A prevented property on the right side now removes the clause.
The left side and the property list looked up the property settings with the text as written. The lookup is case-sensitive, so "title" skipped PreventFilter on Title. The parser now uses the settings of the resolved member path.
When a property had a query name, a sort by its member name in another case, such as "title desc", skipped PreventSort. The sort parser now resolves the property first and uses the settings of the member.
…operations

PreventFilter on a derived property or a custom operation, and PreventSort on a derived property, had no effect. The filter now removes the clause, and the sort skips the property.
The filter parser replaced query names with a regex pass over the whole filter text. That pass found only a query name before a comparison operator, so a query name in a property list or in arithmetic stayed unknown. It also replaced text inside quoted values and inside nested paths such as Author.Name.

The property resolver now resolves the query name first for every property reference. A property that can not be filtered or sorted is still rejected by its query name. The InvalidOperationException now comes inside a ParsingException.
ReplaceAliasesWithPropertyPaths replaced a query name that was a segment of a nested path. For example, with Title named "name", Author.Name == "x" became Author.Title == "x". The method now skips a query name after a dot.

The filter parser does not use this method now. The method stays because it is public.
The right side of a comparison accepted only one identifier. A filter such as Rating > Author.Score, which the README shows, failed with a ParsingException. The right side now accepts a path with dots. The path gets the same permission and depth checks as other property references.
GetMaxDepthForProperty matched any path that started with the property name. For example, HasMaxDepth on Author also applied to AuthorNote.Text, so that path skipped the global MaxPropertyDepth. The lookup now matches only the property itself and the paths below it.
…operty

An unknown property in arithmetic threw ArgumentException, also with AllowUnknownProperties. Now the parser removes the clause when AllowUnknownProperties is true, and throws UnknownFilterPropertyException when it is false.

Arithmetic supports only members. A derived property or a custom operation in arithmetic gets the same result as an unknown property.
A property with PreventFilter and PreventSort threw InvalidOperationException when the filter used its query name. With the member name, the parser removed the clause. Now the parser removes the clause for both names, and the rest of the filter still runs.

A prevented sort stays ignored. The OrderBy(x => x) fallback in ApplyQueryKitSort was unreachable, because ParseSort never returns an entry without an expression. This commit deletes it.
@pdevito3
pdevito3 merged commit d54de85 into main Sep 29, 2026
2 checks passed
@pdevito3
pdevito3 deleted the fm/qk-property-resolver branch September 29, 2026 19:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant