From 53350f18fd6640df837075b0088028e354307581 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sat, 3 Oct 2026 15:08:34 +0300 Subject: [PATCH] fix(filter)!: accept a property path on the right side of a comparison The right side of a comparison read one identifier. Title == Author.Name threw ParsingException, and the README examples with a path on the right side (Rating > Author.Score) did not work. Read a dotted property path on the right side, and resolve it like a path on the left side. An unquoted dotted name on the right side must resolve to a property. If it does not, the filter throws UnknownFilterPropertyException. A path through a collection also throws. The path obeys MaxPropertyDepth and PreventFilter. A property list does not accept a path on the right side. BREAKING CHANGE: Title == Author.Name compares the two properties. Title == foo.bar throws UnknownFilterPropertyException instead of ParsingException. Quote the text to compare with a literal value. --- .../Tests/PropertyResolverTests.cs | 58 +++++++++++++ QueryKit.UnitTests/PropertyResolverTests.cs | 86 +++++++++++++++++-- QueryKit/FilterParser.cs | 18 ++-- README.md | 2 + 4 files changed, 153 insertions(+), 11 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 2c2112b..17e6107 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -5,7 +5,10 @@ namespace QueryKit.IntegrationTests.Tests; using FluentAssertions; using Microsoft.EntityFrameworkCore; using SharedTestingHelper.Fakes; +using SharedTestingHelper.Fakes.Author; +using SharedTestingHelper.Fakes.Recipes; using WebApiTestProject.Entities; +using WebApiTestProject.Entities.Recipes; public class PropertyResolverTests : TestBase { @@ -150,6 +153,61 @@ public async Task property_is_not_prevented_by_another_property_whose_query_name people[0].Id.Should().Be(fakePerson.Id); } + [Fact] + public async Task property_path_on_the_right_side_is_compared() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var name = Guid.NewGuid().ToString(); + var matchingRecipe = new FakeRecipeBuilder() + .WithTitle(name) + .Build(); + matchingRecipe.SetAuthor(new FakeAuthorBuilder().WithName(name).Build()); + var otherRecipe = new FakeRecipeBuilder() + .WithTitle(name) + .Build(); + otherRecipe.SetAuthor(new FakeAuthorBuilder().WithName(Guid.NewGuid().ToString()).Build()); + await testingServiceScope.InsertAsync(matchingRecipe, otherRecipe); + + var input = $"""Title == "{name}" && Title == Author.Name"""; + + // Act + var recipes = await testingServiceScope.DbContext().Recipes + .ApplyQueryKitFilter(input) + .ToListAsync(); + + // Assert + recipes.Should().ContainSingle(x => x.Id == matchingRecipe.Id); + } + + [Fact] + public async Task prevented_property_path_on_the_right_side_is_not_compared() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var name = Guid.NewGuid().ToString(); + var recipe = new FakeRecipeBuilder() + .WithTitle(name) + .WithRating(5) + .Build(); + recipe.SetAuthor(new FakeAuthorBuilder().WithName(name).Build()); + await testingServiceScope.InsertAsync(recipe); + + var input = $"""Title == "{name}" && (Title == Author.Name || Rating > 100)"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Author.Name).PreventFilter(); + }); + + // Act + var recipes = await testingServiceScope.DbContext().Recipes + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + recipes.Should().BeEmpty(); + } + [Fact] public async Task prevented_property_in_arithmetic_is_not_filtered() { diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index d741e84..7ea354c 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -857,25 +857,99 @@ public void alias_replacement_replaces_a_query_name_in_a_nested_path() } [Fact] - public void property_path_on_the_right_side_throws() + public void property_path_on_the_right_side_is_compared() { var input = """Title == Author.Name"""; + var filterExpression = FilterParser.ParseFilter(input); + + filterExpression.ToDisplayString().Should().Be("x => (x.Title == x.Author.Name)"); + } + + [Theory] + [InlineData("Author.Nmae", "Nmae")] + [InlineData("foo.bar", "foo")] + [InlineData("Ingredients.Name", "Ingredients.Name")] + public void unresolved_property_path_on_the_right_side_throws(string path, string unknownProperty) + { + var input = $"""Title == {path}"""; + var act = () => FilterParser.ParseFilter(input); - act.Should().Throw() - .WithInnerException() - .WithMessage("*Equal is not defined for the types 'System.String' and*Author*"); + act.Should().Throw() + .WithMessage($"The filter property '{unknownProperty}' was not recognized."); } [Fact] - public void unquoted_dotted_word_on_the_right_side_throws() + public void unresolved_property_path_on_the_right_side_throws_when_unknown_properties_are_allowed() { var input = """Title == foo.bar"""; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + var act = () => FilterParser.ParseFilter(input, config); + + act.Should().Throw(); + } + + [Fact] + public void property_path_on_the_right_side_obeys_max_property_depth() + { + var input = """Title == Author.Name"""; + var config = new QueryKitConfiguration(config => + { + config.MaxPropertyDepth = 0; + }); + + var act = () => FilterParser.ParseFilter(input, config); + + act.Should().Throw(); + } + + [Fact] + public void prevented_property_path_on_the_right_side_removes_the_clause() + { + var input = """Title == Author.Name || Rating > 3"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Author.Name).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Rating > 3)"); + } + + [Fact] + public void property_path_on_the_right_side_of_a_property_list_throws() + { + var input = """(Title, Directions) == Author.Name"""; var act = () => FilterParser.ParseFilter(input); - act.Should().Throw().WithMessage("*Line 1, Column 13*"); + act.Should().Throw(); + } + + [Fact] + public void number_with_a_dot_on_the_right_side_is_a_value() + { + var input = """Rating == 3.5"""; + + var filterExpression = FilterParser.ParseFilter(input); + + filterExpression.ToDisplayString().Should().Be("x => (x.Rating == 3.5)"); + } + + [Theory] + [InlineData("""Title == "Author.Name" """, "x => (x.Title == \"Author.Name\")")] + [InlineData("""Title == "foo.bar" """, "x => (x.Title == \"foo.bar\")")] + public void quoted_value_with_a_dot_on_the_right_side_is_a_value(string input, string expected) + { + var filterExpression = FilterParser.ParseFilter(input); + + filterExpression.ToDisplayString().Should().Be(expected); } [Fact] diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index c1c849b..cfd21be 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -367,7 +367,8 @@ from closingQuotes in Parse.Char('"').Repeat(count).Text() // This is needed to disambiguate a literal from a bare property reference (property-to-property // comparison) once the surrounding quotes have been stripped, since both are otherwise identical strings. // CultureNumberPrefix is set when the value holds a number that only the '.' decimal point reads (see BuildClauseLikeV1142). - private readonly record struct RightSideValue(string Value, bool IsQuotedLiteral, string? CultureNumberPrefix = null); + // IsPropertyPath is set for unquoted identifiers joined with '.', which must resolve to a property path. + private readonly record struct RightSideValue(string Value, bool IsQuotedLiteral, string? CultureNumberPrefix = null, bool IsPropertyPath = false); private static readonly Parser> SquareBracketValuesParser = Parse.String("null").Text() @@ -395,7 +396,7 @@ from closingBracket in Parse.Char(']') .XOr(NumberParser.Select(v => new RightSideValue(v, false, CultureNumberPrefix(v)))) .XOr((RawStringLiteralParser.Or(DoubleQuoteParser)).Select(v => new RightSideValue(v, true))) .XOr(SquareBracketParser) - .XOr(Identifier.Select(v => new RightSideValue(v, false))); // Keep this last to try property paths only if nothing else matches + .XOr(Identifier.DelimitedBy(Parse.Char('.')).Select(v => v.ToList()).Select(v => new RightSideValue(string.Join(".", v), false, IsPropertyPath: v.Count > 1))); // Keep this last to try property paths only if nothing else matches private static readonly Parser RightSideValueParser = from atSign in Parse.Char('@').Optional() @@ -944,7 +945,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa var regularComparison = CreateLeftExprParser(parameter.Type, config) .SelectMany(reference => comparisonOperatorParser, (reference, op) => new { reference, op }) - .SelectMany(temp => rightSideValueParser, (temp, rightValue) => new { temp.reference, temp.op, right = rightValue.Value, rightIsQuotedLiteral = rightValue.IsQuotedLiteral, cultureNumberPrefix = rightValue.CultureNumberPrefix }) + .SelectMany(temp => rightSideValueParser, (temp, rightValue) => new { temp.reference, temp.op, right = rightValue.Value, rightIsQuotedLiteral = rightValue.IsQuotedLiteral, cultureNumberPrefix = rightValue.CultureNumberPrefix, rightIsPropertyPath = rightValue.IsPropertyPath }) .Select(clause => BuildClauseLikeV1142(clause.cultureNumberPrefix, clause.right, right => { var temp = clause with { right = right }; @@ -1000,7 +1001,8 @@ private static Parser ComparisonExprParser(ParameterExpression pa // Check if the right side is a property path for property-to-property comparison. // A quoted string literal is always a value, even when its text matches a property name. - if (!temp.rightIsQuotedLiteral && IsPropertyPath(temp.right, parameter.Type)) + // A dotted path must resolve to a property. A single identifier that is not a property stays a value. + if (temp.rightIsPropertyPath || !temp.rightIsQuotedLiteral && IsPropertyPath(temp.right, parameter.Type)) { // Build the right side from the resolved path, so that the checked property is the compared property. var rightReference = PropertyResolver.ResolveWithoutQueryName(parameter.Type, temp.right, config); @@ -1012,6 +1014,11 @@ private static Parser ComparisonExprParser(ParameterExpression pa var rightPropertyExpr = rightReference.Kind == PropertyReferenceKind.Member ? CreateRightPropertyExpr(parameter, rightReference.Path, config) : null; + if (rightPropertyExpr == null && temp.rightIsPropertyPath) + { + // A path through a collection resolves to a member, but it is not one value to compare with. + throw new UnknownFilterPropertyException(rightReference.UnknownSegment ?? temp.right); + } if (rightPropertyExpr != null) { // Handle GUID conversion for property-to-property comparisons @@ -1300,7 +1307,8 @@ private static Parser PropertyListComparisonExprParser( return PropertyListParser(PropertyPathParser(config)) .SelectMany(properties => comparisonOperatorParser, (properties, op) => new { properties, op }) - .SelectMany(temp => rightSideValueParser, + // A property list compares with a value, so a property path on the right side does not parse. + .SelectMany(temp => rightSideValueParser.Where(rightValue => !rightValue.IsPropertyPath), (temp, rightValue) => new { temp.properties, temp.op, right = rightValue.Value, rightIsQuotedLiteral = rightValue.IsQuotedLiteral, cultureNumberPrefix = rightValue.CultureNumberPrefix }) .Select(clause => BuildClauseLikeV1142(clause.cultureNumberPrefix, clause.right, right => { diff --git a/README.md b/README.md index 6699e4f..b88da46 100644 --- a/README.md +++ b/README.md @@ -314,6 +314,8 @@ Child property comparisons work with: - **Type Conversion**: Automatic conversion between compatible types - **Complex Expressions**: Can be combined with logical operators and parentheses +An unquoted dotted name on the right side is always a property path. If it does not resolve to a property, the filter throws `UnknownFilterPropertyException`. A path through a collection (`Ingredients.Name`) also throws. To compare with the literal text, quote it: `Title == "foo.bar"`. + ### Arithmetic Expressions QueryKit supports arithmetic expressions in filters, allowing you to perform calculations directly within your queries. This enables powerful filtering capabilities based on computed values.