From a62bbeb9d1e9798c93260eb3f36f806aa7d04de0 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Thu, 1 Oct 2026 17:02:48 +0300 Subject: [PATCH] fix(filter)!: apply PreventFilter and PreventSort to every property path PreventFilter was checked only for a left-side member, by its name in the exact case after the query-name rewrite. Arithmetic, the right side of a comparison, a property list in another case, derived properties, and custom operations skipped the check. PreventSort was checked by the typed path in the exact case. A caller could learn the value of a hidden field one comparison at a time. The parser now resolves each property reference and applies the prevent settings in each of these places. A prevented clause follows IgnoredClauseBehavior, and a prevented sort is skipped. Arithmetic does not apply MaxPropertyDepth in this change. BREAKING CHANGE: a filter or sort that reaches a property with PreventFilter or PreventSort through arithmetic, the right side, a property list, another letter case, the member name of a property with a query name, a derived property, or a custom operation no longer filters or sorts by that property. The clause follows IgnoredClauseBehavior, and the sort is skipped. --- .../Tests/PropertyResolverTests.cs | 208 ++++++++++++++++++ QueryKit.UnitTests/PropertyResolverTests.cs | 119 ++++++++-- .../QueryKitConfigurationExtensions.cs | 5 - QueryKit/FilterParser.cs | 43 +++- QueryKit/PropertyResolver.cs | 9 + QueryKit/SortParser.cs | 3 +- README.md | 4 +- 7 files changed, 350 insertions(+), 41 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 432436b..afbadc4 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -151,6 +151,214 @@ public async Task property_is_not_prevented_by_another_property_whose_query_name people[0].Id.Should().Be(fakePerson.Id); } + [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); + } + + [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.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; + config.Property(x => x.LastName!).PreventFilter(); + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // 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(); + } + + [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); + } + + [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); + } + [Theory] [InlineData("first-name")] [InlineData("_first")] diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 040943f..b014963 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -113,6 +113,53 @@ public void ignored_clause_in_a_group_is_true_equals_true_in_the_group() """x => ((x.Title == "a") AndAlso ((True == True) OrElse (x.Age > 100)))"""); } + [Fact] + public void prevented_property_in_arithmetic_is_true_equals_true() + { + var input = """(Age + 0) > 10 || Title == "a" """; + var config = new QueryKitConfiguration(config => + { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.ReplaceWithTrue; + config.Property(x => x.Age!).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be( + """x => ((True == True) OrElse (x.Title == "a"))"""); + } + + [Fact] + public void prevented_property_on_the_right_side_of_arithmetic_is_true_equals_true() + { + var input = """(Age + 0) > (Rating * 2)"""; + var config = new QueryKitConfiguration(config => + { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.ReplaceWithTrue; + config.Property(x => x.Rating!).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (True == True)"); + } + + [Fact] + public void prevented_property_on_the_right_side_is_true_equals_true_when_replaced() + { + var input = """FirstName == Title || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.ReplaceWithTrue; + config.Property(x => x.Title!).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be( + "x => ((True == True) OrElse (x.Age > 100))"); + } + [Fact] public void unknown_property_clause_is_removed_under_or() { @@ -190,7 +237,7 @@ public void property_list_with_only_prevented_properties_is_removed() } [Fact] - public void prevented_property_in_arithmetic_is_still_filtered() + public void prevented_property_in_arithmetic_removes_the_clause() { var input = """(Age + 0) > 10 || Title == "a" """; var config = new QueryKitConfiguration(config => @@ -201,11 +248,11 @@ public void prevented_property_in_arithmetic_is_still_filtered() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (((x.Age + Convert(0, Nullable`1)) > Convert(10, Nullable`1)) OrElse (x.Title == "a"))"""); + filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "a")"""); } [Fact] - public void prevented_property_on_the_right_side_of_arithmetic_is_still_filtered() + public void prevented_property_on_the_right_side_of_arithmetic_removes_the_clause() { var input = """(Age + 0) > (Rating * 2)"""; var config = new QueryKitConfiguration(config => @@ -216,7 +263,7 @@ public void prevented_property_on_the_right_side_of_arithmetic_is_still_filtered var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (Convert((x.Age + Convert(0, Nullable`1)), Nullable`1) > (x.Rating * Convert(2, Nullable`1)))"); + filterExpression.ToDisplayString().Should().Be("x => True"); } [Fact] @@ -234,7 +281,7 @@ public void arithmetic_property_skips_max_property_depth() } [Fact] - public void prevented_property_on_the_right_side_is_still_compared() + public void prevented_property_on_the_right_side_removes_the_clause() { var input = """FirstName == Title || Age > 100"""; var config = new QueryKitConfiguration(config => @@ -245,11 +292,26 @@ public void prevented_property_on_the_right_side_is_still_compared() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => ((x.FirstName == x.Title) OrElse (x.Age > 100))"); + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); } [Fact] - public void prevented_property_in_a_list_in_another_case_is_still_filtered() + 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.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; + 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_in_a_list_is_skipped_in_any_case() { var input = """(title, FirstName) == "x" """; var config = new QueryKitConfiguration(config => @@ -259,7 +321,7 @@ public void prevented_property_in_a_list_in_another_case_is_still_filtered() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => ((x.Title == "x") OrElse (x.FirstName == "x"))"""); + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "x")"""); } [Fact] @@ -278,7 +340,7 @@ public void prevented_property_removes_the_clause_in_any_case() } [Fact] - public void prevented_property_with_a_query_name_is_still_filtered_by_its_member_name_in_another_case() + 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 => @@ -289,7 +351,7 @@ public void prevented_property_with_a_query_name_is_still_filtered_by_its_member var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => ((x.Title == "x") OrElse (x.Age > 100))"""); + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); } [Fact] @@ -322,7 +384,7 @@ public void prevented_sort_property_is_skipped_in_any_case() } [Fact] - public void prevented_sort_property_with_a_query_name_still_sorts_by_its_member_name_in_another_case() + 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 => @@ -332,12 +394,11 @@ public void prevented_sort_property_with_a_query_name_still_sorts_by_its_member_ var sortExpressions = SortParser.ParseSort(input, config); - sortExpressions.Should().ContainSingle(); - sortExpressions[0].Expression!.ToString().Should().Be("x => Convert(x.Title, Object)"); + sortExpressions.Should().BeEmpty(); } [Fact] - public void prevented_derived_property_is_still_filtered() + public void prevented_derived_property_removes_the_clause() { var input = """full == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => @@ -348,11 +409,11 @@ public void prevented_derived_property_is_still_filtered() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => ((((x.FirstName + " ") + x.LastName) == "x") OrElse (x.Age > 100))"""); + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); } [Fact] - public void prevented_derived_property_in_a_list_is_still_filtered() + public void prevented_derived_property_in_a_list_is_skipped() { var input = """(full, FirstName) == "x" """; var config = new QueryKitConfiguration(config => @@ -362,11 +423,11 @@ public void prevented_derived_property_in_a_list_is_still_filtered() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => ((((x.FirstName + " ") + x.LastName) == "x") OrElse (x.FirstName == "x"))"""); + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "x")"""); } [Fact] - public void prevented_custom_operation_is_still_applied() + public void prevented_custom_operation_removes_the_clause() { var input = """adult == true || Age > 100"""; var config = new QueryKitConfiguration(config => @@ -377,11 +438,26 @@ public void prevented_custom_operation_is_still_applied() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (Invoke((entity, op, value) => (Convert(entity, TestingPerson).Age > Convert(17, Nullable`1)), Convert(x, Object), ==, True) OrElse (x.Age > 100))"); + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); } [Fact] - public void prevented_derived_sort_property_still_sorts() + public void prevented_custom_operation_is_true_equals_true_when_replaced() + { + var input = """adult == true || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.ReplaceWithTrue; + config.CustomOperation((x, op, value) => x.Age > 17).HasQueryName("adult").PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => ((True == True) OrElse (x.Age > 100))"); + } + + [Fact] + public void prevented_derived_sort_property_is_skipped() { var input = "full desc, Age"; var config = new QueryKitConfiguration(config => @@ -391,7 +467,8 @@ public void prevented_derived_sort_property_still_sorts() var sortExpressions = SortParser.ParseSort(input, config); - sortExpressions.Should().HaveCount(2); + sortExpressions.Should().ContainSingle(); + sortExpressions[0].Expression!.ToString().Should().Be("x => Convert(x.Age, Object)"); } [Fact] 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/FilterParser.cs b/QueryKit/FilterParser.cs index 07ead9d..49a03f4 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -807,6 +807,11 @@ 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 => { + if (!CanFilterArithmetic(temp.leftArithmetic, typeof(T), config) || !CanFilterArithmetic(temp.rightSide, typeof(T), config)) + { + return IgnoredClause(config); + } + var leftExpr = temp.leftArithmetic.ToLinqExpression(parameter, typeof(T)); var rightExpr = temp.rightSide.ToLinqExpression(parameter, typeof(T)); @@ -815,6 +820,18 @@ private static Parser ArithmeticComparisonExprParser(ParameterExp }); } + // Returns false when a property in an arithmetic expression cannot be filtered, because then the parser removes the clause. + private static bool CanFilterArithmetic(ArithmeticExpression expr, Type entityType, IQueryKitConfiguration? config) + { + return expr switch + { + PropertyArithmeticExpression property => PropertyResolver.ResolveWithoutDepthCheck(entityType, property.PropertyPath, config) is not { Kind: PropertyReferenceKind.Member, CanFilter: false }, + BinaryArithmeticExpression binary => CanFilterArithmetic(binary.Left, entityType, config) && CanFilterArithmetic(binary.Right, entityType, config), + GroupedArithmeticExpression grouped => CanFilterArithmetic(grouped.Inner, entityType, config), + _ => true + }; + } + private static bool ContainsArithmeticOperator(ArithmeticExpression expr) { return expr switch @@ -854,11 +871,6 @@ private static bool IsValidPropertyName(string value) value.All(c => char.IsLetterOrDigit(c) || c == '_' || c == '.'); } - // The filter settings of a left-side property: by its property path after alias replacement, in the exact case, like v1.14.2. - // Derived properties and custom operations are not in this lookup. - private static QueryKitPropertyInfo? GetFilterPropertyInfo(string text, IQueryKitConfiguration? config) - => config?.PropertyMappings?.GetPropertyInfo(text); - private static CaseInsensitiveMode ResolveCaseMode(string? propertyPath, IQueryKitConfiguration? config) { if (!string.IsNullOrEmpty(propertyPath) && config?.PropertyMappings != null) @@ -890,6 +902,11 @@ private static Parser ComparisonExprParser(ParameterExpression pa var temp = clause with { right = right }; if (temp.reference.Kind == PropertyReferenceKind.CustomOperation) { + if (!temp.reference.CanFilter) + { + return IgnoredClause(config); + } + return CreateCustomOperationExpression(parameter, temp.reference.Mapping!, temp.op, temp.right); } @@ -937,6 +954,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 IgnoredClause(config); + } + var rightPropertyExpr = CreateRightPropertyExpr(parameter, temp.right, config); if (rightPropertyExpr != null) { @@ -1132,7 +1154,7 @@ private static Expression CreateLeftExpr(ParameterExpression parameter, Property ? reference.Mapping!.DerivedExpression! : CreateMemberExpression(parameter, reference.Path); - if (GetFilterPropertyInfo(reference.Text, config)?.CanFilter == false) + if (!reference.CanFilter) { return RemovedClauseExpression.Instance; } @@ -1278,15 +1300,12 @@ private static Parser PropertyListComparisonExprParser( foreach (var fullPropPath in temp.properties) { // Build expression for each property. A property list does not support custom operations. - // Check if property can be filtered - var propertyConfig = config?.PropertyMappings?.GetPropertyInfo(fullPropPath); - if (propertyConfig != null && !propertyConfig.CanFilter) + var reference = PropertyResolver.Resolve(parameter.Type, fullPropPath, config); + if (!reference.CanFilter) { continue; } - var reference = PropertyResolver.Resolve(parameter.Type, fullPropPath, config); - if (reference.Kind is PropertyReferenceKind.Unknown or PropertyReferenceKind.CustomOperation) { if (config?.AllowUnknownProperties == true) @@ -1309,7 +1328,7 @@ private static Parser PropertyListComparisonExprParser( } var rightExpr = CreateRightExpr(leftExpr, temp.right, temp.rightIsQuotedLiteral, temp.op, config, fullPropPath, reference.Path); - 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 diff --git a/QueryKit/PropertyResolver.cs b/QueryKit/PropertyResolver.cs index 4645ef5..b22ed35 100644 --- a/QueryKit/PropertyResolver.cs +++ b/QueryKit/PropertyResolver.cs @@ -36,6 +36,10 @@ private PropertyReference(PropertyReferenceKind kind, string text, string path, /// 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; + + 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; } @@ -54,7 +58,12 @@ internal static class PropertyResolver internal static PropertyReference Resolve(Type rootType, string reference, IQueryKitConfiguration? config) { config?.ValidatePropertyDepth(reference); + return ResolveWithoutDepthCheck(rootType, reference, config); + } + // Arithmetic does not apply MaxPropertyDepth. + internal static PropertyReference ResolveWithoutDepthCheck(Type rootType, string reference, IQueryKitConfiguration? config) + { var memberPath = ResolveMemberPath(rootType, reference, out var unknownSegment); if (memberPath != null) { diff --git a/QueryKit/SortParser.cs b/QueryKit/SortParser.cs index 22a915a..26c0280 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.CustomOperation && !reference.CanSort) { return new SortExpressionInfo { diff --git a/README.md b/README.md index 96f5341..59417e3 100644 --- a/README.md +++ b/README.md @@ -565,7 +565,7 @@ public enum BirthMonthEnum Filtering is set up to create an expression using the property names you have on your entity, but you can pass in a config to customize things a bit when needed. * `HasQueryName()` to create a custom alias for a property. For exmaple, we can make `FirstName` aliased to `first`. -* `PreventFilter()` to prevent filtering on a given property +* `PreventFilter()` to prevent filtering on a given property. The setting applies in every place that a filter can use the property: the left side, the right side, arithmetic, and a property list, in any letter case. It also applies to derived properties and custom operations. ```c# var input = $"""first == "Jane" || Age > 10"""; @@ -961,7 +961,7 @@ var input = "Title, -Age"; Sorting is set up to create an expression using the property names you have on your entity, but you can pass in a config to customize things a bit when needed. * Just as with filtering, `HasQueryName()` to create a custom alias for a property. For exmaple, we can make `FirstName` aliased to `first`. -* `PreventSort()` to prevent filtering on a given property +* `PreventSort()` to prevent sorting on a given property. The setting applies in any letter case, also when the sort uses the member name of a property that has a query name. It also applies to derived properties. ```c# var input = "Age desc, first";