From d4e2cb3ce5384d844f523ad22d130c415151bee7 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:38:34 +0300 Subject: [PATCH 01/14] test(filter): use fixed or unique data in tests that failed at random 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. --- QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs | 4 +++- QueryKit.UnitTests/CustomFilterPropertyTests.cs | 6 ++---- QueryKit.UnitTests/FilterParserTests.cs | 3 +-- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs index 4b0a83f..b126188 100644 --- a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs +++ b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs @@ -398,6 +398,7 @@ public async Task can_filter_by_string_for_nested_collection() var testingServiceScope = new TestingServiceScope(); var faker = new Faker(); var preparationOne = new FakeIngredientPreparation().Generate(); + preparationOne.Text = Guid.NewGuid().ToString(); var preparationTwo = new FakeIngredientPreparation().Generate(); var fakeIngredientOne = new FakeIngredientBuilder() .WithPreparation(preparationOne) @@ -435,6 +436,7 @@ public async Task can_filter_by_string_for_nested_collection_with_alias() var testingServiceScope = new TestingServiceScope(); var faker = new Faker(); var preparationOne = new FakeIngredientPreparation().Generate(); + preparationOne.Text = Guid.NewGuid().ToString(); var preparationTwo = new FakeIngredientPreparation().Generate(); var fakeIngredientOne = new FakeIngredientBuilder() .WithPreparation(preparationOne) @@ -885,7 +887,7 @@ public async Task can_filter_nested_property_using_ownsone() .WithPhysicalAddress(new Address(faker.Address.StreetAddress() , faker.Address.SecondaryAddress() , faker.Address.City() - , faker.Address.State() + , Guid.NewGuid().ToString() , faker.Address.ZipCode() , faker.Address.Country())) .Build(); diff --git a/QueryKit.UnitTests/CustomFilterPropertyTests.cs b/QueryKit.UnitTests/CustomFilterPropertyTests.cs index 917f4df..3757a59 100644 --- a/QueryKit.UnitTests/CustomFilterPropertyTests.cs +++ b/QueryKit.UnitTests/CustomFilterPropertyTests.cs @@ -261,8 +261,7 @@ public void filter_prevented_props_always_have_true_equals_true_regardless_of_co [Fact] public void can_throw_error_when_property_has_space() { - var faker = new Faker(); - var propertyName = faker.Lorem.Sentence(); + var propertyName = "unknown property name"; var firstWord = propertyName.Split(' ').First(); var input = $"""{propertyName} == 25"""; @@ -278,8 +277,7 @@ public void can_throw_error_when_property_has_space() [Fact] public void can_handle_nonexistent_property() { - var faker = new Faker(); - var input = $"""{faker.Lorem.Word()} == 25"""; + var input = """unknownProperty == 25"""; var config = new QueryKitConfiguration(config => { diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index a2daaa3..9f49e73 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -519,8 +519,7 @@ public void can_throw_error_when_missing_double_quotes_not_recognized() [Fact] public void can_throw_error_when_property_has_space() { - var faker = new Faker(); - var propertyName = faker.Lorem.Sentence(); + var propertyName = "unknown property name"; var firstWord = propertyName.Split(' ').First(); var input = $"""{propertyName} == 25"""; var act = () => FilterParser.ParseFilter(input); From 89d17cba1ede7e5fa7e59884fdf5b7e363183297 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:06:45 +0300 Subject: [PATCH 02/14] refactor(filter): resolve filter property references in one place 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. --- QueryKit.UnitTests/FilterParserTests.cs | 9 + QueryKit/FilterParser.cs | 368 +++++++----------------- QueryKit/PropertyResolver.cs | 114 ++++++++ 3 files changed, 230 insertions(+), 261 deletions(-) create mode 100644 QueryKit/PropertyResolver.cs diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 9f49e73..87ce7fd 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -536,6 +536,15 @@ public void simple_child_collection_for_string_equal() .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => (z == "flour"))""""); } + [Fact] + public void child_collection_member_resolves_in_any_case() + { + var input = """ingredients.name == "flour" """; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToDisplayString().Should() + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => (z == "flour"))""""); + } + [Fact] public void simple_child_collection_for_string_case_insensitive_equal() { diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 7e74634..586050d 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -662,39 +662,32 @@ private static Parser ComparisonExprParser(ParameterExpression pa // Try arithmetic comparison (e.g., (price + tax) > 100) var arithmeticComparison = ArithmeticComparisonExprParser(parameter, config); - var regularComparison = CreateLeftExprParser(parameter, config) - .SelectMany(leftExpr => comparisonOperatorParser, (leftExpr, op) => new { leftExpr, op }) - .SelectMany(temp => rightSideValueParser, (temp, rightValue) => new { temp.leftExpr, temp.op, right = rightValue.Value, rightIsQuotedLiteral = rightValue.IsQuotedLiteral }) + 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 }) .Select(temp => { - if (temp.leftExpr == null) + if (temp.reference.Kind == PropertyReferenceKind.CustomOperation) { - throw new InvalidOperationException("Left expression cannot be null"); + return CreateCustomOperationExpression(parameter, temp.reference.Mapping!, temp.op, temp.right); } - if (temp.leftExpr.NodeType == ExpressionType.Constant && true.Equals(((ConstantExpression)temp.leftExpr).Value)) + if (temp.reference.Kind == PropertyReferenceKind.Unknown) { return Expression.Equal(Expression.Constant(true), Expression.Constant(true)); } - // Check if this is a custom operation placeholder - if (temp.leftExpr.NodeType == ExpressionType.Constant && - ((ConstantExpression)temp.leftExpr).Value is string constantValue && - constantValue.StartsWith("CustomOperation:")) + var leftExpr = CreateLeftExpr(parameter, temp.reference, config); + if (leftExpr.NodeType == ExpressionType.Constant && true.Equals(((ConstantExpression)leftExpr).Value)) { - var operationName = constantValue.Substring("CustomOperation:".Length); - var customOperationInfo = config?.PropertyMappings?.GetCustomOperationInfoByQueryName(operationName); - if (customOperationInfo?.CustomOperation != null) - { - return CreateCustomOperationExpression(parameter, customOperationInfo, temp.op, temp.right); - } + return Expression.Equal(Expression.Constant(true), Expression.Constant(true)); } - - if (temp.leftExpr.Type == typeof(Guid) || temp.leftExpr.Type == typeof(Guid?)) + + if (leftExpr.Type == typeof(Guid) || leftExpr.Type == typeof(Guid?)) { // Try to determine the property path for HasConversion support string? guidPropertyPath = null; - if (temp.leftExpr is MemberExpression guidMemberExpr) + if (leftExpr is MemberExpression guidMemberExpr) { guidPropertyPath = GetPropertyPath(guidMemberExpr, parameter); } @@ -703,13 +696,13 @@ private static Parser ComparisonExprParser(ParameterExpression pa // For equality/comparison operators, keep as GUID for better EF Core translation if (temp.op.IsStringComparisonOperator()) { - var guidStringExpr = HandleGuidConversion(temp.leftExpr, temp.leftExpr.Type); + var guidStringExpr = HandleGuidConversion(leftExpr, leftExpr.Type); return temp.op.GetExpression(guidStringExpr, CreateRightExpr(guidStringExpr, temp.right, temp.op, config, guidPropertyPath), config?.DbContextType, ResolveCaseMode(guidPropertyPath, config)); } // For non-string operators, use direct GUID comparison - return temp.op.GetExpression(temp.leftExpr, CreateRightExpr(temp.leftExpr, temp.right, temp.op, config, guidPropertyPath), + return temp.op.GetExpression(leftExpr, CreateRightExpr(leftExpr, temp.right, temp.op, config, guidPropertyPath), config?.DbContextType); } @@ -722,12 +715,12 @@ private static Parser ComparisonExprParser(ParameterExpression pa { // Handle GUID conversion for property-to-property comparisons // Only convert to string for string operators - var leftExpr = temp.leftExpr; + var comparedLeftExpr = leftExpr; if (temp.op.IsStringComparisonOperator()) { - if (leftExpr.Type == typeof(Guid) || leftExpr.Type == typeof(Guid?)) + if (comparedLeftExpr.Type == typeof(Guid) || comparedLeftExpr.Type == typeof(Guid?)) { - leftExpr = HandleGuidConversion(leftExpr, leftExpr.Type); + comparedLeftExpr = HandleGuidConversion(comparedLeftExpr, comparedLeftExpr.Type); } if (rightPropertyExpr.Type == typeof(Guid) || rightPropertyExpr.Type == typeof(Guid?)) { @@ -736,20 +729,20 @@ private static Parser ComparisonExprParser(ParameterExpression pa } // Ensure compatible types for property-to-property comparison - var (leftCompatible, rightCompatible) = EnsureCompatibleTypes(leftExpr, rightPropertyExpr); - var propToProptPath = temp.leftExpr is MemberExpression ptpMemberExpr ? GetPropertyPath(ptpMemberExpr, parameter) : null; + var (leftCompatible, rightCompatible) = EnsureCompatibleTypes(comparedLeftExpr, rightPropertyExpr); + var propToProptPath = leftExpr is MemberExpression ptpMemberExpr ? GetPropertyPath(ptpMemberExpr, parameter) : null; return temp.op.GetExpression(leftCompatible, rightCompatible, config?.DbContextType, ResolveCaseMode(propToProptPath, config)); } } // Try to determine the property path for HasConversion support string? propertyPath = null; - if (temp.leftExpr is MemberExpression memberExpr) + if (leftExpr is MemberExpression memberExpr) { propertyPath = GetPropertyPath(memberExpr, parameter); } - var leftExprForComparison = temp.leftExpr; + var leftExprForComparison = leftExpr; // If the left expression is a conditional with Object type, convert it to the proper type if (leftExprForComparison.Type == typeof(object)) @@ -834,153 +827,58 @@ private static Parser ComparisonExprParser(ParameterExpression pa return propertyListComparison.Or(arithmeticComparison).Or(regularComparison); } - private static Parser? CreateLeftExprParser(ParameterExpression parameter, IQueryKitConfiguration? config) + private static Parser CreateLeftExprParser(Type entityType, IQueryKitConfiguration? config) { var leftIdentifierParser = Identifier.DelimitedBy(Parse.Char('.')).Token(); - return leftIdentifierParser?.Select(left => + return leftIdentifierParser.Select(left => { - var leftList = left.ToList(); - var fullPropPath = string.Join(".", leftList); - - // Validate property depth before processing - config?.ValidatePropertyDepth(fullPropPath); - var propertyExpression = leftList?.Aggregate((Expression)parameter, (expr, propName) => + var reference = PropertyResolver.Resolve(entityType, string.Join(".", left), config); + if (reference.Kind == PropertyReferenceKind.Unknown && config?.AllowUnknownProperties != true) { - if (expr is MemberExpression member) - { - if (IsEnumerable(member.Type)) - { - var genericArgType = member.Type.GetGenericArguments()[0]; - var propertyType = genericArgType.GetProperty(propName)!.PropertyType; - - if (IsEnumerable(propertyType)) - { - propertyType = propertyType.GetGenericArguments()[0]; - - var linqMethod = "SelectMany"; - var selectMethod = typeof(Enumerable).GetMethods() - .First(m => m.Name == linqMethod && m.GetParameters().Length == 2) - .MakeGenericMethod(genericArgType, propertyType); - - var innerParameter = Expression.Parameter(genericArgType, "y"); - var propertyInfoForMethod = GetPropertyInfo(genericArgType, propName); - Expression lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod!.Name); - - // Ensure the lambda body returns IEnumerable for SelectMany - var expectedType = typeof(IEnumerable<>).MakeGenericType(propertyType); - if (lambdaBody.Type != expectedType && !expectedType.IsAssignableFrom(lambdaBody.Type)) - { - // Convert to IEnumerable if needed (e.g., List to IEnumerable) - lambdaBody = Expression.Convert(lambdaBody, expectedType); - } - - // Create lambda with the correct return type - var lambdaType = typeof(Func<,>).MakeGenericType(genericArgType, expectedType); - lambdaBody = Expression.Lambda(lambdaType, lambdaBody, innerParameter); - - return Expression.Call(selectMethod, member, lambdaBody); - } - else - { - var selectMethod = typeof(Enumerable).GetMethods() - .First(m => m.Name == "Select" && m.GetParameters().Length == 2) - .MakeGenericMethod(genericArgType, genericArgType.GetProperty(propName)!.PropertyType); - - var innerParameter = Expression.Parameter(genericArgType, "y"); - var propertyInfoForMethod = GetPropertyInfo(genericArgType, propName); - var lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod!.Name); - var selectLambda = Expression.Lambda(lambdaBody, innerParameter); - var selectResult = Expression.Call(null, selectMethod, member, selectLambda); - - return HandleGuidConversion(selectResult, propertyType, "Select"); - } - } - } - - if (expr is MethodCallExpression call) - { - var innerGenericType = GetInnerGenericType(call.Method.ReturnType); - var propertyInfoForMethod = GetPropertyInfo(innerGenericType!, propName); - - var propertyType = propertyInfoForMethod!.PropertyType; - var linqMethod = IsEnumerable(propertyType) ? "SelectMany" : "Select"; - var resultType = IsEnumerable(propertyType) ? propertyType.GetGenericArguments()[0] : propertyType; - - var selectMethod = typeof(Enumerable).GetMethods() - .First(m => m.Name == linqMethod && m.GetParameters().Length == 2) - .MakeGenericMethod(innerGenericType!, resultType); - - var innerParameter = Expression.Parameter(innerGenericType!, "y"); - var lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod.Name); - var selectLambda = Expression.Lambda(lambdaBody, innerParameter); - - return Expression.Call(selectMethod, expr, selectLambda); - } - - var propertyInfo = GetPropertyInfo(expr.Type, propName); - var actualPropertyName = propertyInfo?.Name ?? propName; - try - { - return Expression.PropertyOrField(expr, actualPropertyName); - } - catch(ArgumentException) - { - // Check for custom operations first - var customOperationInfo = config?.PropertyMappings?.GetCustomOperationInfoByQueryName(fullPropPath); - if (customOperationInfo?.CustomOperation != null) - { - // Custom operations will be handled in the comparison parsing, so return a placeholder - return Expression.Constant($"CustomOperation:{fullPropPath}", typeof(string)); - } + throw new UnknownFilterPropertyException(reference.UnknownSegment!); + } - var derivedPropertyInfo = config?.PropertyMappings?.GetDerivedPropertyInfoByQueryName(fullPropPath); - if (derivedPropertyInfo?.DerivedExpression != null) - { - return derivedPropertyInfo.DerivedExpression; - } - - if(config?.AllowUnknownProperties == true) - { - return Expression.Constant(true, typeof(bool)); - } + return reference; + }); + } - throw new UnknownFilterPropertyException(actualPropertyName); - } - }); + private static Expression CreateLeftExpr(ParameterExpression parameter, PropertyReference reference, IQueryKitConfiguration? config) + { + var propertyExpression = reference.Kind == PropertyReferenceKind.DerivedProperty + ? reference.Mapping!.DerivedExpression! + : CreateMemberExpression(parameter, reference.Path); - var propertyConfig = config?.PropertyMappings?.GetPropertyInfo(fullPropPath); - if (propertyConfig != null && !propertyConfig.CanFilter) - { - return Expression.Constant(true, typeof(bool)); - } + var propertyConfig = config?.PropertyMappings?.GetPropertyInfo(reference.Text); + if (propertyConfig != null && !propertyConfig.CanFilter) + { + return Expression.Constant(true, typeof(bool)); + } - // Check if this property uses HasConversion - var currentPropertyConfig = config?.PropertyMappings?.GetPropertyInfo(fullPropPath); - if (currentPropertyConfig?.UsesConversion == true) - { - // For HasConversion properties, return the property expression as-is - // EF Core will handle the type conversion automatically when it translates the expression to SQL - // The key is that the right-side value will be converted to match the property's conversion target type - return propertyExpression; - } + // Check if this property uses HasConversion + if (propertyConfig?.UsesConversion == true) + { + // For HasConversion properties, return the property expression as-is + // EF Core will handle the type conversion automatically when it translates the expression to SQL + // The key is that the right-side value will be converted to match the property's conversion target type + return propertyExpression; + } + + // Also check if this is a nested property where the parent has HasConversion configured + if (propertyExpression is MemberExpression nestedMemberExpression && + nestedMemberExpression.Expression is MemberExpression parentExpression) + { + var parentPropertyPath = GetPropertyPath(parentExpression, parameter); + var parentPropertyConfig = config?.PropertyMappings?.GetPropertyInfo(parentPropertyPath); - // Also check if this is a nested property where the parent has HasConversion configured - if (propertyExpression is MemberExpression nestedMemberExpression && - nestedMemberExpression.Expression is MemberExpression parentExpression) + if (parentPropertyConfig?.UsesConversion == true) { - var parentPropertyPath = GetPropertyPath(parentExpression, parameter); - var parentPropertyConfig = config?.PropertyMappings?.GetPropertyInfo(parentPropertyPath); - - if (parentPropertyConfig?.UsesConversion == true) - { - // Use the parent expression instead of the nested property - return parentExpression; - } + // Use the parent expression instead of the nested property + return parentExpression; } + } - return propertyExpression; - }); + return propertyExpression; } private static string GetPropertyPath(MemberExpression memberExpression, ParameterExpression parameter) @@ -1001,107 +899,69 @@ private static string GetPropertyPath(MemberExpression memberExpression, Paramet return string.Join(".", parts); } - private static Expression CreatePropertyExpressionFromPath( - ParameterExpression parameter, - List propertyPath, - IQueryKitConfiguration? config) + // Builds the access expression for a resolved member path. A member of a collection element becomes a Select, or a SelectMany when the member is a collection too. + private static Expression CreateMemberExpression(ParameterExpression parameter, string memberPath) { - var fullPropPath = string.Join(".", propertyPath); - - // Validate property depth before processing - config?.ValidatePropertyDepth(fullPropPath); - - return propertyPath.Aggregate((Expression)parameter, (expr, propName) => + return memberPath.Split('.').Aggregate((Expression)parameter, (expr, memberName) => { - if (expr is MemberExpression member) + if (expr is MemberExpression member && IsEnumerable(member.Type)) { - if (IsEnumerable(member.Type)) - { - var genericArgType = member.Type.GetGenericArguments()[0]; - var propertyType = genericArgType.GetProperty(propName)!.PropertyType; + var genericArgType = member.Type.GetGenericArguments()[0]; + var innerParameter = Expression.Parameter(genericArgType, "y"); + Expression lambdaBody = Expression.PropertyOrField(innerParameter, memberName); + var propertyType = lambdaBody.Type; - if (IsEnumerable(propertyType)) - { - propertyType = propertyType.GetGenericArguments()[0]; - - var linqMethod = "SelectMany"; - var selectMethod = typeof(Enumerable).GetMethods() - .First(m => m.Name == linqMethod && m.GetParameters().Length == 2) - .MakeGenericMethod(genericArgType, propertyType); + if (IsEnumerable(propertyType)) + { + propertyType = propertyType.GetGenericArguments()[0]; - var innerParameter = Expression.Parameter(genericArgType, "y"); - var propertyInfoForMethod = GetPropertyInfo(genericArgType, propName); - Expression lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod!.Name); + var selectManyMethod = typeof(Enumerable).GetMethods() + .First(m => m.Name == "SelectMany" && m.GetParameters().Length == 2) + .MakeGenericMethod(genericArgType, propertyType); - var expectedType = typeof(IEnumerable<>).MakeGenericType(propertyType); - if (lambdaBody.Type != expectedType && !expectedType.IsAssignableFrom(lambdaBody.Type)) - { - lambdaBody = Expression.Convert(lambdaBody, expectedType); - } + // Ensure the lambda body returns IEnumerable for SelectMany + var expectedType = typeof(IEnumerable<>).MakeGenericType(propertyType); + if (lambdaBody.Type != expectedType && !expectedType.IsAssignableFrom(lambdaBody.Type)) + { + // Convert to IEnumerable if needed (e.g., List to IEnumerable) + lambdaBody = Expression.Convert(lambdaBody, expectedType); + } - var lambdaType = typeof(Func<,>).MakeGenericType(genericArgType, expectedType); - lambdaBody = Expression.Lambda(lambdaType, lambdaBody, innerParameter); + // Create lambda with the correct return type + var lambdaType = typeof(Func<,>).MakeGenericType(genericArgType, expectedType); + return Expression.Call(selectManyMethod, member, Expression.Lambda(lambdaType, lambdaBody, innerParameter)); + } - return Expression.Call(selectMethod, member, lambdaBody); - } - else - { - var selectMethod = typeof(Enumerable).GetMethods() - .First(m => m.Name == "Select" && m.GetParameters().Length == 2) - .MakeGenericMethod(genericArgType, genericArgType.GetProperty(propName)!.PropertyType); + var selectMethod = typeof(Enumerable).GetMethods() + .First(m => m.Name == "Select" && m.GetParameters().Length == 2) + .MakeGenericMethod(genericArgType, propertyType); - var innerParameter = Expression.Parameter(genericArgType, "y"); - var propertyInfoForMethod = GetPropertyInfo(genericArgType, propName); - var lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod!.Name); - var selectLambda = Expression.Lambda(lambdaBody, innerParameter); - var selectResult = Expression.Call(null, selectMethod, member, selectLambda); + var selectLambda = Expression.Lambda(lambdaBody, innerParameter); + var selectResult = Expression.Call(null, selectMethod, member, selectLambda); - return HandleGuidConversion(selectResult, propertyType, "Select"); - } - } + return HandleGuidConversion(selectResult, propertyType, "Select"); } if (expr is MethodCallExpression call) { - var innerGenericType = GetInnerGenericType(call.Method.ReturnType); - var propertyInfoForMethod = GetPropertyInfo(innerGenericType!, propName); + var innerGenericType = GetInnerGenericType(call.Method.ReturnType)!; + var innerParameter = Expression.Parameter(innerGenericType, "y"); + var lambdaBody = Expression.PropertyOrField(innerParameter, memberName); - var propertyType = propertyInfoForMethod!.PropertyType; + var propertyType = lambdaBody.Type; var linqMethod = IsEnumerable(propertyType) ? "SelectMany" : "Select"; var resultType = IsEnumerable(propertyType) ? propertyType.GetGenericArguments()[0] : propertyType; var selectMethod = typeof(Enumerable).GetMethods() .First(m => m.Name == linqMethod && m.GetParameters().Length == 2) - .MakeGenericMethod(innerGenericType!, resultType); + .MakeGenericMethod(innerGenericType, resultType); - var innerParameter = Expression.Parameter(innerGenericType!, "y"); - var lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod.Name); var selectLambda = Expression.Lambda(lambdaBody, innerParameter); return Expression.Call(selectMethod, expr, selectLambda); } - var propertyInfo = GetPropertyInfo(expr.Type, propName); - var actualPropertyName = propertyInfo?.Name ?? propName; - try - { - return Expression.PropertyOrField(expr, actualPropertyName); - } - catch(ArgumentException) - { - var derivedPropertyInfo = config?.PropertyMappings?.GetDerivedPropertyInfoByQueryName(fullPropPath); - if (derivedPropertyInfo?.DerivedExpression != null) - { - return derivedPropertyInfo.DerivedExpression; - } - - if(config?.AllowUnknownProperties == true) - { - return Expression.Constant(true, typeof(bool)); - } - - throw new UnknownFilterPropertyException(actualPropertyName); - } + return Expression.PropertyOrField(expr, memberName); }); } @@ -1142,36 +1002,22 @@ private static Parser PropertyListComparisonExprParser( continue; } - // Build expression for each property - var leftExpr = CreatePropertyExpressionFromPath( - parameter, propertyPathList, config); - - // Skip if it's a placeholder for unknown properties - if (leftExpr.NodeType == ExpressionType.Constant && - ((ConstantExpression)leftExpr).Value!.Equals(true)) - { - continue; - } - - // Handle custom operations - if (leftExpr.NodeType == ExpressionType.Constant && - ((ConstantExpression)leftExpr).Value is string constantValue && - constantValue.StartsWith("CustomOperation:")) + // Build expression for each property. A property list does not support custom operations. + var reference = PropertyResolver.Resolve(parameter.Type, fullPropPath, config); + if (reference.Kind is PropertyReferenceKind.Unknown or PropertyReferenceKind.CustomOperation) { - var operationName = constantValue.Substring("CustomOperation:".Length); - var customOperationInfo = config?.PropertyMappings?.GetCustomOperationInfoByQueryName(operationName); - if (customOperationInfo?.CustomOperation != null) + if (config?.AllowUnknownProperties == true) { - var customComparison = CreateCustomOperationExpression(parameter, customOperationInfo, temp.op, temp.right); - result = result == null - ? customComparison - : isNegativeOperator - ? Expression.AndAlso(result, customComparison) - : Expression.OrElse(result, customComparison); continue; } + + throw new UnknownFilterPropertyException(reference.UnknownSegment!); } + var leftExpr = reference.Kind == PropertyReferenceKind.DerivedProperty + ? reference.Mapping!.DerivedExpression! + : CreateMemberExpression(parameter, reference.Path); + // Use the resolved member path for HasConversion support, since the typed path can differ in casing var resolvedPropPath = leftExpr is MemberExpression listMemberExpr ? GetPropertyPath(listMemberExpr, parameter) diff --git a/QueryKit/PropertyResolver.cs b/QueryKit/PropertyResolver.cs new file mode 100644 index 0000000..950e0cb --- /dev/null +++ b/QueryKit/PropertyResolver.cs @@ -0,0 +1,114 @@ +namespace QueryKit; + +using System.Reflection; +using Configuration; + +internal enum PropertyReferenceKind +{ + Member, + DerivedProperty, + CustomOperation, + Unknown +} + +/// +/// A property reference from a filter or sort string, resolved against the entity type and the configuration. +/// +internal sealed class PropertyReference +{ + private PropertyReference(PropertyReferenceKind kind, string text, string path, QueryKitPropertyInfo? mapping, string? unknownSegment) + { + Kind = kind; + Text = text; + Path = path; + Mapping = mapping; + UnknownSegment = unknownSegment; + } + + public PropertyReferenceKind Kind { get; } + + /// The reference as the caller wrote it. + public string Text { get; } + + /// For a member, the real member names joined with '.'. For other kinds, the reference text. + public string Path { get; } + + /// The configuration of a derived property or a custom operation. + public QueryKitPropertyInfo? Mapping { get; } + + /// When the reference is not a member, the first path segment that did not resolve to a member. + public string? UnknownSegment { get; } + + internal static PropertyReference Member(string text, string path) + => new(PropertyReferenceKind.Member, text, path, null, null); + + internal static PropertyReference NotMember(PropertyReferenceKind kind, string text, QueryKitPropertyInfo? mapping, string unknownSegment) + => new(kind, text, text, mapping, unknownSegment); +} + +/// +/// Resolves every property reference in a filter or sort string the same way. +/// +internal static class PropertyResolver +{ + internal static PropertyReference Resolve(Type rootType, string reference, IQueryKitConfiguration? config) + { + config?.ValidatePropertyDepth(reference); + + var memberPath = ResolveMemberPath(rootType, reference, out var unknownSegment); + if (memberPath != null) + { + return PropertyReference.Member(reference, memberPath); + } + + var customOperationInfo = config?.PropertyMappings?.GetCustomOperationInfoByQueryName(reference); + if (customOperationInfo?.CustomOperation != null) + { + return PropertyReference.NotMember(PropertyReferenceKind.CustomOperation, reference, customOperationInfo, unknownSegment!); + } + + var derivedPropertyInfo = config?.PropertyMappings?.GetDerivedPropertyInfoByQueryName(reference); + if (derivedPropertyInfo?.DerivedExpression != null) + { + return PropertyReference.NotMember(PropertyReferenceKind.DerivedProperty, reference, derivedPropertyInfo, unknownSegment!); + } + + return PropertyReference.NotMember(PropertyReferenceKind.Unknown, reference, null, unknownSegment!); + } + + // Matches each segment to a public member, ignoring case. A segment after a collection resolves on the element type. + private static string? ResolveMemberPath(Type rootType, string path, out string? unknownSegment) + { + var memberNames = new List(); + var currentType = rootType; + + foreach (var segment in path.Split('.')) + { + while (IsCollection(currentType)) + { + currentType = currentType.GetGenericArguments()[0]; + } + + var member = (MemberInfo?)currentType.GetProperty(segment, MemberFlags) + ?? currentType.GetField(segment, MemberFlags); + if (member == null) + { + unknownSegment = segment; + return null; + } + + memberNames.Add(member.Name); + currentType = member is PropertyInfo property ? property.PropertyType : ((FieldInfo)member).FieldType; + } + + unknownSegment = null; + return string.Join(".", memberNames); + } + + private const BindingFlags MemberFlags = BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance; + + private static bool IsCollection(Type type) + => type != typeof(string) && type.IsGenericType && + (type.GetGenericTypeDefinition() == typeof(IEnumerable<>) || + type.GetInterfaces().Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEnumerable<>))); +} From 91debe7b41c67b22252d6516f715de62ba15cf29 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:09:59 +0300 Subject: [PATCH 03/14] fix(filter): remove unknown and prevented clauses instead of using true 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. --- .../Tests/PropertyResolverTests.cs | 66 ++++++++++++++++ .../CustomFilterPropertyTests.cs | 10 +-- QueryKit.UnitTests/PropertyResolverTests.cs | 79 +++++++++++++++++++ .../Expressions/RemovedClauseExpression.cs | 19 +++++ QueryKit/FilterParser.cs | 39 +++++++-- 5 files changed, 200 insertions(+), 13 deletions(-) create mode 100644 QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs create mode 100644 QueryKit.UnitTests/PropertyResolverTests.cs create mode 100644 QueryKit/Expressions/RemovedClauseExpression.cs diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs new file mode 100644 index 0000000..fbedf42 --- /dev/null +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -0,0 +1,66 @@ +namespace QueryKit.IntegrationTests.Tests; + +using Bogus; +using Configuration; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SharedTestingHelper.Fakes; +using WebApiTestProject.Entities; + +public class PropertyResolverTests : TestBase +{ + [Fact] + public async Task unknown_property_clause_under_or_does_not_return_every_row() + { + // 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 == "x" || Age > 100)"""; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().BeEmpty(); + } + + [Fact] + public async Task prevented_property_clause_under_or_does_not_return_every_row() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = new Faker().Lorem.Sentence(); + var fakePerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithAge(30) + .WithRating(1) + .Build(); + await testingServiceScope.InsertAsync(fakePerson); + + var input = $"""Title == "{title}" && (Rating == 1 || Age > 100)"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Rating).PreventFilter(); + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().BeEmpty(); + } +} diff --git a/QueryKit.UnitTests/CustomFilterPropertyTests.cs b/QueryKit.UnitTests/CustomFilterPropertyTests.cs index 3757a59..a41cbdf 100644 --- a/QueryKit.UnitTests/CustomFilterPropertyTests.cs +++ b/QueryKit.UnitTests/CustomFilterPropertyTests.cs @@ -142,7 +142,7 @@ public void can_have_custom_prop_excluded_from_filter() config.Property(x => x.Id).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (True == True))"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "{stringValue}")"""); } [Fact] @@ -159,7 +159,7 @@ public void can_have_custom_prop_excluded_from_filter_with_custom_propname() config.Property(x => x.Id).HasQueryName("identifier").PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (True == True))"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "{stringValue}")"""); } [Fact] @@ -243,7 +243,7 @@ public void can_have_derived_prop_work_with_collection_filters() } [Fact] - public void filter_prevented_props_always_have_true_equals_true_regardless_of_comparison() + public void filter_prevented_props_are_removed_regardless_of_comparison() { var faker = new Faker(); var filterOperator = faker.PickRandom(ComparisonOperator.List.Where(x => x != ComparisonOperator.EqualsOperator()).ToList()); @@ -255,7 +255,7 @@ public void filter_prevented_props_always_have_true_equals_true_regardless_of_co config.Property(x => x.Id).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => (True == True)"""); + filterExpression.ToDisplayString().Should().Be("x => True"); } [Fact] @@ -284,6 +284,6 @@ public void can_handle_nonexistent_property() config.AllowUnknownProperties = true; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (True == True)"); + filterExpression.ToDisplayString().Should().Be("x => True"); } } \ No newline at end of file diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs new file mode 100644 index 0000000..5f757fa --- /dev/null +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -0,0 +1,79 @@ +namespace QueryKit.UnitTests; + +using Configuration; +using FluentAssertions; +using WebApiTestProject.Entities; + +public class PropertyResolverTests +{ + [Fact] + public void unknown_property_clause_is_removed_under_or() + { + var input = """Nope == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void unknown_property_clause_is_removed_under_and() + { + var input = """Age > 100 && Nope == "x" """; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void prevented_property_clause_is_removed_under_or() + { + var input = """Rating == 1 || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Rating).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void removed_clause_in_a_group_is_removed_from_the_group() + { + var input = """Title == "a" && (Nope == "x" || Age > 100)"""; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => ((x.Title == "a") AndAlso (x.Age > 100))"""); + } + + [Fact] + public void property_list_with_only_prevented_properties_is_removed() + { + var input = """(Title, FirstName) == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).PreventFilter(); + config.Property(x => x.FirstName).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } +} diff --git a/QueryKit/Expressions/RemovedClauseExpression.cs b/QueryKit/Expressions/RemovedClauseExpression.cs new file mode 100644 index 0000000..f0be49c --- /dev/null +++ b/QueryKit/Expressions/RemovedClauseExpression.cs @@ -0,0 +1,19 @@ +namespace QueryKit.Expressions; + +using System.Linq.Expressions; + +/// +/// Marks a filter clause that the parser removed, for example a clause on an unknown property. +/// A logical operator with a removed side becomes its other side, so the clause has no effect on the result. +/// +internal sealed class RemovedClauseExpression : Expression +{ + public static readonly RemovedClauseExpression Instance = new(); + + private RemovedClauseExpression() + { + } + + public override ExpressionType NodeType => ExpressionType.Extension; + public override Type Type => typeof(bool); +} diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 586050d..5ee2ee6 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -31,6 +31,13 @@ public static Expression> ParseFilter(string input, IQueryKitCo try { expr = ExprParser(parameter, config).End().Parse(input); + + // When the parser removed every clause, no clause limits the result + if (expr is RemovedClauseExpression) + { + expr = Expression.Constant(true); + } + expr = ReplaceDerivedProperties(expr, config, parameter); } catch (InvalidOperationException e) @@ -674,13 +681,13 @@ private static Parser ComparisonExprParser(ParameterExpression pa if (temp.reference.Kind == PropertyReferenceKind.Unknown) { - return Expression.Equal(Expression.Constant(true), Expression.Constant(true)); + return RemovedClauseExpression.Instance; } var leftExpr = CreateLeftExpr(parameter, temp.reference, config); - if (leftExpr.NodeType == ExpressionType.Constant && true.Equals(((ConstantExpression)leftExpr).Value)) + if (leftExpr is RemovedClauseExpression) { - return Expression.Equal(Expression.Constant(true), Expression.Constant(true)); + return leftExpr; } if (leftExpr.Type == typeof(Guid) || leftExpr.Type == typeof(Guid?)) @@ -852,7 +859,7 @@ private static Expression CreateLeftExpr(ParameterExpression parameter, Property var propertyConfig = config?.PropertyMappings?.GetPropertyInfo(reference.Text); if (propertyConfig != null && !propertyConfig.CanFilter) { - return Expression.Constant(true, typeof(bool)); + return RemovedClauseExpression.Instance; } // Check if this property uses HasConversion @@ -1041,8 +1048,8 @@ private static Parser PropertyListComparisonExprParser( : Expression.OrElse(result, comparison); } - // If all properties were filtered out, return true - return result ?? Expression.Constant(true, typeof(bool)); + // If all properties were filtered out, remove the clause + return result ?? RemovedClauseExpression.Instance; }); } @@ -1068,15 +1075,31 @@ private static Parser AndExprParser(ParameterExpression parameter => Parse.ChainOperator( LogicalOperatorParser.Where(x => x.Name == LogicalOperator.AndOperator.Operator()), AtomicExprParser(parameter, config), - (op, left, right) => op.GetExpression(left, right) + CombineClauses ); private static Parser OrExprParser(ParameterExpression parameter, IQueryKitConfiguration? config = null) => Parse.ChainOperator( LogicalOperatorParser.Where(x => x.Name == LogicalOperator.OrOperator.Operator()), AndExprParser(parameter, config), - (op, left, right) => op.GetExpression(left, right) + CombineClauses ); + + // A removed clause has no effect, so the operator keeps only the other side + private static Expression CombineClauses(LogicalOperator op, Expression left, Expression right) + { + if (left is RemovedClauseExpression) + { + return right; + } + + if (right is RemovedClauseExpression) + { + return left; + } + + return op.GetExpression(left, right); + } private static Expression GetGuidToStringExpression(Expression leftExpr) { From 797ff0fe535573f5401a2d54650b56052cbcbf5a Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:13:26 +0300 Subject: [PATCH 04/14] fix(filter): check permission and depth for properties in arithmetic Arithmetic comparisons now resolve each property through the property resolver. A property with PreventFilter removes the clause, and MaxPropertyDepth applies to the property path. --- .../Tests/PropertyResolverTests.cs | 27 ++++++++++++ QueryKit.UnitTests/PropertyResolverTests.cs | 44 +++++++++++++++++++ QueryKit/FilterParser.cs | 37 +++++++++++++++- QueryKit/PropertyResolver.cs | 10 +++-- 4 files changed, 112 insertions(+), 6 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index fbedf42..b8ed914 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -63,4 +63,31 @@ public async Task prevented_property_clause_under_or_does_not_return_every_row() // Assert people.Should().BeEmpty(); } + + [Fact] + public async Task prevented_property_in_arithmetic_is_not_filtered() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = new Faker().Lorem.Sentence(); + var fakePerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithAge(5) + .Build(); + await testingServiceScope.InsertAsync(fakePerson); + + var input = $"""Title == "{title}" && (Age + 0) > 10"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Age).PreventFilter(); + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().ContainSingle(x => x.Id == fakePerson.Id); + } } diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 5f757fa..872f3d9 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -1,8 +1,10 @@ namespace QueryKit.UnitTests; using Configuration; +using Exceptions; using FluentAssertions; using WebApiTestProject.Entities; +using WebApiTestProject.Entities.Ingredients; public class PropertyResolverTests { @@ -76,4 +78,46 @@ public void property_list_with_only_prevented_properties_is_removed() filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); } + + [Fact] + public void prevented_property_in_arithmetic_removes_the_clause() + { + var input = """(Age + 0) > 10 || Title == "a" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Age).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "a")"""); + } + + [Fact] + public void prevented_property_on_the_right_side_of_arithmetic_removes_the_clause() + { + var input = """(Age + 0) > (Rating * 2)"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Rating).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => True"); + } + + [Fact] + public void arithmetic_property_obeys_max_property_depth() + { + var input = """(Recipe.Rating + 0) > 1"""; + var config = new QueryKitConfiguration(config => + { + config.MaxPropertyDepth = 0; + }); + + var act = () => FilterParser.ParseFilter(input, config); + + act.Should().Throw(); + } } diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 5ee2ee6..ba6cdb8 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -599,14 +599,47 @@ 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); + if (leftArithmetic == null || rightArithmetic == null) + { + return RemovedClauseExpression.Instance; + } + + 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. + // Returns null when a property cannot be filtered, because then the parser removes the clause. + 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 reference.CanFilter ? new PropertyArithmeticExpression(reference.Path) : null; + case BinaryArithmeticExpression binary: + var left = ResolveArithmeticProperties(binary.Left, entityType, config); + var right = ResolveArithmeticProperties(binary.Right, entityType, config); + return left == null || right == null ? null : new BinaryArithmeticExpression(left, binary.Operator, right); + case GroupedArithmeticExpression grouped: + var inner = ResolveArithmeticProperties(grouped.Inner, entityType, config); + return inner == null ? null : new GroupedArithmeticExpression(inner); + default: + return expr; + } + } + private static bool ContainsArithmeticOperator(ArithmeticExpression expr) { return expr switch diff --git a/QueryKit/PropertyResolver.cs b/QueryKit/PropertyResolver.cs index 950e0cb..3d3b9ce 100644 --- a/QueryKit/PropertyResolver.cs +++ b/QueryKit/PropertyResolver.cs @@ -33,14 +33,16 @@ private PropertyReference(PropertyReferenceKind kind, string text, string path, /// For a member, the real member names joined with '.'. For other kinds, the reference text. public string Path { get; } - /// The configuration of a derived property or a custom operation. + /// The configuration of the member, the derived property, or the custom operation, if there is one. public QueryKitPropertyInfo? Mapping { get; } + public bool CanFilter => Mapping?.CanFilter ?? true; + /// When the reference is not a member, the first path segment that did not resolve to a member. public string? UnknownSegment { get; } - internal static PropertyReference Member(string text, string path) - => new(PropertyReferenceKind.Member, text, path, null, null); + internal static PropertyReference Member(string text, string path, QueryKitPropertyInfo? mapping) + => new(PropertyReferenceKind.Member, text, path, mapping, null); internal static PropertyReference NotMember(PropertyReferenceKind kind, string text, QueryKitPropertyInfo? mapping, string unknownSegment) => new(kind, text, text, mapping, unknownSegment); @@ -58,7 +60,7 @@ internal static PropertyReference Resolve(Type rootType, string reference, IQuer var memberPath = ResolveMemberPath(rootType, reference, out var unknownSegment); if (memberPath != null) { - return PropertyReference.Member(reference, memberPath); + return PropertyReference.Member(reference, memberPath, config?.PropertyMappings?.GetPropertyInfo(memberPath)); } var customOperationInfo = config?.PropertyMappings?.GetCustomOperationInfoByQueryName(reference); From 61402758d12490346c14a8a21de0d6c7676641f5 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:15:42 +0300 Subject: [PATCH 05/14] fix(filter): check permission for a property on the right side A comparison such as FirstName == Title did not check PreventFilter on Title. A prevented property on the right side now removes the clause. --- .../Tests/PropertyResolverTests.cs | 29 +++++++++++++++++++ QueryKit.UnitTests/PropertyResolverTests.cs | 28 ++++++++++++++++++ QueryKit/FilterParser.cs | 5 ++++ 3 files changed, 62 insertions(+) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index b8ed914..9b2223a 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -90,4 +90,33 @@ public async Task prevented_property_in_arithmetic_is_not_filtered() // Assert people.Should().ContainSingle(x => x.Id == fakePerson.Id); } + + [Fact] + public async Task prevented_property_on_the_right_side_is_not_compared() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = new Faker().Lorem.Sentence(); + var fakePerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithFirstName("Same") + .WithLastName("Same") + .WithAge(30) + .Build(); + await testingServiceScope.InsertAsync(fakePerson); + + var input = $"""Title == "{title}" && (FirstName == LastName || Age > 100)"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.LastName).PreventFilter(); + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().BeEmpty(); + } } diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 872f3d9..0803044 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -120,4 +120,32 @@ public void arithmetic_property_obeys_max_property_depth() act.Should().Throw(); } + + [Fact] + public void prevented_property_on_the_right_side_removes_the_clause() + { + var input = """FirstName == Title || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void prevented_property_on_the_right_side_removes_the_clause_in_any_case() + { + var input = """FirstName == title || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } } diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index ba6cdb8..46a9f70 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -750,6 +750,11 @@ private static Parser ComparisonExprParser(ParameterExpression pa // A quoted string literal is always a value, even when its text matches a property name. if (!temp.rightIsQuotedLiteral && IsPropertyPath(temp.right, parameter.Type)) { + if (!PropertyResolver.Resolve(parameter.Type, temp.right, config).CanFilter) + { + return RemovedClauseExpression.Instance; + } + var rightPropertyExpr = CreateRightPropertyExpr(parameter, temp.right, config); if (rightPropertyExpr != null) { From 5008c932740a656f7cbb157e68f854f8ffb8d2b4 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:18:52 +0300 Subject: [PATCH 06/14] fix(filter): look up property settings by the resolved member path 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. --- .../Tests/PropertyResolverTests.cs | 28 ++++++++++ QueryKit.UnitTests/PropertyResolverTests.cs | 56 +++++++++++++++++++ QueryKit/FilterParser.cs | 12 ++-- 3 files changed, 89 insertions(+), 7 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 9b2223a..069e2eb 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -119,4 +119,32 @@ public async Task prevented_property_on_the_right_side_is_not_compared() // Assert people.Should().BeEmpty(); } + + [Fact] + public async Task prevented_property_in_a_list_is_not_filtered_in_any_case() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = new Faker().Lorem.Sentence(); + var fakePerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithFirstName("Paul") + .WithLastName("Other") + .Build(); + await testingServiceScope.InsertAsync(fakePerson); + + var input = $"""Title == "{title}" && (firstname, LastName) == "Paul" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.FirstName).PreventFilter(); + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().BeEmpty(); + } } diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 0803044..56d8345 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -148,4 +148,60 @@ public void prevented_property_on_the_right_side_removes_the_clause_in_any_case( filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); } + + [Fact] + public void prevented_property_in_a_list_is_skipped_in_any_case() + { + var input = """(title, FirstName) == "x" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "x")"""); + } + + [Fact] + public void prevented_property_removes_the_clause_in_any_case() + { + var input = """title == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void prevented_property_with_a_query_name_removes_the_clause_when_written_by_its_member_name_in_any_case() + { + var input = """title == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("t").PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void property_in_a_list_uses_its_case_insensitive_mode_in_any_case() + { + var input = """(title) @=* "x" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasCaseInsensitiveMode(CaseInsensitiveMode.Upper); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Contain("ToUpper()"); + } } diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 46a9f70..e3bbcb8 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -894,7 +894,7 @@ private static Expression CreateLeftExpr(ParameterExpression parameter, Property ? reference.Mapping!.DerivedExpression! : CreateMemberExpression(parameter, reference.Path); - var propertyConfig = config?.PropertyMappings?.GetPropertyInfo(reference.Text); + var propertyConfig = reference.Kind == PropertyReferenceKind.Member ? reference.Mapping : null; if (propertyConfig != null && !propertyConfig.CanFilter) { return RemovedClauseExpression.Instance; @@ -1040,15 +1040,13 @@ private static Parser PropertyListComparisonExprParser( var propertyPathList = propertyPath.ToList(); var fullPropPath = string.Join(".", propertyPathList); - // Check if property can be filtered - var propertyConfig = config?.PropertyMappings?.GetPropertyInfo(fullPropPath); - if (propertyConfig != null && !propertyConfig.CanFilter) + // Build expression for each property. A property list does not support custom operations. + var reference = PropertyResolver.Resolve(parameter.Type, fullPropPath, config); + if (reference.Kind == PropertyReferenceKind.Member && !reference.CanFilter) { continue; } - // Build expression for each property. A property list does not support custom operations. - var reference = PropertyResolver.Resolve(parameter.Type, fullPropPath, config); if (reference.Kind is PropertyReferenceKind.Unknown or PropertyReferenceKind.CustomOperation) { if (config?.AllowUnknownProperties == true) @@ -1076,7 +1074,7 @@ private static Parser PropertyListComparisonExprParser( } var rightExpr = CreateRightExpr(leftExpr, temp.right, temp.op, config, resolvedPropPath); - var comparison = temp.op.GetExpression(leftExpr, rightExpr, config?.DbContextType, ResolveCaseMode(fullPropPath, config)); + var comparison = temp.op.GetExpression(leftExpr, rightExpr, config?.DbContextType, ResolveCaseMode(reference.Path, config)); // Combine with AND for negative operators, OR for positive operators result = result == null From d5f27a06423e0c63a9b69b34955bc03753d018fe Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:22:30 +0300 Subject: [PATCH 07/14] fix(sort): look up the sort permission by 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. --- .../Tests/PropertyResolverTests.cs | 34 +++++++++++++++++++ QueryKit.UnitTests/PropertyResolverTests.cs | 29 ++++++++++++++++ .../QueryKitConfigurationExtensions.cs | 5 --- QueryKit/PropertyResolver.cs | 2 ++ QueryKit/SortParser.cs | 3 +- 5 files changed, 67 insertions(+), 6 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 069e2eb..82c5c53 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -147,4 +147,38 @@ public async Task prevented_property_in_a_list_is_not_filtered_in_any_case() // Assert people.Should().BeEmpty(); } + + [Fact] + public async Task prevented_sort_property_with_a_query_name_is_not_sorted_when_written_by_its_member_name() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = new Faker().Lorem.Sentence(); + var firstPerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithFirstName("A") + .WithAge(1) + .Build(); + var secondPerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithFirstName("B") + .WithAge(2) + .Build(); + await testingServiceScope.InsertAsync(firstPerson, secondPerson); + + var input = "firstname desc, Age"; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.FirstName).HasQueryName("first").PreventSort(); + }); + + // Act + var people = await testingServiceScope.DbContext().People + .Where(x => x.Title == title) + .ApplyQueryKitSort(input, config) + .ToListAsync(); + + // Assert + people.Select(x => x.Id).Should().Equal(firstPerson.Id, secondPerson.Id); + } } diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 56d8345..d1f80e6 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -204,4 +204,33 @@ public void property_in_a_list_uses_its_case_insensitive_mode_in_any_case() filterExpression.ToDisplayString().Should().Contain("ToUpper()"); } + + [Fact] + public void prevented_sort_property_is_skipped_in_any_case() + { + var input = "title, Age desc"; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).PreventSort(); + }); + + var sortExpressions = SortParser.ParseSort(input, config); + + sortExpressions.Should().ContainSingle(); + sortExpressions[0].Expression!.ToString().Should().Be("x => Convert(x.Age, Object)"); + } + + [Fact] + public void prevented_sort_property_with_a_query_name_is_skipped_when_written_by_its_member_name() + { + var input = "title desc"; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("t").PreventSort(); + }); + + var sortExpressions = SortParser.ParseSort(input, config); + + sortExpressions.Should().BeEmpty(); + } } diff --git a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs index f196d49..43735b6 100644 --- a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs +++ b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs @@ -37,11 +37,6 @@ internal static string ReplaceLogicalAliases(this IQueryKitConfiguration configu return configuration.PropertyMappings.GetPropertyPathByQueryName(queryName); } - internal static bool IsPropertySortable(this IQueryKitConfiguration configuration, string? propertyName) - { - return configuration.PropertyMappings.GetPropertyInfo(propertyName)?.CanSort ?? true; - } - internal static void ValidatePropertyDepth(this IQueryKitConfiguration? configuration, string? propertyPath) { if (configuration == null || string.IsNullOrEmpty(propertyPath)) diff --git a/QueryKit/PropertyResolver.cs b/QueryKit/PropertyResolver.cs index 3d3b9ce..dbb5a6d 100644 --- a/QueryKit/PropertyResolver.cs +++ b/QueryKit/PropertyResolver.cs @@ -38,6 +38,8 @@ private PropertyReference(PropertyReferenceKind kind, string text, string path, public bool CanFilter => Mapping?.CanFilter ?? true; + public bool CanSort => Mapping?.CanSort ?? true; + /// When the reference is not a member, the first path segment that did not resolve to a member. public string? UnknownSegment { get; } diff --git a/QueryKit/SortParser.cs b/QueryKit/SortParser.cs index 214e9e9..f523391 100644 --- a/QueryKit/SortParser.cs +++ b/QueryKit/SortParser.cs @@ -56,7 +56,8 @@ private static SortExpressionInfo CreateSortExpression(string sortClause, } var propertyPath = config?.GetPropertyPathByQueryName(propertyName) ?? propertyName; - if (config != null && config.IsPropertySortable(propertyPath) == false) + var reference = PropertyResolver.Resolve(typeof(T), propertyPath, config); + if (reference.Kind == PropertyReferenceKind.Member && !reference.CanSort) { return new SortExpressionInfo { From 3b05ab48d1dd4f1f4358421bca51d8face535ce3 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:24:19 +0300 Subject: [PATCH 08/14] fix(filter): apply prevent settings to derived properties and custom 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. --- .../Tests/PropertyResolverTests.cs | 89 +++++++++++++++++++ QueryKit.UnitTests/PropertyResolverTests.cs | 57 ++++++++++++ QueryKit/FilterParser.cs | 9 +- QueryKit/SortParser.cs | 2 +- 4 files changed, 154 insertions(+), 3 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 82c5c53..9763ae0 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -181,4 +181,93 @@ public async Task prevented_sort_property_with_a_query_name_is_not_sorted_when_w // Assert people.Select(x => x.Id).Should().Equal(firstPerson.Id, secondPerson.Id); } + + [Fact] + public async Task prevented_custom_operation_is_not_filtered() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = new Faker().Lorem.Sentence(); + var fakePerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithAge(5) + .Build(); + await testingServiceScope.InsertAsync(fakePerson); + + var input = $"""Title == "{title}" && adult == true"""; + var config = new QueryKitConfiguration(config => + { + config.CustomOperation((x, op, value) => x.Age > 17).HasQueryName("adult").PreventFilter(); + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().ContainSingle(x => x.Id == fakePerson.Id); + } + + [Fact] + public async Task prevented_derived_property_is_not_filtered() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = new Faker().Lorem.Sentence(); + var fakePerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithFirstName("Paul") + .WithLastName("Other") + .Build(); + await testingServiceScope.InsertAsync(fakePerson); + + var input = $"""Title == "{title}" && full == "no match" """; + var config = new QueryKitConfiguration(config => + { + config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventFilter(); + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().ContainSingle(x => x.Id == fakePerson.Id); + } + + [Fact] + public async Task prevented_derived_sort_property_is_not_sorted() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = new Faker().Lorem.Sentence(); + var firstPerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithFirstName("A") + .WithAge(1) + .Build(); + var secondPerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithFirstName("B") + .WithAge(2) + .Build(); + await testingServiceScope.InsertAsync(firstPerson, secondPerson); + + var input = "full desc, Age"; + var config = new QueryKitConfiguration(config => + { + config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventSort(); + }); + + // Act + var people = await testingServiceScope.DbContext().People + .Where(x => x.Title == title) + .ApplyQueryKitSort(input, config) + .ToListAsync(); + + // Assert + people.Select(x => x.Id).Should().Equal(firstPerson.Id, secondPerson.Id); + } } diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index d1f80e6..e59302e 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -233,4 +233,61 @@ public void prevented_sort_property_with_a_query_name_is_skipped_when_written_by sortExpressions.Should().BeEmpty(); } + + [Fact] + public void prevented_derived_property_removes_the_clause() + { + var input = """full == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void prevented_derived_property_in_a_list_is_skipped() + { + var input = """(full, FirstName) == "x" """; + var config = new QueryKitConfiguration(config => + { + config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "x")"""); + } + + [Fact] + public void prevented_custom_operation_removes_the_clause() + { + var input = """adult == true || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.CustomOperation((x, op, value) => x.Age > 17).HasQueryName("adult").PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void prevented_derived_sort_property_is_skipped() + { + var input = "full desc, Age"; + var config = new QueryKitConfiguration(config => + { + config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventSort(); + }); + + var sortExpressions = SortParser.ParseSort(input, config); + + sortExpressions.Should().ContainSingle(); + sortExpressions[0].Expression!.ToString().Should().Be("x => Convert(x.Age, Object)"); + } } diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index e3bbcb8..683dd92 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -709,6 +709,11 @@ private static Parser ComparisonExprParser(ParameterExpression pa { if (temp.reference.Kind == PropertyReferenceKind.CustomOperation) { + if (!temp.reference.CanFilter) + { + return RemovedClauseExpression.Instance; + } + return CreateCustomOperationExpression(parameter, temp.reference.Mapping!, temp.op, temp.right); } @@ -894,7 +899,7 @@ private static Expression CreateLeftExpr(ParameterExpression parameter, Property ? reference.Mapping!.DerivedExpression! : CreateMemberExpression(parameter, reference.Path); - var propertyConfig = reference.Kind == PropertyReferenceKind.Member ? reference.Mapping : null; + var propertyConfig = reference.Mapping; if (propertyConfig != null && !propertyConfig.CanFilter) { return RemovedClauseExpression.Instance; @@ -1042,7 +1047,7 @@ private static Parser PropertyListComparisonExprParser( // Build expression for each property. A property list does not support custom operations. var reference = PropertyResolver.Resolve(parameter.Type, fullPropPath, config); - if (reference.Kind == PropertyReferenceKind.Member && !reference.CanFilter) + if (!reference.CanFilter) { continue; } diff --git a/QueryKit/SortParser.cs b/QueryKit/SortParser.cs index f523391..af1c5f6 100644 --- a/QueryKit/SortParser.cs +++ b/QueryKit/SortParser.cs @@ -57,7 +57,7 @@ private static SortExpressionInfo CreateSortExpression(string sortClause, var propertyPath = config?.GetPropertyPathByQueryName(propertyName) ?? propertyName; var reference = PropertyResolver.Resolve(typeof(T), propertyPath, config); - if (reference.Kind == PropertyReferenceKind.Member && !reference.CanSort) + if (reference.Kind != PropertyReferenceKind.CustomOperation && !reference.CanSort) { return new SortExpressionInfo { From 9f3b973f8cf7b58aba079f4eb3eedd49ddd49da2 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:33:11 +0300 Subject: [PATCH 09/14] fix(filter): resolve query names in property lists and arithmetic 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. --- .../Tests/PropertyResolverTests.cs | 55 ++++++++++++++ QueryKit.UnitTests/PropertyResolverTests.cs | 72 +++++++++++++++++++ QueryKit/FilterParser.cs | 9 ++- QueryKit/PropertyResolver.cs | 6 +- 4 files changed, 139 insertions(+), 3 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 9763ae0..657b7a7 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -270,4 +270,59 @@ public async Task prevented_derived_sort_property_is_not_sorted() // Assert people.Select(x => x.Id).Should().Equal(firstPerson.Id, secondPerson.Id); } + + [Fact] + public async Task query_name_in_a_property_list_is_filtered() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = new Faker().Lorem.Sentence(); + var fakePerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithFirstName("Paul") + .WithLastName("Other") + .Build(); + await testingServiceScope.InsertAsync(fakePerson); + + var input = $"""Title == "{title}" && (first, LastName) == "Paul" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.FirstName).HasQueryName("first"); + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().ContainSingle(x => x.Id == fakePerson.Id); + } + + [Fact] + public async Task query_name_in_arithmetic_is_filtered() + { + // 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}" && (years + 0) > 20"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Age).HasQueryName("years"); + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().ContainSingle(x => x.Id == fakePerson.Id); + } } diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index e59302e..33984d8 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -290,4 +290,76 @@ public void prevented_derived_sort_property_is_skipped() sortExpressions.Should().ContainSingle(); sortExpressions[0].Expression!.ToString().Should().Be("x => Convert(x.Age, Object)"); } + + [Fact] + public void query_name_in_a_property_list_resolves_to_its_property() + { + var input = """(name, FirstName) == "x" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("name"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => ((x.Title == "x") OrElse (x.FirstName == "x"))"""); + } + + [Fact] + public void query_name_in_arithmetic_resolves_to_its_property() + { + var input = """(stars + 0) > 3"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Rating).HasQueryName("stars"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Contain("x.Rating"); + } + + [Fact] + public void query_name_in_a_value_is_not_replaced() + { + var input = """FirstName == "name == x" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("name"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "name == x")"""); + } + + [Fact] + public void property_prevented_for_filter_and_sort_is_rejected_by_its_query_name() + { + var input = """name == "x" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); + }); + + var act = () => FilterParser.ParseFilter(input, config); + + act.Should().Throw() + .WithInnerException() + .WithMessage("'Title' is not allowed for filtering or sorting."); + } + + [Fact] + public void property_prevented_for_filter_and_sort_is_removed_by_its_member_name() + { + var input = """Title == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } } diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 683dd92..77302c3 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -24,7 +24,6 @@ public static Expression> ParseFilter(string input, IQueryKitCo input = config?.ReplaceLogicalAliases(input) ?? input; input = config?.ReplaceComparisonAliases(input) ?? input; - input = config?.PropertyMappings?.ReplaceAliasesWithPropertyPaths(input) ?? input; var parameter = Expression.Parameter(typeof(T), "x"); Expression expr; @@ -889,6 +888,14 @@ private static Parser CreateLeftExprParser(Type entityType, I throw new UnknownFilterPropertyException(reference.UnknownSegment!); } + // A property that can not be filtered or sorted is rejected when the filter uses its query name + if (reference.Kind == PropertyReferenceKind.Member && + reference.Mapping is { CanFilter: false, CanSort: false } && + string.Equals(reference.Mapping.QueryName, reference.Text, StringComparison.InvariantCultureIgnoreCase)) + { + throw new InvalidOperationException($"'{reference.Mapping.Name}' is not allowed for filtering or sorting."); + } + return reference; }); } diff --git a/QueryKit/PropertyResolver.cs b/QueryKit/PropertyResolver.cs index dbb5a6d..8db8a0c 100644 --- a/QueryKit/PropertyResolver.cs +++ b/QueryKit/PropertyResolver.cs @@ -57,9 +57,11 @@ internal static class PropertyResolver { internal static PropertyReference Resolve(Type rootType, string reference, IQueryKitConfiguration? config) { - config?.ValidatePropertyDepth(reference); + // A query name resolves to the property path of its mapping first + var path = config?.PropertyMappings?.GetPropertyPathByQueryName(reference) ?? reference; + config?.ValidatePropertyDepth(path); - var memberPath = ResolveMemberPath(rootType, reference, out var unknownSegment); + var memberPath = ResolveMemberPath(rootType, path, out var unknownSegment); if (memberPath != null) { return PropertyReference.Member(reference, memberPath, config?.PropertyMappings?.GetPropertyInfo(memberPath)); From 459544e93adacb22fc0ddf97928fb4bc017de10c Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:48:45 +0300 Subject: [PATCH 10/14] fix(filter): keep a query name in a nested path in alias replacement 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. --- QueryKit.UnitTests/PropertyResolverTests.cs | 14 ++++++++++++++ QueryKit/QueryKitPropertyMappings.cs | 5 +++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 33984d8..c98bb4d 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -5,6 +5,7 @@ namespace QueryKit.UnitTests; using FluentAssertions; using WebApiTestProject.Entities; using WebApiTestProject.Entities.Ingredients; +using WebApiTestProject.Entities.Recipes; public class PropertyResolverTests { @@ -362,4 +363,17 @@ public void property_prevented_for_filter_and_sort_is_removed_by_its_member_name filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); } + + [Fact] + public void alias_replacement_does_not_replace_a_query_name_in_a_nested_path() + { + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("name"); + }); + + var input = config.PropertyMappings.ReplaceAliasesWithPropertyPaths("""Author.Name == "x" && name == "y" """); + + input.Should().Be("""Author.Name == "x" && Title == "y" """); + } } diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index 9408114..fab9ccc 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -136,8 +136,9 @@ public string ReplaceAliasesWithPropertyPaths(string input) { // Use regular expression to isolate left side of the expression. Query names and // operators are matched literally, so escape any regex metacharacters they contain - // (e.g. the `^` in `^^` would otherwise be read as a start-of-line anchor). - var regex = AliasRegexCache.Get($@"\b{Regex.Escape(queryKitPropertyInfo.QueryName!)}\b(?=\s*{Regex.Escape(op)})"); + // (e.g. the `^` in `^^` would otherwise be read as a start-of-line anchor). A query name + // after a dot is a segment of a nested path, so it is not replaced. + var regex = AliasRegexCache.Get($@"(? Date: Tue, 29 Sep 2026 21:51:40 +0300 Subject: [PATCH 11/14] fix(filter): accept a nested property path on the right side 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. --- .../Tests/PropertyResolverTests.cs | 29 ++++++++++++++ QueryKit.UnitTests/PropertyResolverTests.cs | 38 +++++++++++++++++++ QueryKit/FilterParser.cs | 2 +- 3 files changed, 68 insertions(+), 1 deletion(-) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 657b7a7..89dfd5c 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -5,6 +5,8 @@ namespace QueryKit.IntegrationTests.Tests; using FluentAssertions; using Microsoft.EntityFrameworkCore; using SharedTestingHelper.Fakes; +using SharedTestingHelper.Fakes.Author; +using SharedTestingHelper.Fakes.Recipes; using WebApiTestProject.Entities; public class PropertyResolverTests : TestBase @@ -325,4 +327,31 @@ public async Task query_name_in_arithmetic_is_filtered() // Assert people.Should().ContainSingle(x => x.Id == 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); + } } diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index c98bb4d..46b16bd 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -376,4 +376,42 @@ public void alias_replacement_does_not_replace_a_query_name_in_a_nested_path() input.Should().Be("""Author.Name == "x" && Title == "y" """); } + + [Fact] + 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)"); + } + + [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 || Directions == "x" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Author.Name).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.Directions == "x")"""); + } } diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 77302c3..9bd97d1 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -218,7 +218,7 @@ from closingBracket in Parse.Char(']') .XOr(NumberParser.Select(v => new RightSideValue(v, false))) .XOr((RawStringLiteralParser.Or(DoubleQuoteParser)).Select(v => new RightSideValue(v, true))) .XOr(SquareBracketParser.Select(v => new RightSideValue(v, false))) - .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 => new RightSideValue(string.Join(".", v), false))); // Keep this last to try property paths only if nothing else matches private static readonly Parser RightSideValueParser = from atSign in Parse.Char('@').Optional() From 64f7487fa973d64b553ac05025b040b51d3036fc Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:53:29 +0300 Subject: [PATCH 12/14] fix(config): apply a property max depth only to its own path 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. --- QueryKit.UnitTests/PropertyDepthTests.cs | 35 ++++++++++++++++++++++++ QueryKit/QueryKitPropertyMappings.cs | 7 +++-- 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/QueryKit.UnitTests/PropertyDepthTests.cs b/QueryKit.UnitTests/PropertyDepthTests.cs index c4021bb..35659d2 100644 --- a/QueryKit.UnitTests/PropertyDepthTests.cs +++ b/QueryKit.UnitTests/PropertyDepthTests.cs @@ -255,4 +255,39 @@ public void filter_root_property_allowed_when_max_depth_is_0() var filterExpression = FilterParser.ParseFilter(input, config); filterExpression.Should().NotBeNull(); } + + [Fact] + public void filter_per_property_max_depth_does_not_apply_to_a_property_that_starts_with_its_name() + { + var input = """AddressBackup.State == "x" """; + var config = new QueryKitConfiguration(settings => + { + settings.MaxPropertyDepth = 0; + settings.Property(x => x.Address).HasMaxDepth(1); + }); + + var act = () => FilterParser.ParseFilter(input, config); + act.Should().Throw() + .WithMessage("*AddressBackup.State*depth of 1*maximum allowed depth of 0*"); + } + + [Fact] + public void sort_per_property_max_depth_does_not_apply_to_a_property_that_starts_with_its_name() + { + var input = "AddressBackup.State"; + var config = new QueryKitConfiguration(settings => + { + settings.MaxPropertyDepth = 0; + settings.Property(x => x.Address).HasMaxDepth(1); + }); + + var act = () => SortParser.ParseSort(input, config); + act.Should().Throw(); + } + + private class Owner + { + public Address Address { get; set; } = null!; + public Address AddressBackup { get; set; } = null!; + } } diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index fab9ccc..216231e 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -413,11 +413,12 @@ private static string GetOperator(ExpressionType nodeType) if (string.IsNullOrEmpty(propertyPath)) return null; - // Check if the property path starts with any configured property that has MaxDepth + // Check if the property path is, or is under, any configured property that has MaxDepth foreach (var mapping in _propertyMappings.Values) { - if (mapping.MaxDepth.HasValue && - propertyPath.StartsWith(mapping.Name ?? "", StringComparison.OrdinalIgnoreCase)) + if (mapping.MaxDepth.HasValue && !string.IsNullOrEmpty(mapping.Name) && + (propertyPath.Equals(mapping.Name, StringComparison.OrdinalIgnoreCase) || + propertyPath.StartsWith(mapping.Name + ".", StringComparison.OrdinalIgnoreCase))) { return mapping.MaxDepth; } From 50a16316bde382c218e27c00729aa745a9920fa1 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:57:45 +0300 Subject: [PATCH 13/14] fix(filter): treat an unknown property in arithmetic as an unknown property 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. --- .../Tests/PropertyResolverTests.cs | 27 +++++++++++++ QueryKit.UnitTests/PropertyResolverTests.cs | 39 +++++++++++++++++++ QueryKit/FilterParser.cs | 15 ++++++- 3 files changed, 79 insertions(+), 2 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 89dfd5c..1aebe6e 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -354,4 +354,31 @@ public async Task property_path_on_the_right_side_is_compared() // Assert recipes.Should().ContainSingle(x => x.Id == matchingRecipe.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.AllowUnknownProperties = true; + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().BeEmpty(); + } } diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 46b16bd..cc52056 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -414,4 +414,43 @@ public void prevented_property_path_on_the_right_side_removes_the_clause() filterExpression.ToDisplayString().Should().Be("""x => (x.Directions == "x")"""); } + + [Fact] + 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 => + { + config.AllowUnknownProperties = true; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + 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 => + { + config.AllowUnknownProperties = true; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "a")"""); + } + + [Fact] + public void unknown_property_in_arithmetic_is_not_recognized() + { + var input = """(Nope + 1) > 3"""; + + var act = () => FilterParser.ParseFilter(input); + + act.Should().Throw() + .WithMessage("The filter property 'Nope' was not recognized."); + } } diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 9bd97d1..cc859f3 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -621,12 +621,23 @@ private static Parser ArithmeticComparisonExprParser(ParameterExp { case PropertyArithmeticExpression property: var reference = PropertyResolver.Resolve(entityType, property.PropertyPath, config); + if (!reference.CanFilter) + { + return null; + } + + // Arithmetic supports only members, so a derived property or a custom operation is unknown here if (reference.Kind != PropertyReferenceKind.Member) { - return property; + if (config?.AllowUnknownProperties == true) + { + return null; + } + + throw new UnknownFilterPropertyException(reference.UnknownSegment!); } - return reference.CanFilter ? new PropertyArithmeticExpression(reference.Path) : null; + return new PropertyArithmeticExpression(reference.Path); case BinaryArithmeticExpression binary: var left = ResolveArithmeticProperties(binary.Left, entityType, config); var right = ResolveArithmeticProperties(binary.Right, entityType, config); From b21b41a82d8adf57df99453bbcc9febac58be091 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 22:00:31 +0300 Subject: [PATCH 14/14] fix(filter): remove a prevented clause that uses its query name 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. --- .../Tests/PropertyResolverTests.cs | 28 +++++++++++++++++++ QueryKit.UnitTests/PropertyResolverTests.cs | 10 +++---- QueryKit/FilterParser.cs | 8 ------ QueryKit/QueryKitExtensions.cs | 27 ++++++++---------- 4 files changed, 43 insertions(+), 30 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 1aebe6e..590601b 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -66,6 +66,34 @@ public async Task prevented_property_clause_under_or_does_not_return_every_row() people.Should().BeEmpty(); } + [Fact] + public async Task prevented_property_clause_by_its_query_name_under_or_does_not_return_every_row() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = new Faker().Lorem.Sentence(); + var fakePerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithFirstName("Paul") + .WithAge(30) + .Build(); + await testingServiceScope.InsertAsync(fakePerson); + + var input = $"""Title == "{title}" && (first == "Paul" || Age > 100)"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.FirstName).HasQueryName("first").PreventFilter().PreventSort(); + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.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 cc52056..90cc61d 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -335,19 +335,17 @@ public void query_name_in_a_value_is_not_replaced() } [Fact] - public void property_prevented_for_filter_and_sort_is_rejected_by_its_query_name() + public void property_prevented_for_filter_and_sort_is_removed_by_its_query_name() { - var input = """name == "x" """; + var input = """name == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); }); - var act = () => FilterParser.ParseFilter(input, config); + var filterExpression = FilterParser.ParseFilter(input, config); - act.Should().Throw() - .WithInnerException() - .WithMessage("'Title' is not allowed for filtering or sorting."); + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); } [Fact] diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index cc859f3..c36c616 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -899,14 +899,6 @@ private static Parser CreateLeftExprParser(Type entityType, I throw new UnknownFilterPropertyException(reference.UnknownSegment!); } - // A property that can not be filtered or sorted is rejected when the filter uses its query name - if (reference.Kind == PropertyReferenceKind.Member && - reference.Mapping is { CanFilter: false, CanSort: false } && - string.Equals(reference.Mapping.QueryName, reference.Text, StringComparison.InvariantCultureIgnoreCase)) - { - throw new InvalidOperationException($"'{reference.Mapping.Name}' is not allowed for filtering or sorting."); - } - return reference; }); } diff --git a/QueryKit/QueryKitExtensions.cs b/QueryKit/QueryKitExtensions.cs index eaa7d1a..9c4b5e7 100644 --- a/QueryKit/QueryKitExtensions.cs +++ b/QueryKit/QueryKitExtensions.cs @@ -37,27 +37,22 @@ public static IOrderedQueryable ApplyQueryKitSort(this IQueryable query { var sortLambdas = SortParser.ParseSort(sortExpression, config); - if (sortLambdas.Count == 0) + if (sortLambdas.Count == 0 || sortLambdas[0].Expression is null) return queryable.OrderBy(_ => 0); var firstSortInfo = sortLambdas[0]; - if (firstSortInfo.Expression != null) + var orderedQueryable = firstSortInfo.IsAscending ? queryable.OrderBy(firstSortInfo.Expression!) : queryable.OrderByDescending(firstSortInfo.Expression!); + + for (var i = 1; i < sortLambdas.Count; i++) { - var orderedQueryable = firstSortInfo.IsAscending ? queryable.OrderBy(firstSortInfo.Expression) : queryable.OrderByDescending(firstSortInfo.Expression); - - for (var i = 1; i < sortLambdas.Count; i++) - { - var sortInfo = sortLambdas[i]; - if (sortInfo.Expression != null) - orderedQueryable = sortInfo.IsAscending - ? orderedQueryable.ThenBy(sortInfo.Expression) - : orderedQueryable.ThenByDescending(sortInfo.Expression); - } - - return orderedQueryable; + var sortInfo = sortLambdas[i]; + if (sortInfo.Expression != null) + orderedQueryable = sortInfo.IsAscending + ? orderedQueryable.ThenBy(sortInfo.Expression) + : orderedQueryable.ThenByDescending(sortInfo.Expression); } - - return queryable.OrderBy(x => x); + + return orderedQueryable; } public static IEnumerable ApplyQueryKit(