diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 1b59a37..793643a 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -150,6 +150,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 f00248e..b80d515 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -116,6 +116,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() { @@ -188,7 +235,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 => @@ -198,11 +245,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 => @@ -212,7 +259,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] @@ -230,7 +277,7 @@ public void arithmetic_property_obeys_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 => @@ -240,11 +287,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_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_in_another_case_is_still_filtered() + public void prevented_property_in_a_list_is_skipped_in_any_case() { var input = """(title, FirstName) == "x" """; var config = new QueryKitConfiguration(config => @@ -254,7 +316,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] @@ -272,7 +334,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 => @@ -282,7 +344,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] @@ -315,7 +377,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 => @@ -325,12 +387,42 @@ public void prevented_sort_property_with_a_query_name_still_sorts_by_its_member_ var sortExpressions = SortParser.ParseSort(input, config); + sortExpressions.Should().BeEmpty(); + } + + [Fact] + public void property_on_the_right_side_is_compared_by_its_resolved_path() + { + var input = """FirstName == title"""; + + var filterExpression = FilterParser.ParseFilter(input); + + filterExpression.ToDisplayString().Should().Be("x => (x.FirstName == x.Title)"); + } + + [Fact] + public void sort_property_is_sorted_by_its_resolved_path() + { + var input = "title desc"; + + var sortExpressions = SortParser.ParseSort(input); + sortExpressions.Should().ContainSingle(); sortExpressions[0].Expression!.ToString().Should().Be("x => Convert(x.Title, Object)"); } [Fact] - public void prevented_derived_property_is_still_filtered() + public void sort_on_a_collection_member_path_throws_a_sort_parsing_exception() + { + var input = "Ingredients.name asc"; + + var act = () => SortParser.ParseSort(input); + + act.Should().Throw(); + } + + [Fact] + public void prevented_derived_property_removes_the_clause() { var input = """full == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => @@ -340,11 +432,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 => @@ -354,11 +446,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 => @@ -368,11 +460,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 => @@ -382,7 +489,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 7e9b147..2890a38 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -769,6 +769,10 @@ private static Parser ArithmeticComparisonExprParser(ParameterExp { var leftArithmetic = ResolveArithmeticProperties(temp.leftArithmetic, typeof(T), config); var rightArithmetic = ResolveArithmeticProperties(temp.rightSide, typeof(T), config); + if (leftArithmetic == null || rightArithmetic == null) + { + return IgnoredClause(config); + } var leftExpr = leftArithmetic.ToLinqExpression(parameter, typeof(T)); var rightExpr = rightArithmetic.ToLinqExpression(parameter, typeof(T)); @@ -779,7 +783,8 @@ private static Parser ArithmeticComparisonExprParser(ParameterExp } // Resolves each property in an arithmetic expression to its member path. - private static ArithmeticExpression ResolveArithmeticProperties(ArithmeticExpression expr, Type entityType, IQueryKitConfiguration? config) + // 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) { @@ -790,13 +795,14 @@ private static ArithmeticExpression ResolveArithmeticProperties(ArithmeticExpres return property; } - return new PropertyArithmeticExpression(reference.Path); + 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 new BinaryArithmeticExpression(left, binary.Operator, right); + return left == null || right == null ? null : new BinaryArithmeticExpression(left, binary.Operator, right); case GroupedArithmeticExpression grouped: - return new GroupedArithmeticExpression(ResolveArithmeticProperties(grouped.Inner, entityType, config)); + var inner = ResolveArithmeticProperties(grouped.Inner, entityType, config); + return inner == null ? null : new GroupedArithmeticExpression(inner); default: return expr; } @@ -841,11 +847,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) @@ -877,6 +878,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); } @@ -924,7 +930,16 @@ 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)) { - var rightPropertyExpr = CreateRightPropertyExpr(parameter, temp.right, config); + // Build the right side from the resolved path, so that the checked property is the compared property. + var rightReference = PropertyResolver.Resolve(parameter.Type, temp.right, config); + if (!rightReference.CanFilter) + { + return IgnoredClause(config); + } + + var rightPropertyExpr = rightReference.Kind == PropertyReferenceKind.Member + ? CreateRightPropertyExpr(parameter, rightReference.Path, config) + : null; if (rightPropertyExpr != null) { // Handle GUID conversion for property-to-property comparisons @@ -1119,7 +1134,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; } @@ -1265,15 +1280,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) @@ -1296,7 +1308,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 e2ebde0..6a3490b 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; } diff --git a/QueryKit/SortParser.cs b/QueryKit/SortParser.cs index 593ea6e..ac7d682 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 { @@ -66,7 +67,7 @@ private static SortExpressionInfo CreateSortExpression(string sortClause, } var parameter = Expression.Parameter(typeof(T), "x"); - var sortExpressionBody = CreateSortExpressionBody(parameter, propertyName, config); + var sortExpressionBody = CreateSortExpressionBody(parameter, propertyName, reference, config); if (sortExpressionBody == null) { @@ -85,7 +86,7 @@ private static SortExpressionInfo CreateSortExpression(string sortClause, }; } - private static Expression? CreateSortExpressionBody(Expression parameter, string propertyName, IQueryKitConfiguration? config) + private static Expression? CreateSortExpressionBody(Expression parameter, string propertyName, PropertyReference reference, IQueryKitConfiguration? config) { // First check if this is a derived property var derivedPropertyInfo = config?.PropertyMappings?.GetDerivedPropertyInfoByQueryName(propertyName); @@ -96,8 +97,11 @@ private static SortExpressionInfo CreateSortExpression(string sortClause, return parameterReplacer.Visit(derivedPropertyInfo.DerivedExpression); } - // Handle regular properties with null-safe navigation - var propertyPath = config?.GetPropertyPathByQueryName(propertyName) ?? propertyName; + // Handle regular properties with null-safe navigation. + // Sort by the resolved path, so that the checked property is the sorted property. + var propertyPath = reference.Kind == PropertyReferenceKind.Member + ? reference.Path + : config?.GetPropertyPathByQueryName(propertyName) ?? propertyName; // Validate property depth before processing config?.ValidatePropertyDepth(propertyPath); diff --git a/README.md b/README.md index a708731..8d3ca88 100644 --- a/README.md +++ b/README.md @@ -567,7 +567,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"""; @@ -967,7 +967,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";