fix(filter): resolve every property reference with one resolver - #113
Merged
Merged
Conversation
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.
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 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, andHasMaxDepth()did not apply in all positions.Each commit fixes one finding and adds regression tests. Unit and integration tests pass at every commit.
Commits
test(filter): use fixed or unique data in tests that failed at randomrefactor(filter): resolve filter property references in one placeorigin/main.fix(filter): remove unknown and prevented clauses instead of using truefix(filter): check permission and depth for properties in arithmeticPreventFilter()andMaxPropertyDepthdid not apply in arithmetic.fix(filter): check permission for a property on the right sidePreventFilter()did not apply to a property on the right side.fix(filter): look up property settings by the resolved member pathPreventFilter()did not apply when the case was different, for exampletitle.fix(sort): look up the sort permission by the resolved member pathPreventSort()did not apply when the case was different.fix(filter): apply prevent settings to derived properties and custom operationsPreventFilter()andPreventSort()had no effect on derived properties and custom operations.fix(filter): resolve query names in property lists and arithmeticAuthor.Name.fix(filter): keep a query name in a nested path in alias replacementReplaceAliasesWithPropertyPathsreplaced a query name after a dot. Unit test proof only, because the parser does not call it now.fix(filter): accept a nested property path on the right sideRating > Author.Score, was aParsingException.fix(config): apply a property max depth only to its own pathHasMaxDepth()onAuthoralso applied toAuthorNote.fix(filter): treat an unknown property in arithmetic as an unknown propertyArgumentException, also withAllowUnknownProperties.fix(filter): remove a prevented clause that uses its query nameOrderBy(x => x)fallback is deleted.Behavior changes
AllowUnknownProperties) or a prevented property now removes its clause. Before, the clause becametrue. Under||, atrueclause returned every row.PreventFilter()andPreventSort()no longer throwsInvalidOperationExceptionwhen 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.Rating > Author.Score. Before, this was aParsingException.HasMaxDepth()onAuthorno longer applies toAuthorNote. The depth check now matches a full path segment, not a string prefix.ArgumentException. WithAllowUnknownProperties, the parser removes the clause. Without it, the parser throwsUnknownFilterPropertyException. A derived property or a custom operation in arithmetic gets the same result, because arithmetic supports only members.Notes for review
Title == a.b, is now a string value. Before, it was aParsingException. 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.x => True. This matches the old behavior for a removed clause at the top level.ReplaceAliasesWithPropertyPathsmethod 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.ArgumentExceptionbecomesUnknownFilterPropertyExceptionin arithmetic.ParsingExceptionbecomesQueryKitPropertyDepthExceededExceptionfor a right-side path that is too deep.InvalidOperationException(insideParsingException) is removed for a prevented query name.Flaky tests
The first commit fixes six tests that failed at random:
TestingPerson.Id. The tests now use fixed names.Without this commit,
can_filter_by_string_for_nested_collectionfailed at random on an unrelated commit in this PR.Proof with verify-querykit
Each finding was reproduced with the
verify-querykitskill 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 ownedAuthorNote.Text.Configuration presets:
noneallow-unknownAllowUnknownProperties = true.max-depth-0MaxPropertyDepth = 0.preventTitlehas query namet,PreventFilter(), andPreventSort().RatinghasPreventFilter().prevent-derivedheadlinewithPreventFilter()andPreventSort(). Custom operationtotal_stock_abovewithPreventFilter().numeric-aliasRatingasstars,Author.Scoreasscore,Titleasname.aliasesTitleasname,Author.Nameaschef.RatinghasPreventFilter().PricehasPreventSort().right-path-preventAuthor.ScorehasPreventFilter().max-depth-prefixMaxPropertyDepth = 0.AuthorhasHasMaxDepth(1).AuthorNotehas no override.Results, from
origin/mainto the commit that changed each scenario. "Rows" is the recipe titles from memory and Postgres, which were the same.origin/main--filter 'Nope == "x" || Rating > 100' --config allow-unknown--filter 'Nope == "x" && Rating > 3' --config allow-unknown--filter 'Rating == 1 || Price > 100' --config prevent--filter '(Rating + 0) > 3' --config prevent--filter '(Author.Score + 0) > 1' --config max-depth-0QueryKitPropertyDepthExceededException--filter 'Price > Rating' --config prevent--filter '(title, Directions) @=* "salt"' --config prevent--filter 'title == "Pancakes"' --config prevent--sort 'title desc' --config prevent--filter 'headline @=* "Julia"' --config prevent-derived--sort 'headline desc' --config prevent-derived--filter 'total_stock_above > 20' --config prevent-derived--filter '(name, Directions) @=* "salt"' --config numeric-aliasUnknownFilterPropertyException--filter '(stars + 0) > 3' --config numeric-aliasArgumentException--filter '(score + 0) > 3' --config numeric-aliasArgumentException--filter 'Author.Name == "Julia Child"' --config aliasesUnknownFilterPropertyException--filter 'Rating > Author.Score'ParsingException--filter 'Rating > Author.Score' --config max-depth-0ParsingExceptionQueryKitPropertyDepthExceededException--filter 'Rating > Author.Score || Price > 100' --config right-path-preventParsingException--filter 'AuthorNote.Text == "note 1"' --config max-depth-prefixQueryKitPropertyDepthExceededException--filter 'Author.Name == "Julia Child"' --config max-depth-prefix--filter '(Nope + 1) > 3 || Title == "Pancakes"' --config allow-unknownArgumentException--filter '(Nope + 1) > 3 || Title == "Pancakes"' --config noneArgumentExceptionUnknownFilterPropertyException--filter 'Title == "Pancakes"' --config prevent--filter 't == "Pancakes"' --config preventInvalidOperationException--filter 't == "Pancakes" || Price > 100' --config preventInvalidOperationException--sort 'Title desc' --config prevent--filter 'Rating > 3' --config preventcontrol-prevented-aliasandcontrol-prevented-alias-orchanged twice. At 9f3b973, theInvalidOperationExceptioncame inside aParsingException. 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
CaseSensitiveAppendixrename andreadonlystatic fields.No version change.