Skip to content

fix(filter)!: resolve query names in the grammar again - #154

Open
pdevito3 wants to merge 1 commit into
mainfrom
fm/qk-breaking-query-names
Open

pdevito3 wants to merge 1 commit into
mainfrom
fm/qk-breaking-query-names

Conversation

@pdevito3

@pdevito3 pdevito3 commented Oct 1, 2026 •

Copy link
Copy Markdown
Owner

For later consideration in a major version. Do not merge now. #134 restored the v1.14.2 behavior to keep v1.x compatible. This PR re-applies the query-name behavior of 3c85253 (#113). The captain decides on this PR separately.

This PR combines two items from #134:

  • J5 (restore commit f0f48be): QueryKit replaced query names in the filter text before the parse.
  • J-bis (restore commit 5e7d918): QueryKit resolved a query name only in front of an operator.

J5 and J-bis stay in one PR. They can not be separated, because they change the same code:

  • J5 removes the text rewrite. Without the rewrite, the left side of a comparison must read query names in the grammar (PropertyPathParser) and map them to a property path (PropertyResolver.Resolve).
  • J-bis uses the same PropertyPathParser and the same query-name mapping in property lists and in arithmetic.
  • Two PRs would each add PropertyPathParser, QueryName, and the query-name lookups. After one PR merges, the other PR conflicts in all of this code.

QN1 (a query name after a dot)

This PR owns QN1. A filter such as Author.name == "Ann" (query name name on Title) is not rewritten to Author.Title anymore. QN1 can not be separated from this PR, because QN1 comes from the text rewrite that J5 removes.

#127 now changes only the result of the public method QueryKitPropertyMappings.ReplaceAliasesWithPropertyPaths. It does not change filters.

Summary

  • ParseFilter no longer rewrites query names in the filter text before the parse. The operator and logical alias passes stay.
  • PropertyPathParser(config) comes back. It reads a configured query name first (longest first), then a normal identifier path. The left side of a comparison and a property list use this parser.
  • PropertyResolver.Resolve maps a query name to the property path of its mapping before the depth check and the member lookup.
  • A query name in an arithmetic expression maps to its property path (ResolveArithmeticQueryNames).
  • QueryKitPropertyMappings.PropertyQueryNames and DerivedOrCustomOperationQueryNames (internal) hold the query names that the grammar reads.
  • The property-list check finds a PreventFilter setting by the query name first, then by the property path. This closes a bypass that the new property-list support opens (see Security).
  • A property with PreventFilter() and PreventSort() still throws InvalidOperationException by its query name in front of an operator. EnsureNoQueryNameOfAPropertyPreventedForFilterAndSort keeps this v1.14.2 throw. fix(filter)!: ignore a fully prevented clause by its query name #152 is the PR that removes the throw.

The parser from bc70b3a (derived-property and custom-operation query names with a hyphen or a space) is now part of PropertyPathParser. Its tests stay and pass. bc70b3a has no PR of its own.

v1.14.2 behavior (and main)

QueryKit replaces each query name in front of an operator with its property path. This replacement runs on the raw filter text, before the parse. As a result:

  • A query name inside a quoted value changes. Title == "first == x" compares Title with FirstName == x.
  • A query name in a property list throws UnknownFilterPropertyException.
  • A query name in arithmetic throws ArgumentException ("Property 'stars' not found on type 'TestingPerson'").
  • A query name after a dot changes too. Author.name == "Lee" (query name name on Title) becomes Author.Title and throws UnknownFilterPropertyException: 'Title'.
  • A filter that fails after a property query name, where no comparison operator follows the query name, throws UnknownFilterPropertyException for the query name.

New behavior

The parser reads a query name as a property name. The resolver maps it to its property path.

  • Text inside a quoted value does not change.
  • A query name works in a property list and in arithmetic.
  • Author.name == "Lee" gives x => (x.Author.Name == "Lee"), like main before fix: restore v1.14.2 behavior for every breaking change on main #134.
  • A property list skips a prevented property by its query name, and by its name in another letter case. Every configured property has its name as its default query name, and the query-name lookup ignores the letter case.

Example

HasQueryName("first") on FirstName, and HasQueryName("stars") on Rating:

Input v1.14.2 and main This PR
Title == "first == x" x => (x.Title == "FirstName == x") x => (x.Title == "first == x")
(first, LastName) == "Paul" UnknownFilterPropertyException: 'first' x => ((x.FirstName == "Paul") OrElse (x.LastName == "Paul"))
(stars + 0) > 3 ArgumentException: Property 'stars' not found on type 'TestingPerson' x => ((x.Rating + Convert(0, Nullable`1)) > Convert(3, Nullable`1))

Some inputs that fail on main and on this PR throw a different exception type. HasQueryName("years") on Age, and a derived property with HasQueryName("double age"):

Input v1.14.2 and main This PR
years + 1 > 3 UnknownFilterPropertyException: The filter property 'years' was not recognized. ParsingException (like Age + 1 > 3 on all trees)
first eq "x" (no eq alias) UnknownFilterPropertyException: The filter property 'first' was not recognized. ParsingException
(double age, Age) > 6 UnknownFilterPropertyException: The filter property 'double' was not recognized. ParsingException

The cause: the grammar now reads the query name as a property. As a result, the parse fails at the next token, and not at the query name.

These inputs give the same result on main and on this PR:

  • first == "x", first eq "x" with an eq operator alias, and Title == "x" with HasQueryName("Title") on FirstName.
  • hidden == "x", with HasQueryName("hidden").PreventFilter().PreventSort() on FirstName: InvalidOperationException.

Justification

The filter text is a grammar. A text replacement before the parse can not know if a word is a property, a value, or a part of a path. As a result, v1.14.2 changes user data inside quoted values, and a query name works in some positions only. This PR gives one rule: a query name is a property name in each position where a property can occur.

Security

These notes compare the risk on v1.14.2 (and main) with this PR.

  • Property list, case. On v1.14.2, the property-list check finds a PreventFilter setting by the exact property path only. (title, FirstName) == "x", with PreventFilter() on Title, gives x => ((x.Title == "x") OrElse (x.FirstName == "x")). The client filters on the prevented property. On this PR, the result is x => (x.FirstName == "x").
  • Property list, query name. Without the lookup by query name, the new property-list support opens a bypass. (hidden, Title) == "x", with HasQueryName("hidden").PreventFilter() on FirstName, filters on FirstName. This PR skips the clause and gives x => (x.Title == "x"). Main throws UnknownFilterPropertyException for this input.
  • Arithmetic. On v1.14.2 and main, arithmetic does not check PreventFilter. (Rating + 0) > 3 filters on a prevented Rating. This PR adds the query name as one more name for this known gap: (stars + 0) > 3 filters on a prevented Rating too. fix(filter)!: apply PreventFilter and PreventSort to every property path #136 closes the gap for both names.

Migration

  • If your code catches UnknownFilterPropertyException for a failed filter that starts with a query name, also catch ParsingException. The examples are in the second table above.
  • If your filter puts a query name inside a quoted value and expects the property path there, write the property path in the value.
  • If your code catches UnknownFilterPropertyException or ArgumentException for a query name in a property list or in arithmetic, this exception no longer occurs. The query name filters by its property.
  • If a client sends a property list with a prevented property in another letter case, QueryKit now skips that property. IgnoredClauseBehavior controls the skipped part.
  • If your code relies on the rewrite of a query name after a dot (Author.name), use the member path. The resolver maps a full query name only.

README

The Property Settings section gets a new paragraph. It says that a query name works on the left side of a comparison, in a property list, and in an arithmetic expression. It also says that QueryKit does not change text inside a quoted value.

Rebase on main

This PR is rebased on current main, after the restore PRs #155 to #160 and after #167 to #169. The rebase keeps the v1.14.2 results of these restores:

Interaction with other PRs

Tests

Unit (QueryKit.UnitTests/PropertyResolverTests.cs), from main before #134:

  • query_name_in_a_value_is_replaced becomes query_name_in_a_value_is_not_replaced again.
  • query_name_with_a_hyphen_in_a_value_is_replaced becomes query_name_with_a_hyphen_in_a_value_is_not_replaced again.
  • query_name_in_a_property_list_throws becomes query_name_in_a_property_list_resolves_to_its_property again.
  • query_name_with_a_hyphen_in_a_property_list_throws becomes query_name_with_a_hyphen_in_a_property_list_resolves_to_its_property again.
  • query_name_in_arithmetic_throws becomes query_name_in_arithmetic_resolves_to_its_property again.
  • prevented_property_in_a_list_in_another_case_is_still_filtered becomes prevented_property_in_a_list_is_skipped_in_any_case again. It expects x => (x.FirstName == "x").
  • The pin query_name_of_a_prevented_property_in_a_property_list_throws becomes query_name_of_a_prevented_property_in_a_property_list_is_skipped. It expects x => (x.Title == "x").
  • The pin query_name_with_a_hyphen_before_an_operator_alias_filters_by_its_property and the bc70b3a tests stay unchanged and pass.

Integration (QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs): query_name_in_a_property_list_is_filtered and query_name_in_arithmetic_is_filtered come back.

New unit tests: query_name_in_arithmetic_without_parentheses_throws_like_its_property (years + 1 > 3) and query_name_matches_with_the_case_rules_of_tr_tr (3 cases).

dotnet build: 0 errors. dotnet test on the rebased branch: 474 unit tests and 299 Postgres integration tests (Testcontainers) pass, 0 failures.

@pdevito3
pdevito3 force-pushed the fm/qk-breaking-query-names branch from e5dbe6b to b8b4ae9 Compare October 1, 2026 20:59
pdevito3 added a commit that referenced this pull request Oct 1, 2026
The public method ReplaceAliasesWithPropertyPaths does not replace a query
name after a dot anymore. A query name after a dot is a segment of a nested
path of another type. For example, with the query name "name" on Title,
"Author.Name" stays "Author.Name".

The filter parser calls an internal overload that keeps the v1.14.2 match.
Filters do not change. #154 owns
the filter change for a query name after a dot.

BREAKING CHANGE: ReplaceAliasesWithPropertyPaths keeps a query name after a
dot. It also does not throw InvalidOperationException for a fully prevented
query name after a dot.
QueryKit no longer replaces query names in the filter text before the parse. The grammar reads a query name as a property, and the property resolver maps it to its property path. As a result, a query name works in a property list and in arithmetic, and text inside a quoted value does not change.

A property query name matches with the case rules of the current culture, like the alias regex of v1.14.2. A derived property or custom operation query name still ignores case with the invariant rules. When the grammar reads such a query name where v1.14.2 read an unknown identifier path, a filter that fails still throws the v1.14.2 exception.

A property list skips a property that has PreventFilter by its query name too. Every configured property has its name as its default query name, so a property list also skips a prevented property in another letter case.

A property that has PreventFilter and PreventSort still throws InvalidOperationException by its query name in front of an operator.

BREAKING CHANGE: a query name inside a quoted value is not replaced anymore. A query name after a dot is not replaced anymore. A query name in a property list or in arithmetic resolves to its property instead of throwing. A property list skips a prevented property in another letter case.
@pdevito3
pdevito3 force-pushed the fm/qk-breaking-query-names branch from b8b4ae9 to 8b21edc Compare October 1, 2026 21:44
pdevito3 added a commit that referenced this pull request Oct 1, 2026
The public method ReplaceAliasesWithPropertyPaths does not replace a query
name after a dot anymore. A query name after a dot is a segment of a nested
path of another type. For example, with the query name "name" on Title,
"Author.Name" stays "Author.Name".

The filter parser calls an internal overload that keeps the v1.14.2 match.
Filters do not change. #154 owns
the filter change for a query name after a dot.

BREAKING CHANGE: ReplaceAliasesWithPropertyPaths keeps a query name after a
dot. It also does not throw InvalidOperationException for a fully prevented
query name after a dot.
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