Skip to content

fix: restore v1.14.2 behavior for every breaking change on main - #134

Merged
pdevito3 merged 24 commits into
mainfrom
fm/qk-restore-all
Oct 1, 2026
Merged

pdevito3 merged 24 commits into
mainfrom
fm/qk-restore-all

Conversation

@pdevito3

Copy link
Copy Markdown
Owner

Summary

Main must have no breaking change against v1.14.2. This PR adds restore commits on top of c345c53. It does not rewrite history.

The rule for a break: an input that v1.14.2 accepted gives a different result (expression text, SQL, value, or exception type, or a throw compared with no throw on in-memory evaluation). A change is not a break if it only makes an input work that v1.14.2 rejected with an exception.

Each restored item has one commit. A later per-change PR can re-apply each item by itself: revert the restore commit and bring back the tests in the test ledger below.

The captain accepted that main gets the old security gaps back (B, I, M, P). The next major release closes them again.

The merge needs the captain's word.

Restored items from the audit

B: parse limits are off by default (42866b9)

  • Restores: de350f7 (fix(parser): limit filter nesting depth and length, plus small fixes and doc cleanups #109).
  • What went back: v1.14.2 parsed a filter of any length and any nesting depth. DefaultMaxInputLength and DefaultMaxNestingDepth are int.MaxValue now. MaxInputLength and MaxNestingDepth stay as an opt-in.
  • Security consequence: a filter with a few thousand nested parentheses crashes the host process with a stack overflow, and a catch cannot stop it. At depth 1000, one parse takes more than 20 seconds of CPU. Every app that sends user input to QueryKit and does not set the limits is exposed again.

I: PreventFilter and PreventSort bypasses are open again (2f33f60)

  • Restores: 988fdff, 2781423, 630d080, 1f40773, b350bf8 (fix(filter): resolve every property reference with one resolver #113).
  • What went back: v1.14.2 checked PreventFilter only for a left-side member, by its name in the exact case after the query-name rewrite. It did not check arithmetic, the right side, another case in a property list, derived properties, or custom operations. PreventSort was checked by the typed path in the exact case.
  • Security consequence: the six bypasses I1 to I6 are open again. A caller can learn the value of a prevented field one comparison at a time, for example (Salary + 0) > 50000, then > 75000. A sort bypass shows the order of the hidden values.

M: a per-property max depth matches by path prefix (a947ebb)

P: MaxPropertyDepth does not apply to arithmetic (8a09c25)

F: HasConversion lookup by query name (76d3a7d)

G: child collection member in the exact case (0f1d864)

J5: query names are replaced before the parse (f0f48be)

J-bis: query names only in front of an operator (5e7d918)

  • Restores: the J-bis part of 3c85253 (fix(filter): resolve every property reference with one resolver #113).
  • What went back: a query name in a property list throws UnknownFilterPropertyException. A query name in arithmetic throws ArgumentException.
  • The J regression fix stays: a query name with a hyphen, a leading underscore, or a space works in front of an operator.

K: fully prevented query name throws (c0e9914)

L: one word on the right side (2b1c252)

O: unknown property in arithmetic (a3a4d54)

T: TimeOnly literals on net6.0 (7653edb)

Restored items found after the audit

The audit compared v1.14.2 with main at d54de85. These commits came later, or the main-verify scout found them. Each one restores only the break part. The throw-to-works part stays and is listed below.

Restore commit Restores What went back
f5209e5 39312a1 The operator-alias rewrite before the parse, in the v1.14.2 order. Title eq "salt and pepper" compares with "salt && pepper" again.
01e8b50 c90861c A list value splits on every comma, also inside a quoted item.
7198ac2 ea5cc66 A DateTimeOffset literal keeps its offset. The value goes to UTC only when ParameterizeFilterValues is on.
b49ad2b 5c84ef6 (break parts) A zone before the fraction (2024-01-15T08:00:00Z.5) parses again. A quoted time keeps the v1.14.2 fraction rule: milliseconds need 3 digits, microseconds need 6.
0abd1f8 aff9638 No null check in the case-sensitive @=, _=, _-= and their negations. In memory, a null property throws NullReferenceException. Postgres results do not change.
0761e53 c948532 The public ComparisonOperator factories ignore usesAll.
e6d55b3 the list read that 5db8c4b (#110) removed InOperator(true) and NotInOperator(true) read a list that a caller passes as a ConstantExpression.
19125d1 the Obsolete mark of d507c87 ArithmeticOperator.FromSymbol has no Obsolete attribute. A consumer with warnings as errors does not get CS0618.
22798f0 the message of de350f7 (#109) has on a property that is not a collection throws DoesNotHaveType is only supported for collections again.

Design choice in b49ad2b: a quoted time uses the v1.14.2 fraction rule. An unquoted time fraction keeps the full fraction, because v1.14.2 rejected an unquoted fraction.

QN1: a v1.14.2 behavior that main had changed

Author.name == "Lee" with query name name on Title: v1.14.2 rewrites it to Author.Title and throws UnknownFilterPropertyException: 'Title'. Main gave x => (x.Author.Name == "Lee"). The branch throws like v1.14.2 again, through the rewrite before the parse (f0f48be, f5209e5).

Kept: v1.14.2 threw, now works

Change Evidence (v1.14.2 compared with this branch)
e032af7 a . decimal point in every culture de-DE Rating > 4.4: ParsingException, now x => (x.Rating > 4,4)
52556cd int and decimal equality Age == Rating: ParsingException, now x => (Convert(x.Age, Decimal) == x.Rating)
67bb158 sort direction after more than one space sort Age desc: ArgumentException, now sorts 4,3
39312a1 an alias that the rewrite does not replace (Age)eq 3 with alias eq: ParsingException, now x => (x.Age == 3)
5c84ef6 fraction before the zone At == 2024-01-15T08:00:00.500Z: ParsingException, now works
5c84ef6 fraction before an offset At == 2024-01-15T10:00:00.5+02:00: ParsingException, now works
5c84ef6 7 fraction digits At > 2024-01-15T07:00:00.1234567: ParsingException, now works
5c84ef6 unquoted time fraction T == 08:30:00.5: ParsingException, now new TimeOnly(8, 30, 0, 500, 0)
5c84ef6 time list T ^^ [08:30:00.5]: ParsingException, now works
QN3 derived property query name that is not an identifier (bc70b3a) full-name == "Ann Lee": UnknownFilterPropertyException: 'full', now x => (((x.FirstName + " ") + x.Title) == "Ann Lee")
QN4 custom operation query name that is not an identifier (bc70b3a) adult-ish == true: UnknownFilterPropertyException: 'adult', now calls the custom operation

bc70b3a reads the identifier path first, like v1.14.2. It tries the derived-property and custom-operation query names only when the path is not a property and AllowUnknownProperties is off, or when the path does not parse. In both cases v1.14.2 threw. The probe cases FirstName with query name first, Title with query name Title, Age!= 20 with query name age!, and foo!= 20 with AllowUnknownProperties give the same result as v1.14.2.

Kept known differences

  • fix(operators): reuse compiled queries for equal constant in-lists #133 (NEW-1): the in-list constant is QueryKit.InListValues`1 in place of List`1. The rows do not change. The 3 v1.14.2 unit tests that assert the type name fail: simple_in_operator_for_nullable_int, simple_in_operator_for_guid, can_have_custom_prop_name_with_in_operator.
  • fix(exceptions): stop leaking internal type names in ParsingException #128: the ParsingException message adds Failed at Line N, Column M. The captain approved this as not breaking. Example: Title eq"Lee" with alias eq throws ParsingException on both. Only the message text is different.
  • Fix non-breaking review findings: docs, tests, and interface #130: kept as it is.
  • The additive opt-in API: IQueryKitFilterBehavior, IQueryKitParseLimits, IgnoredClauseBehavior, ParameterizeFilterValues, MaxInputLength, MaxNestingDepth, QueryKitInputLengthExceededException, QueryKitNestingDepthExceededException. The defaults give the v1.14.2 behavior.
  • A rejected input with a different exception: (full-name, Title) == "Lee" with a derived query name full-name. v1.14.2 throws UnknownFilterPropertyException. The branch throws ParsingException. Both reject the input.
  • The restores of A, C, E, H, and N stay.

Other commits

  • 2fa061a test: can_filter_enumerable gave two fake recipes the same random title sometimes, so the test found two rows. The filtered recipe gets a unique title now. This was a flaky test.
  • 01b06c1 test: 40 property selectors in the test configurations get the null-forgiving operator. The build has no CS8603 warnings now.

Changed or removed tests

A per-change PR can bring back each test on the left side.

Test ledger

B 42866b9

  • ParseLimitsTests.filter_over_default_nesting_depth_throws -> filter_over_33_nesting_levels_parses_by_default + quoted_value_with_33_parentheses_parses_by_default
  • ParseLimitsTests.filter_over_default_input_length_throws -> filter_over_5000_characters_parses_by_default
  • ParseLimitsTests.configuration_that_implements_only_the_interface_uses_the_default_limits -> configuration_that_implements_only_the_interface_has_no_limits

M a947ebb

  • PropertyDepthTests.filter_per_property_max_depth_does_not_apply_to_a_property_that_starts_with_its_name -> filter_per_property_max_depth_applies_to_a_property_that_starts_with_its_name
  • PropertyDepthTests.sort_per_property_max_depth_does_not_apply_to_a_property_that_starts_with_its_name -> sort_per_property_max_depth_applies_to_a_property_that_starts_with_its_name

L 2b1c252

  • Unit PropertyResolverTests.property_path_on_the_right_side_is_compared -> property_path_on_the_right_side_throws
  • Unit PropertyResolverTests.property_path_on_the_right_side_obeys_max_property_depth -> removed (new: unquoted_dotted_word_on_the_right_side_throws)
  • Unit PropertyResolverTests.prevented_property_path_on_the_right_side_removes_the_clause -> removed
  • Integration PropertyResolverTests.property_path_on_the_right_side_is_compared -> removed

T 7653edb

  • none (no net6.0 test runtime)

O a3a4d54

  • Unit PropertyResolverTests.unknown_property_in_arithmetic_removes_the_clause_when_unknown_properties_are_allowed -> unknown_property_in_arithmetic_throws_when_unknown_properties_are_allowed
  • Unit PropertyResolverTests.unknown_property_on_the_right_side_of_arithmetic_removes_the_clause_when_unknown_properties_are_allowed -> ..._throws_when_unknown_properties_are_allowed
  • Unit PropertyResolverTests.unknown_property_in_arithmetic_is_not_recognized -> unknown_property_in_arithmetic_throws_an_argument_exception
  • Integration PropertyResolverTests.unknown_property_in_arithmetic_removes_the_clause_when_unknown_properties_are_allowed -> removed

I

  • Unit PropertyResolverTests.prevented_property_in_arithmetic_is_true_equals_true -> removed
  • Unit PropertyResolverTests.prevented_property_in_arithmetic_removes_the_clause -> prevented_property_in_arithmetic_is_still_filtered
  • Unit PropertyResolverTests.prevented_property_on_the_right_side_of_arithmetic_is_true_equals_true -> removed
  • Unit PropertyResolverTests.prevented_property_on_the_right_side_of_arithmetic_removes_the_clause -> prevented_property_on_the_right_side_of_arithmetic_is_still_filtered
  • Unit PropertyResolverTests.prevented_property_on_the_right_side_is_true_equals_true_when_replaced -> removed
  • Unit PropertyResolverTests.prevented_property_on_the_right_side_removes_the_clause -> prevented_property_on_the_right_side_is_still_compared
  • Unit PropertyResolverTests.prevented_property_on_the_right_side_removes_the_clause_in_any_case -> removed
  • Unit PropertyResolverTests.prevented_property_in_a_list_is_skipped_in_any_case -> prevented_property_in_a_list_in_another_case_is_still_filtered
  • Unit PropertyResolverTests.prevented_property_with_a_query_name_removes_the_clause_when_written_by_its_member_name_in_any_case -> prevented_property_with_a_query_name_is_still_filtered_by_its_member_name_in_another_case
  • Unit PropertyResolverTests.prevented_sort_property_with_a_query_name_is_skipped_when_written_by_its_member_name -> prevented_sort_property_with_a_query_name_still_sorts_by_its_member_name_in_another_case
  • Unit PropertyResolverTests.prevented_derived_property_removes_the_clause -> prevented_derived_property_is_still_filtered
  • Unit PropertyResolverTests.prevented_derived_property_in_a_list_is_skipped -> prevented_derived_property_in_a_list_is_still_filtered
  • Unit PropertyResolverTests.prevented_custom_operation_removes_the_clause -> prevented_custom_operation_is_still_applied
  • Unit PropertyResolverTests.prevented_custom_operation_is_true_equals_true_when_replaced -> removed
  • Unit PropertyResolverTests.prevented_derived_sort_property_is_skipped -> prevented_derived_sort_property_still_sorts
  • Integration PropertyResolverTests.prevented_property_in_arithmetic_is_not_filtered -> removed
  • Integration PropertyResolverTests.prevented_property_on_the_right_side_is_not_compared -> removed
  • Integration PropertyResolverTests.prevented_property_in_a_list_is_not_filtered_in_any_case -> removed
  • Integration PropertyResolverTests.prevented_sort_property_with_a_query_name_is_not_sorted_when_written_by_its_member_name -> removed
  • Integration PropertyResolverTests.prevented_custom_operation_is_not_filtered -> removed
  • Integration PropertyResolverTests.prevented_derived_property_is_not_filtered -> removed
  • Integration PropertyResolverTests.prevented_derived_sort_property_is_not_sorted -> removed

K

  • Unit PropertyResolverTests.property_prevented_for_filter_and_sort_is_removed_by_its_query_name -> property_prevented_for_filter_and_sort_throws_by_its_query_name
  • Unit new pin: property_prevented_for_filter_and_sort_throws_by_its_query_name_before_an_operator_alias
  • Integration PropertyResolverTests.prevented_property_clause_by_its_query_name_under_or_does_not_return_every_row -> removed

F

  • Unit HasConversionTests.can_filter_struct_with_query_name_and_has_conversion -> struct_with_query_name_and_has_conversion_throws
  • Unit HasConversionTests.can_filter_struct_with_has_conversion_configured_before_query_name -> struct_with_has_conversion_configured_before_query_name_throws
  • Unit HasConversionTests.can_filter_struct_with_not_equals_query_name_and_has_conversion -> struct_with_not_equals_query_name_and_has_conversion_throws
  • Unit HasConversionTests.can_filter_by_property_path_when_query_name_and_has_conversion_are_configured -> property_path_with_query_name_and_has_conversion_configured_throws
  • Unit HasConversionTests.can_filter_reference_type_with_query_name_and_has_conversion -> reference_type_with_query_name_and_has_conversion_throws
  • Unit HasConversionTests.can_filter_nested_property_with_query_name_and_has_conversion -> nested_property_with_query_name_and_has_conversion_throws
  • Unit HasConversionTests.child_property_of_converted_parent_with_query_name_compares_parent -> child_property_of_converted_parent_with_query_name_compares_the_child
  • Unit HasConversionTests.can_filter_nullable_struct_with_has_conversion -> nullable_struct_with_has_conversion_throws
  • Unit HasConversionTests.can_filter_null_on_reference_type_with_has_conversion -> null_on_reference_type_with_has_conversion_matches_no_row
  • Unit HasConversionTests.can_filter_null_on_reference_type_with_query_name_and_has_conversion -> null_on_reference_type_with_query_name_and_has_conversion_throws
  • Unit HasConversionTests.can_filter_guid_with_contains_and_has_conversion -> guid_with_contains_and_has_conversion_throws
  • Integration HasConversionTests.can_filter_by_email_with_query_name_and_has_conversion -> removed
  • Integration HasConversionTests.can_filter_by_email_property_path_when_query_name_and_has_conversion_are_configured -> removed
  • Integration HasConversionTests.can_filter_by_email_value_with_query_name_and_has_conversion -> removed
  • Integration HasConversionTests.can_filter_by_null_email_with_query_name_and_has_conversion -> removed
  • Integration HasConversionTests.can_filter_by_nested_postal_code_with_query_name_and_has_conversion -> removed
  • Proof: the pinned unit file passes 17/17 against the v1.14.2 library (scratch/fcheck).

G

  • Unit FilterParserTests.child_collection_member_resolves_in_any_case -> child_collection_member_in_another_case_throws
  • Unit new pins: unknown_child_collection_member_throws_when_unknown_properties_are_allowed, member_after_a_child_collection_member_resolves_in_any_case, nested_child_collection_member_in_another_case_throws
  • Proof: scratch/probe walker cases match v1.14.2 output (o.txt) line for line.

J5

  • Unit PropertyResolverTests.query_name_in_a_value_is_not_replaced -> query_name_in_a_value_is_replaced
  • Unit PropertyResolverTests.query_name_with_a_hyphen_in_a_value_is_not_replaced -> query_name_with_a_hyphen_in_a_value_is_replaced (kept J fix test)
  • Unit new pin: query_name_with_a_hyphen_before_an_operator_alias_filters_by_its_property

J-bis

  • Unit PropertyResolverTests.query_name_in_a_property_list_resolves_to_its_property -> query_name_in_a_property_list_throws
  • Unit PropertyResolverTests.query_name_in_arithmetic_resolves_to_its_property -> query_name_in_arithmetic_throws
  • Unit PropertyResolverTests.query_name_with_a_hyphen_in_a_property_list_resolves_to_its_property -> query_name_with_a_hyphen_in_a_property_list_throws (kept J fix test)
  • Unit PropertyResolverTests.derived_property_query_name_with_a_hyphen_resolves_to_its_expression -> derived_property_query_name_with_a_hyphen_throws (v1.14.2 did not rewrite derived property query names). bc70b3a changes it back, see QN3/QN4.
  • Unit new pin: query_name_of_a_prevented_property_in_a_property_list_throws (closes the (hidden, Title) bypass)
  • Integration PropertyResolverTests.query_name_in_a_property_list_is_filtered -> removed
  • Integration PropertyResolverTests.query_name_in_arithmetic_is_filtered -> removed
  • Proof: the 5 pins passed against the v1.14.2 library at 5e7d918. bc70b3a changes the derived-property pin, because v1.14.2 threw for that input.

P

  • Unit PropertyResolverTests.arithmetic_property_obeys_max_property_depth -> arithmetic_property_skips_max_property_depth

39312a1 (alias pre-pass)

  • Unit OperatorAliasTests.alias_text_inside_quoted_value_is_not_replaced -> alias_text_inside_quoted_value_is_replaced
  • Unit OperatorAliasTests.case_insensitive_alias_text_inside_quoted_value_is_not_replaced -> case_insensitive_alias_text_inside_quoted_value_is_replaced
  • Unit OperatorAliasTests.can_use_alias_operator_with_query_name -> can_use_alias_operator_with_query_name_and_alias_text_in_the_value_is_replaced
  • Integration FilterParsingRegressionTests.operator_alias_text_inside_quoted_value_is_kept -> operator_alias_text_inside_quoted_value_is_replaced

c90861c (comma in a quoted list value)

  • Unit FilterParsingRegressionTests.list_value_with_comma_is_one_item (theory, 5 cases) -> list_value_with_comma_is_split_into_items (4 cases now expect the v1.14.2 split, the trim case is the same)
  • Integration FilterParsingRegressionTests.list_value_with_comma_is_one_item -> list_value_with_comma_is_split_into_items (in and not-in rows swap)

ea5cc66 (DateTimeOffset to UTC, only with parameters on)

  • Unit FilterParserTests.can_handle_datetime_comparison_with_timezone, _timezone_another, _negative_timezone, _timezone_no_minutes: expected value back to the v1.14.2 offset (ea5cc66 had changed them to UTC)
  • Integration date_time_offset_value_with_offset_matches_same_instant: unchanged, passes with parameters off (literal keeps the offset) and on (UTC)

5c84ef6 (fraction: break parts only)

  • Unit FilterParsingRegressionTests.fractional_seconds_are_kept: cases Time == "08:30:00.5" and Time == "08:30:00.50" moved to new pin quoted_time_with_fewer_than_three_fraction_digits_drops_the_fraction (v1.14.2 drops a quoted fraction with fewer than 3 digits). New kept cases: Time == "08:30:00.500", 2024-01-15T08:00:00Z.5, 2024-01-15T10:00:00+02:00.500 (v1.14.2 zone-before-fraction format)
  • Integration FilterParsingRegressionTests.fractional_seconds_are_kept -> fractional_second_value_matches_by_its_fraction (quoted .5 now expects the whole-second row, added Z.5 and quoted .500 cases)
  • Kept (v1.14.2 threw): .500Z, fraction before an offset, 7 fraction digits, unquoted time fraction, time list

aff9638 (null guards on case-sensitive string operators, full revert)

  • Unit FilterParserTests: 8 expression expectations back to the v1.14.2 text (no != null guard on @=, _=, _-=, !@=)
  • Unit OperatorAliasTests: 1 expression expectation back to the v1.14.2 text
  • Unit FilterParsingRegressionTests.case_sensitive_string_operator_handles_null_property -> case_sensitive_string_operator_on_null_property_throws_in_memory (v1.14.2 throws NullReferenceException in memory)
  • Integration FilterParsingRegressionTests.case_sensitive_string_operator_handles_null_property: unchanged, passes on Postgres without the guard

c948532 (factories pass usesAll, full revert)

  • Unit FilterParsingRegressionTests.comparison_operator_factory_keeps_uses_all (theory, 24 factories) -> comparison_operator_factory_ignores_uses_all (UsesAll is false, like v1.14.2)
  • Unit FilterParsingRegressionTests.comparison_operator_factory_with_uses_all_builds_all_expression -> comparison_operator_factory_with_uses_all_builds_any_expression
  • Integration FilterParsingRegressionTests.comparison_operator_factory_with_uses_all_matches_every_item -> comparison_operator_factory_with_uses_all_matches_any_item

NEW-2 (constant list in case-insensitive In and NotIn, from 5db8c4b PR 110)

  • Unit new pin: FilterParsingRegressionTests.case_insensitive_in_operator_factory_reads_a_constant_list (In and NotIn)

C residual (Obsolete on FromSymbol, from d507c87)

  • Unit ArithmeticOperatorTests: 2 tests lose the CS0618 pragma, assertions unchanged

D (HasType message, from de350f7 PR 109)

  • Unit FilterParserTests.has_type_throws_correct_message_on_non_collection_property -> has_type_on_non_collection_property_throws_the_v1_14_2_message (message back to "DoesNotHaveType is only supported for collections")

QN3/QN4 (derived property and custom operation query names that are not identifiers, kept throw-to-works)

  • Unit PropertyResolverTests.derived_property_query_name_with_a_hyphen_throws (J-bis) -> derived_property_query_name_with_a_hyphen_resolves_to_its_expression (the main test name and assertion again)
  • Unit new pin: custom_operation_query_name_with_a_space_resolves_to_its_operation
  • Unit new pin: derived_property_query_name_does_not_match_the_start_of_a_longer_name

Proof

Run on this branch at 01b06c1. The v1.14.2 files come from tag v1.14.2. The v1.14.2 suites run on net9.0 with DOTNET_ROLL_FORWARD=Major.

Check Result
Branch unit suite 402 pass, 0 fail
Branch Postgres integration suite (Testcontainers) 283 pass, 0 fail
v1.14.2 unit suite on the v1.14.2 library 187 pass, 2 skip
v1.14.2 unit suite on this branch 184 pass, 2 skip, 3 fail. The 3 failures are the kept PR 133 type-name tests.
v1.14.2 integration suite on the v1.14.2 library 205 pass, 1 skip
v1.14.2 integration suite on this branch 205 pass, 1 skip
Public API dump (reflection, net10.0), v1.14.2 compared with the branch Additions only (the opt-in API above). No member is removed or changed. IQueryKitConfiguration does not change. FromSymbol has no Obsolete attribute.
Binary-compat consumer It implements every v1.14.2 IQueryKitConfiguration member, calls FromSymbol, nests 40 levels, and sends 6000 characters. Compiled on v1.14.2 and run on v1.14.2, run with the branch dll swapped in, and compiled on the branch: all three print the same output.
Filter and sort probe, en-US and de-DE Each difference is a "kept" row above or the PR 133 type name.
Query-name probe, QN and edge cases Each difference is a "kept" row above, the PR 133 type name, the PR 128 message, or the rejected property-list input.

v1.14.2 parsed a filter of any length and any nesting depth. The
default limits of 5000 characters and 32 levels rejected filters that
v1.14.2 accepted. Set both defaults to int.MaxValue, so the limits are
an opt-in through MaxInputLength and MaxNestingDepth. A later major
version can turn them on by default again.
v1.14.2 applied HasMaxDepth to every path that starts with the
property name, so HasMaxDepth on Address also applied to
AddressBackup.State. Match by prefix again, like v1.14.2. A later
major version can apply the depth only to the property and the paths
below it.
v1.14.2 read one identifier on the right side of a comparison, so
Title == Author.Name and Title == foo.bar threw ParsingException. Read
one identifier again, like v1.14.2. A later minor version can accept a
nested property path on the right side.
v1.14.2 built a TimeOnly value with the constructor that takes five
ints. On net6.0 this constructor does not exist, so a TimeOnly filter
threw ArgumentNullException. Main fell back to a constant. Remove the
fallback, so a v1.14.2 consumer sees the same exception. The opt-in
parameter path keeps its behavior. A later version can build TimeOnly
values on net6.0.
…metic again

v1.14.2 threw ArgumentException for an unknown property in
arithmetic, also with AllowUnknownProperties. Main threw
UnknownFilterPropertyException, or removed the clause when unknown
properties were allowed. Throw ArgumentException again, like v1.14.2.
v1.14.2 checked PreventFilter only for a left-side member, looked up by its name in the exact case after the query-name rewrite. It did not check arithmetic, the right side, another case in a property list, derived properties, or custom operations. PreventSort was looked up by the typed path in the exact case. The parser now does the same checks as v1.14.2 again, so that a v1.14.2 consumer sees no difference.

This reopens the six bypasses I1 to I6 of the breaking-change audit. A later major release closes them again.
…ery name again

In v1.14.2, a property with PreventFilter and PreventSort threw InvalidOperationException when the filter used its query name before an operator. The alias rewrite pass did this check before the parser ran, so the exception was not wrapped in ParsingException.

ParseFilter runs the same logical alias, comparison alias, and query name passes on a copy of the input again. The result of the passes is not used, because the grammar resolves query names. Only the check has an effect.
In v1.14.2, the HasConversion lookups searched by query name after the query name was already replaced with the property path. A property with both HasConversion and HasQueryName did not use its conversion.

The lookups search by query name again. A null literal on a converted property builds a value from the text null again. A converted Nullable<T> struct, Guid string operators, and lower-case property lists do not use the conversion again.
After a collection, only properties match again. The first segment must match in the exact case, and a later segment matches in any case. A segment that does not match throws NullReferenceException, like v1.14.2.
ParseFilter replaces each query name in front of a comparison operator with its property path again, like v1.14.2. The pass also changes a query name inside a quoted value, as v1.14.2 did.

The pass also matches a query name in front of a comparison alias. The parser still reads the aliases, so the aliases inside quoted values stay as they are.
The property resolver no longer maps a query name to its property path, and the grammar reads only identifier paths again. A query name works through the rewrite before the parse, like v1.14.2.

A query name in a property list throws UnknownFilterPropertyException again, and a query name in arithmetic throws ArgumentException again. A query name with a hyphen, a leading underscore, or a space still works in front of an operator.
Arithmetic builds its property paths from the filter text again, like v1.14.2. A property path inside arithmetic does not go through the property resolver, so MaxPropertyDepth does not apply to it.

Security consequence: arithmetic can go deeper than MaxPropertyDepth again, as in v1.14.2.
v1.14.2 replaced each operator alias that stands between whitespace with a regex before the parse. This also changed alias text inside a quoted value, so Title eq "salt and pepper" compares with "salt && pepper". Main read the aliases only in the grammar, so the value stayed unchanged and the result was different. The rewrite runs again, in the v1.14.2 order: logical aliases, comparison aliases, then query names.

The query-name rewrite goes back to the v1.14.2 pattern, because the aliases are already replaced when it runs. The grammar still reads an alias that the rewrite did not replace, for example (Age)eq 3, which v1.14.2 rejected. The grammar tries the canonical operator first, like v1.14.2.

Restores 39312a1.
v1.14.2 split the value of the in and not-in operators on every comma, also on a comma inside a quoted item. Serving ^^ ["Warm, with syrup"] reads the two items Warm and with syrup. Main kept the quoted item as one item, so the same filter gave different rows.

Restores c90861c.
v1.14.2 kept the offset of a DateTimeOffset filter value, so 2022-07-01T00:00:03+01:00 gave new DateTimeOffset(..., 01:00:00). Main converted every value to UTC, also when ParameterizeFilterValues is off, so the expression text and the value changed.

The value goes to UTC only when ParameterizeFilterValues is on, because Npgsql accepts a DateTimeOffset parameter only with offset 0. Without parameters the value keeps its offset, like v1.14.2.

Restores ea5cc66 for the default path.
can_filter_enumerable filtered two fake recipes by the title of the first one. AutoBogus fills the title with one random word, so both recipes sometimes had the same title and the test found two rows.
…d time fraction again

Restores the break parts of 5c84ef6. A date time value with the zone before the fraction, for example 2024-01-15T08:00:00Z.5, parses again. A quoted time keeps the v1.14.2 fraction rule: milliseconds need 3 digits and microseconds need 6.

The parts that v1.14.2 rejected stay: a fraction before the zone, 7 fraction digits, an unquoted time fraction, and a time list.
…ators again

Restores aff9638. The case-sensitive @=, _=, _-= operators and their negations give the v1.14.2 expression text again. In memory, a null property throws NullReferenceException, like v1.14.2. On Postgres, the results do not change.

A string operator on a collection property throws ArgumentException again, like v1.14.2, and not ParsingException.
Restores c948532. The public ComparisonOperator factories accept usesAll but do not give it to the constructor, like v1.14.2. An operator from a factory matches any item of a collection.
…n again

Restores the v1.14.2 list read that 5db8c4b (PR 110) removed. InOperator(true) and NotInOperator(true) threw NullReferenceException when a caller passed the list as a ConstantExpression. They read the constant list again, like v1.14.2.
…Symbol

Completes the C restore. d507c87 added FromSymbol back with an Obsolete attribute. A v1.14.2 consumer that builds with warnings as errors got CS0618. FromSymbol has no attribute again, like v1.14.2.
Restores the message that de350f7 (PR 109) changed. The has operator on a property that is not a collection throws 'DoesNotHaveType is only supported for collections' again, like v1.14.2. The exception type does not change.
…e with a hyphen or a space again

The grammar reads the identifier path first, like v1.14.2. If the path is not a property and unknown properties are not allowed, the grammar tries the query names of derived properties and custom operations, longest first.

v1.14.2 threw for these inputs, so no filter that v1.14.2 accepted gives a different result.
The Property selector returns object, so a selector for a nullable property gave warning CS8603. The 40 selectors now use the null-forgiving operator, and the build has no warnings.
This was referenced Oct 1, 2026
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