From fb755fa1812594c777491cb4ce55e17d52bdf20f Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 17:06:28 +0300 Subject: [PATCH] fix(filter)!: apply MaxPropertyDepth to property paths in arithmetic A property path inside an arithmetic expression did not go through the property resolver, so MaxPropertyDepth did not apply to it. A caller could reach deeper navigation properties than the limit permits, for example (Recipe.Rating + 0) > 1 with a limit of 0. Resolve each property in an arithmetic expression, so the depth check applies. BREAKING CHANGE: a filter with an arithmetic property path deeper than MaxPropertyDepth now throws QueryKitPropertyDepthExceededException. --- QueryKit.UnitTests/PropertyResolverTests.cs | 4 +-- QueryKit/FilterParser.cs | 31 +++++++++++++++++++-- README.md | 2 ++ 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 040943f..bfc1504 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -220,7 +220,7 @@ public void prevented_property_on_the_right_side_of_arithmetic_is_still_filtered } [Fact] - public void arithmetic_property_skips_max_property_depth() + public void arithmetic_property_obeys_max_property_depth() { var input = """(Recipe.Rating + 0) > 1"""; var config = new QueryKitConfiguration(config => @@ -230,7 +230,7 @@ public void arithmetic_property_skips_max_property_depth() var act = () => FilterParser.ParseFilter(input, config); - act.Should().NotThrow(); + act.Should().Throw(); } [Fact] diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 07ead9d..606924a 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -807,14 +807,41 @@ 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 leftExpr = temp.leftArithmetic.ToLinqExpression(parameter, typeof(T)); - var rightExpr = temp.rightSide.ToLinqExpression(parameter, typeof(T)); + var leftArithmetic = ResolveArithmeticProperties(temp.leftArithmetic, typeof(T), config); + var rightArithmetic = ResolveArithmeticProperties(temp.rightSide, typeof(T), config); + + var leftExpr = leftArithmetic.ToLinqExpression(parameter, typeof(T)); + var rightExpr = rightArithmetic.ToLinqExpression(parameter, typeof(T)); var (leftCompatible, rightCompatible) = EnsureCompatibleTypes(leftExpr, rightExpr); return temp.op.GetExpression(leftCompatible, rightCompatible, config?.DbContextType); }); } + // Resolves each property in an arithmetic expression to its member path. + private static ArithmeticExpression ResolveArithmeticProperties(ArithmeticExpression expr, Type entityType, IQueryKitConfiguration? config) + { + switch (expr) + { + case PropertyArithmeticExpression property: + var reference = PropertyResolver.Resolve(entityType, property.PropertyPath, config); + if (reference.Kind != PropertyReferenceKind.Member) + { + return property; + } + + return new PropertyArithmeticExpression(reference.Path); + case BinaryArithmeticExpression binary: + var left = ResolveArithmeticProperties(binary.Left, entityType, config); + var right = ResolveArithmeticProperties(binary.Right, entityType, config); + return new BinaryArithmeticExpression(left, binary.Operator, right); + case GroupedArithmeticExpression grouped: + return new GroupedArithmeticExpression(ResolveArithmeticProperties(grouped.Inner, entityType, config)); + default: + return expr; + } + } + private static bool ContainsArithmeticOperator(ArithmeticExpression expr) { return expr switch diff --git a/README.md b/README.md index 96f5341..3f6b7ac 100644 --- a/README.md +++ b/README.md @@ -794,6 +794,8 @@ var config = new QueryKitConfiguration(config => Setting `MaxPropertyDepth = 0` only allows root-level properties. A `null` value (default) allows unlimited depth. +The limit applies to every property path in a filter, also to a property path inside an arithmetic expression. For example, `(Author.Rating + 0) > 1` throws when `MaxPropertyDepth = 0`. + #### Parameterize Filter Values By default (`ParameterizeFilterValues = false`), QueryKit writes each filter value into the SQL as a literal constant. Set `ParameterizeFilterValues` to `true` to send filter values as SQL parameters instead. Parameters let EF Core reuse one compiled query and one database plan across calls that differ only in their filter values.