diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 432436b..c1ed4a0 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -151,6 +151,34 @@ public async Task property_is_not_prevented_by_another_property_whose_query_name people[0].Id.Should().Be(fakePerson.Id); } + [Fact] + public async Task unknown_property_in_arithmetic_removes_the_clause_when_unknown_properties_are_allowed() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = new Faker().Lorem.Sentence(); + var fakePerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithAge(30) + .Build(); + await testingServiceScope.InsertAsync(fakePerson); + + var input = $"""Title == "{title}" && ((Nope + 1) > 3 || Age > 100)"""; + var config = new QueryKitConfiguration(config => + { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; + config.AllowUnknownProperties = true; + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().BeEmpty(); + } + [Theory] [InlineData("first-name")] [InlineData("_first")] diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 040943f..0c2287b 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -423,7 +423,7 @@ public void query_name_of_a_prevented_property_in_a_property_list_throws() } [Fact] - public void query_name_in_arithmetic_throws() + public void query_name_in_arithmetic_is_not_recognized() { var input = """(stars + 0) > 3"""; var config = new QueryKitConfiguration(config => @@ -433,7 +433,8 @@ public void query_name_in_arithmetic_throws() var act = () => FilterParser.ParseFilter(input, config); - act.Should().ThrowExactly(); + act.Should().Throw() + .WithMessage("The filter property 'stars' was not recognized."); } [Theory] @@ -710,7 +711,7 @@ public void unquoted_dotted_word_on_the_right_side_throws() } [Fact] - public void unknown_property_in_arithmetic_throws_when_unknown_properties_are_allowed() + public void unknown_property_in_arithmetic_removes_the_clause_when_unknown_properties_are_allowed() { var input = """(Nope + 1) > 3 || Age > 100"""; var config = new QueryKitConfiguration(config => @@ -719,14 +720,13 @@ public void unknown_property_in_arithmetic_throws_when_unknown_properties_are_al config.AllowUnknownProperties = true; }); - var act = () => FilterParser.ParseFilter(input, config); + var filterExpression = FilterParser.ParseFilter(input, config); - act.Should().Throw() - .WithMessage("Property 'Nope' not found on type 'TestingPerson'"); + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); } [Fact] - public void unknown_property_on_the_right_side_of_arithmetic_throws_when_unknown_properties_are_allowed() + public void unknown_property_on_the_right_side_of_arithmetic_removes_the_clause_when_unknown_properties_are_allowed() { var input = """(Age + 0) > Nope || Title == "a" """; var config = new QueryKitConfiguration(config => @@ -735,21 +735,20 @@ public void unknown_property_on_the_right_side_of_arithmetic_throws_when_unknown config.AllowUnknownProperties = true; }); - var act = () => FilterParser.ParseFilter(input, config); + var filterExpression = FilterParser.ParseFilter(input, config); - act.Should().Throw() - .WithMessage("Property 'Nope' not found on type 'TestingPerson'"); + filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "a")"""); } [Fact] - public void unknown_property_in_arithmetic_throws_an_argument_exception() + public void unknown_property_in_arithmetic_is_not_recognized() { var input = """(Nope + 1) > 3"""; var act = () => FilterParser.ParseFilter(input); - act.Should().Throw() - .WithMessage("Property 'Nope' not found on type 'TestingPerson'"); + act.Should().Throw() + .WithMessage("The filter property 'Nope' was not recognized."); } [Theory] diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 07ead9d..06227e4 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -807,6 +807,18 @@ private static Parser ArithmeticComparisonExprParser(ParameterExp .SelectMany(temp => parenthesizedArithmetic.Or(rightSideValueParser.Select(value => CreateArithmeticFromValue(value.Value))), (temp, rightSide) => new { temp.leftArithmetic, temp.op, rightSide }) .Select(temp => { + var unknownSegment = FindUnknownArithmeticSegment(temp.leftArithmetic, typeof(T), config) + ?? FindUnknownArithmeticSegment(temp.rightSide, typeof(T), config); + if (unknownSegment != null) + { + if (config?.AllowUnknownProperties == true) + { + return IgnoredClause(config); + } + + throw new UnknownFilterPropertyException(unknownSegment); + } + var leftExpr = temp.leftArithmetic.ToLinqExpression(parameter, typeof(T)); var rightExpr = temp.rightSide.ToLinqExpression(parameter, typeof(T)); @@ -815,6 +827,21 @@ private static Parser ArithmeticComparisonExprParser(ParameterExp }); } + // Returns the first segment that is not a member, for the first property in an arithmetic expression that is not a member. + // Arithmetic supports only members, so a derived property or a custom operation is unknown here. + private static string? FindUnknownArithmeticSegment(ArithmeticExpression expr, Type entityType, IQueryKitConfiguration? config) + { + return expr switch + { + PropertyArithmeticExpression property => PropertyResolver.ResolveWithoutDepthCheck(entityType, property.PropertyPath, config) is { Kind: not PropertyReferenceKind.Member } reference + ? reference.UnknownSegment + : null, + BinaryArithmeticExpression binary => FindUnknownArithmeticSegment(binary.Left, entityType, config) ?? FindUnknownArithmeticSegment(binary.Right, entityType, config), + GroupedArithmeticExpression grouped => FindUnknownArithmeticSegment(grouped.Inner, entityType, config), + _ => null + }; + } + private static bool ContainsArithmeticOperator(ArithmeticExpression expr) { return expr switch diff --git a/QueryKit/PropertyResolver.cs b/QueryKit/PropertyResolver.cs index 4645ef5..0870389 100644 --- a/QueryKit/PropertyResolver.cs +++ b/QueryKit/PropertyResolver.cs @@ -54,7 +54,12 @@ internal static class PropertyResolver internal static PropertyReference Resolve(Type rootType, string reference, IQueryKitConfiguration? config) { config?.ValidatePropertyDepth(reference); + return ResolveWithoutDepthCheck(rootType, reference, config); + } + // Arithmetic does not apply MaxPropertyDepth. + internal static PropertyReference ResolveWithoutDepthCheck(Type rootType, string reference, IQueryKitConfiguration? config) + { var memberPath = ResolveMemberPath(rootType, reference, out var unknownSegment); if (memberPath != null) { diff --git a/README.md b/README.md index 96f5341..a38296c 100644 --- a/README.md +++ b/README.md @@ -380,6 +380,7 @@ var input = "((Revenue - Expenses) / Revenue) > 0.1"; - **Parentheses**: Use parentheses to control calculation order and group expressions - **Entity Framework Translation**: All arithmetic expressions are translated to efficient SQL queries - **Property-to-Property**: Can mix property references with literal values in the same expression +- **Unknown Properties**: An unknown property in an arithmetic expression throws `UnknownFilterPropertyException`, like any other filter property. When `AllowUnknownProperties` is `true`, QueryKit ignores the clause. Arithmetic supports only entity members, so a derived property or a custom operation name is unknown here #### Examples