diff --git a/QueryKit.IntegrationTests/Tests/AliasCultureTests.cs b/QueryKit.IntegrationTests/Tests/AliasCultureTests.cs index 22fe3f4..74e9691 100644 --- a/QueryKit.IntegrationTests/Tests/AliasCultureTests.cs +++ b/QueryKit.IntegrationTests/Tests/AliasCultureTests.cs @@ -2,18 +2,16 @@ namespace QueryKit.IntegrationTests.Tests; using System.Globalization; using Configuration; -using Exceptions; using FluentAssertions; using Microsoft.EntityFrameworkCore; using SharedTestingHelper.Fakes; using WebApiTestProject.Entities; -// Like v1.14.2, a query name matches with the case rules of the culture of each parse, -// also when a parse in another culture used the same query name before. +// A query name ignores case with the rules of the invariant culture, so the result does not depend on the culture of the parse. public class AliasCultureTests : TestBase { [Fact] - public async Task query_name_matches_in_en_us_after_a_tr_tr_parse() + public async Task query_name_with_i_matches_its_upper_case_in_tr_tr() { // Arrange var testingServiceScope = new TestingServiceScope(); @@ -37,10 +35,6 @@ public async Task query_name_matches_in_en_us_after_a_tr_tr_parse() try { CultureInfo.CurrentCulture = new CultureInfo("tr-TR"); - var turkish = () => testingServiceScope.DbContext().People.ApplyQueryKitFilter(input, config); - turkish.Should().Throw(); - - CultureInfo.CurrentCulture = new CultureInfo("en-US"); appliedQueryable = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input, config); } finally diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 793643a..ac5a26f 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -207,6 +207,36 @@ public async Task prevented_property_on_the_right_side_is_not_compared() people.Should().BeEmpty(); } + [Fact] + public async Task prevented_property_on_the_right_side_is_not_compared_when_another_query_name_matches_its_name() + { + // 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.FirstName!).HasQueryName("lastname"); + 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() { @@ -358,6 +388,61 @@ public async Task prevented_derived_sort_property_is_not_sorted() 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); + } + [Theory] [InlineData("first-name")] [InlineData("_first")] diff --git a/QueryKit.UnitTests/AliasCultureTests.cs b/QueryKit.UnitTests/AliasCultureTests.cs index b520b0d..ea5919e 100644 --- a/QueryKit.UnitTests/AliasCultureTests.cs +++ b/QueryKit.UnitTests/AliasCultureTests.cs @@ -6,40 +6,63 @@ namespace QueryKit.UnitTests; using FluentAssertions; using WebApiTestProject.Entities.Recipes; -// Like v1.14.2, an alias matches with the case rules of the culture of each parse, +// A query name ignores case with the rules of the invariant culture, so the result does not depend on the culture of the parse. +// Like v1.14.2, an operator alias matches with the case rules of the culture of each parse, // also when a parse in another culture used the same alias before. // Each test uses its own alias, so the result does not depend on the order of the tests. public class AliasCultureTests { - [Fact] - public void query_name_matches_in_en_us_after_a_tr_tr_parse() + [Theory] + [InlineData("en-US")] + [InlineData("tr-TR")] + public void query_name_with_i_matches_its_upper_case_with_the_invariant_rules_in_every_culture(string cultureName) { var config = new QueryKitConfiguration(settings => { settings.Property(x => x.Rating!).HasQueryName("tipalpha"); }); - var input = "TIPALPHA > 3"; - var turkish = () => WithCulture("tr-TR", () => FilterParser.ParseFilter(input, config)); - turkish.Should().Throw(); + var filterExpression = WithCulture(cultureName, () => FilterParser.ParseFilter("TIPALPHA > 3", config)); - var filterExpression = WithCulture("en-US", () => FilterParser.ParseFilter(input, config)); filterExpression.ToString().Should().Be("x => (x.Rating > 3)"); } - [Fact] - public void query_name_does_not_match_in_tr_tr_after_an_en_us_parse() + [Theory] + [InlineData("en-US", "TIPBETA > 3", "x => (x.Rating > 3)")] + [InlineData("tr-TR", "TIPBETA > 3", "x => (x.Rating > 3)")] + [InlineData("tr-TR", "(TIPBETA, Title) == 3", "x => ((x.Rating == 3) OrElse (x.Title == \"3\"))")] + [InlineData("tr-TR", "TIPBETA desc", null)] + public void query_name_with_i_matches_its_upper_case_in_tr_tr_in_every_syntax(string cultureName, string input, string? expected) { var config = new QueryKitConfiguration(settings => { settings.Property(x => x.Rating!).HasQueryName("tipbeta"); }); - var input = "TIPBETA > 3"; - var filterExpression = WithCulture("en-US", () => FilterParser.ParseFilter(input, config)); - filterExpression.ToString().Should().Be("x => (x.Rating > 3)"); + if (expected == null) + { + var sortExpressions = WithCulture(cultureName, () => SortParser.ParseSort(input, config)); + sortExpressions.Should().ContainSingle(); + sortExpressions[0].Expression!.ToString().Should().Contain("x.Rating"); + return; + } + + var filterExpression = WithCulture(cultureName, () => FilterParser.ParseFilter(input, config)); + filterExpression.ToString().Should().Be(expected); + } + + [Theory] + [InlineData("tıp", "TIP > 3")] + [InlineData("tip", "TİP > 3")] + public void query_name_does_not_match_with_the_dotted_and_dotless_i_rules_of_tr_tr(string queryName, string input) + { + var config = new QueryKitConfiguration(settings => + { + settings.Property(x => x.Rating).HasQueryName(queryName); + }); var turkish = () => WithCulture("tr-TR", () => FilterParser.ParseFilter(input, config)); + turkish.Should().Throw(); } diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index b80d515..0d460b6 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -494,7 +494,7 @@ public void prevented_derived_sort_property_is_skipped() } [Fact] - public void query_name_in_a_property_list_throws() + public void query_name_in_a_property_list_resolves_to_its_property() { var input = """(name, FirstName) == "x" """; var config = new QueryKitConfiguration(config => @@ -502,13 +502,13 @@ public void query_name_in_a_property_list_throws() config.Property(x => x.Title!).HasQueryName("name"); }); - var act = () => FilterParser.ParseFilter(input, config); + var filterExpression = FilterParser.ParseFilter(input, config); - act.Should().ThrowExactly().WithMessage("*'name'*"); + filterExpression.ToDisplayString().Should().Be("""x => ((x.Title == "x") OrElse (x.FirstName == "x"))"""); } [Fact] - public void query_name_of_a_prevented_property_in_a_property_list_throws() + public void query_name_of_a_prevented_property_in_a_property_list_is_skipped() { var input = """(hidden, Title) == "x" """; var config = new QueryKitConfiguration(config => @@ -516,13 +516,13 @@ public void query_name_of_a_prevented_property_in_a_property_list_throws() config.Property(x => x.FirstName!).HasQueryName("hidden").PreventFilter().PreventSort(); }); - var act = () => FilterParser.ParseFilter(input, config); + var filterExpression = FilterParser.ParseFilter(input, config); - act.Should().ThrowExactly().WithMessage("*'hidden'*"); + filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "x")"""); } [Fact] - public void query_name_in_arithmetic_throws() + public void query_name_in_arithmetic_resolves_to_its_property() { var input = """(stars + 0) > 3"""; var config = new QueryKitConfiguration(config => @@ -530,9 +530,24 @@ public void query_name_in_arithmetic_throws() config.Property(x => x.Rating!).HasQueryName("stars"); }); - var act = () => FilterParser.ParseFilter(input, config); + var filterExpression = FilterParser.ParseFilter(input, config); - act.Should().ThrowExactly(); + filterExpression.ToDisplayString().Should().Contain("x.Rating"); + } + + [Fact] + public void query_name_in_arithmetic_without_parentheses_throws_like_its_property() + { + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Age!).HasQueryName("years"); + }); + + var queryName = () => FilterParser.ParseFilter("years + 1 > 3", config); + var property = () => FilterParser.ParseFilter("Age + 1 > 3", config); + + queryName.Should().ThrowExactly(); + property.Should().ThrowExactly(); } [Theory] @@ -569,7 +584,7 @@ public void query_name_with_a_hyphen_resolves_in_every_case() } [Fact] - public void query_name_with_a_hyphen_in_a_value_is_replaced() + public void query_name_with_a_hyphen_in_a_value_is_not_replaced() { var input = """Title == "first-name == x" """; var config = new QueryKitConfiguration(config => @@ -579,7 +594,7 @@ public void query_name_with_a_hyphen_in_a_value_is_replaced() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "FirstName == x")"""); + filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "first-name == x")"""); } [Fact] @@ -657,7 +672,7 @@ public void query_name_does_not_match_the_start_of_a_longer_property_name() } [Fact] - public void query_name_with_a_hyphen_in_a_property_list_throws() + public void query_name_with_a_hyphen_in_a_property_list_resolves_to_its_property() { var input = """(first-name, Title) == "x" """; var config = new QueryKitConfiguration(config => @@ -665,9 +680,9 @@ public void query_name_with_a_hyphen_in_a_property_list_throws() config.Property(x => x.FirstName!).HasQueryName("first-name"); }); - var act = () => FilterParser.ParseFilter(input, config); + var filterExpression = FilterParser.ParseFilter(input, config); - act.Should().ThrowExactly().WithMessage("*'first'*"); + filterExpression.ToDisplayString().Should().Be("""x => ((x.FirstName == "x") OrElse (x.Title == "x"))"""); } [Fact] @@ -713,7 +728,7 @@ public void derived_property_query_name_does_not_match_the_start_of_a_longer_nam } [Fact] - public void query_name_in_a_value_is_replaced() + public void query_name_in_a_value_is_not_replaced() { var input = """FirstName == "name == x" """; var config = new QueryKitConfiguration(config => @@ -723,11 +738,11 @@ public void query_name_in_a_value_is_replaced() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "Title == x")"""); + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "name == x")"""); } [Fact] - public void property_prevented_for_filter_and_sort_throws_by_its_query_name() + public void property_prevented_for_filter_and_sort_is_removed_by_its_query_name() { var input = """name == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => @@ -735,26 +750,56 @@ public void property_prevented_for_filter_and_sort_throws_by_its_query_name() config.Property(x => x.Title!).HasQueryName("name").PreventFilter().PreventSort(); }); - var act = () => FilterParser.ParseFilter(input, config); + var filterExpression = FilterParser.ParseFilter(input, config); - act.Should().ThrowExactly() - .WithMessage("'Title' is not allowed for filtering or sorting."); + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); } [Fact] - public void property_prevented_for_filter_and_sort_throws_by_its_query_name_before_an_operator_alias() + public void property_prevented_for_filter_and_sort_is_removed_by_its_query_name_before_an_operator_alias() { - var input = """name eq "x" """; + var input = """name eq "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { config.EqualsOperator = "eq"; config.Property(x => x.Title!).HasQueryName("name").PreventFilter().PreventSort(); }); - var act = () => FilterParser.ParseFilter(input, config); + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Theory] + [InlineData("==")] + [InlineData("_=")] + public void prevented_property_on_the_right_side_is_not_compared_when_another_query_name_matches_its_name(string op) + { + var input = $"""Directions {op} Title || Rating > 3"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Directions).HasQueryName("title"); + config.Property(x => x.Title).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); - act.Should().ThrowExactly() - .WithMessage("'Title' is not allowed for filtering or sorting."); + filterExpression.ToDisplayString().Should().Be("x => (x.Rating > 3)"); + } + + [Fact] + public void prevented_sort_property_is_skipped_when_its_property_path_is_another_query_name() + { + var input = "title asc"; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Rating!).HasQueryName("directions"); + config.Property(x => x.Directions).HasQueryName("title").PreventSort(); + }); + + var sortExpressions = SortParser.ParseSort(input, config); + + sortExpressions.Should().BeEmpty(); } [Fact] diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 2890a38..db27e21 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; @@ -146,6 +145,52 @@ from rest in Parse.LetterOrDigit.XOr(Parse.Char('_')).Many() private static readonly Parser IdentifierPathParser = Identifier.DelimitedBy(Parse.Char('.')).Select(parts => string.Join(".", parts)); + // A property is a configured query name or a path of identifiers. Query names are matched in the grammar, + // so a query name can hold any text (e.g. `first-name`, `_first`, or `first name`) and text inside quoted values is never changed. + // Longer query names are tried first so a query name that starts with another query name (e.g. `first` and `first name`) still matches. + // Every query name ignores case with the rules of the invariant culture, so the result does not depend on the culture of the parse. + private static Parser PropertyPathParser(IQueryKitConfiguration? config) + { + Parser parser = i => Result.Failure(i, "no query name", Array.Empty()); + var mappings = config?.PropertyMappings; + if (mappings != null) + { + var queryNames = mappings.PropertyQueryNames + .Concat(mappings.DerivedOrCustomOperationQueryNames) + .OrderByDescending(queryName => queryName.Length); + foreach (var queryName in queryNames) + { + parser = parser.Or(QueryName(queryName)); + } + } + + return parser.Or(IdentifierPathParser); + } + + // A query name is a whole name: the next character can not continue a property path. + private static Parser QueryName(string queryName) => input => + { + var remainder = input; + foreach (var c in queryName) + { + if (remainder.AtEnd || char.ToLowerInvariant(remainder.Current) != char.ToLowerInvariant(c)) + { + return Result.Failure(input, $"Query name '{queryName}' expected", new[] { queryName }); + } + + remainder = remainder.Advance(); + } + + if (!remainder.AtEnd && IsPropertyPathChar(remainder.Current)) + { + return Result.Failure(input, $"Query name '{queryName}' must not be followed by '{remainder.Current}'", new[] { queryName }); + } + + return Result.Success(queryName, remainder); + }; + + private static bool IsPropertyPathChar(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '.'; + private static Parser> PropertyListParser(Parser propertyPathParser) { var propertiesParser = propertyPathParser.Token().DelimitedBy(Parse.Char(',').Token()); @@ -931,7 +976,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa if (!temp.rightIsQuotedLiteral && IsPropertyPath(temp.right, parameter.Type)) { // 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); + var rightReference = PropertyResolver.ResolveWithoutQueryName(parameter.Type, temp.right, config); if (!rightReference.CanFilter) { return IgnoredClause(config); @@ -1058,76 +1103,45 @@ private static Parser ComparisonExprParser(ParameterExpression pa private static Parser CreateLeftExprParser(Type entityType, IQueryKitConfiguration? config) { - var leftPropertyParser = IdentifierPathParser.Token(); - var queryNameParser = DerivedOrCustomOperationQueryNameParser(config).Token(); + var leftPropertyParser = PropertyPathParser(config).Token(); + var identifierPathParser = IdentifierPathParser.Token(); + var derivedOrCustomOperationQueryNames = new HashSet( + config?.PropertyMappings?.DerivedOrCustomOperationQueryNames ?? Enumerable.Empty(), + StringComparer.InvariantCultureIgnoreCase); return input => { var left = leftPropertyParser(input); - var reference = left.WasSuccessful ? PropertyResolver.Resolve(entityType, left.Value, config) : null; - if (reference != null && (reference.Kind != PropertyReferenceKind.Unknown || config?.AllowUnknownProperties == true)) + if (!left.WasSuccessful) { - return Result.Success(reference, left.Remainder); + return Result.Failure(left.Remainder, left.Message, left.Expectations); } - // v1.14.2 did not accept the text here, so a derived property or custom operation query name can not change an accepted filter. - var queryName = _queryNameFallbackOff && reference != null - ? Result.Failure(input, "query names are off", Array.Empty()) - : queryNameParser(input); - if (queryName.WasSuccessful) + // v1.14.2 read an identifier path where the grammar now reads a derived property or custom operation query name. + // When that identifier path is unknown, a filter that fails throws the v1.14.2 exception for it. + if (derivedOrCustomOperationQueryNames.Contains(left.Value)) { - _queryNameOverUnknown |= reference != null; - return Result.Success(PropertyResolver.Resolve(entityType, queryName.Value, config), queryName.Remainder); + var identifier = identifierPathParser(input); + var identifierReference = identifier.WasSuccessful ? PropertyResolver.Resolve(entityType, identifier.Value, config) : null; + if (identifierReference?.Kind == PropertyReferenceKind.Unknown && config?.AllowUnknownProperties != true) + { + _queryNameOverUnknown = true; + if (_queryNameFallbackOff) + { + throw new UnknownFilterPropertyException(identifierReference.UnknownSegment!); + } + } } - if (reference == null) + var reference = PropertyResolver.Resolve(entityType, left.Value, config); + if (reference.Kind == PropertyReferenceKind.Unknown && config?.AllowUnknownProperties != true) { - return Result.Failure(left.Remainder, left.Message, left.Expectations); + throw new UnknownFilterPropertyException(reference.UnknownSegment!); } - throw new UnknownFilterPropertyException(reference.UnknownSegment!); + return Result.Success(reference, left.Remainder); }; } - // The rewrite before the parse does not replace the query name of a derived property or a custom operation, - // so the grammar reads it when the identifier path is not a property. This lets the query name hold any text (for example `full-name` or `full name`). - // Longer query names are tried first, so a query name that starts with another query name still matches. - private static Parser DerivedOrCustomOperationQueryNameParser(IQueryKitConfiguration? config) - { - Parser parser = i => Result.Failure(i, "no query name", Array.Empty()); - var mappings = config?.PropertyMappings; - if (mappings == null) - { - return parser; - } - - var queryNames = mappings.DerivedPropertyMappings.Values.Concat(mappings.CustomOperationMappings.Values) - .Select(info => info.QueryName) - .Where(queryName => !string.IsNullOrEmpty(queryName)) - .Select(queryName => queryName!) - .Distinct(StringComparer.InvariantCultureIgnoreCase) - .OrderByDescending(queryName => queryName.Length); - foreach (var queryName in queryNames) - { - parser = parser.Or(WholeQueryName(queryName)); - } - - return parser; - } - - // A query name is a whole name: the next character can not continue a property path. - private static Parser WholeQueryName(string queryName) => input => - { - var result = Parse.IgnoreCase(queryName).Text()(input); - if (!result.WasSuccessful || result.Remainder.AtEnd || !IsPropertyPathChar(result.Remainder.Current)) - { - return result; - } - - return Result.Failure(input, $"Query name '{queryName}' must not be followed by '{result.Remainder.Current}'", new[] { queryName }); - }; - - private static bool IsPropertyPathChar(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '.'; - private static Expression CreateLeftExpr(ParameterExpression parameter, PropertyReference reference, IQueryKitConfiguration? config) { var propertyExpression = reference.Kind == PropertyReferenceKind.DerivedProperty @@ -1140,9 +1154,7 @@ private static Expression CreateLeftExpr(ParameterExpression parameter, Property } // Check if this property uses HasConversion - var propertyConfig = config?.PropertyMappings?.GetPropertyInfoByQueryName( - config.PropertyMappings.GetPropertyPathByQueryName(reference.Text) ?? reference.Text); - if (propertyConfig?.UsesConversion == true) + if (reference.Mapping?.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 @@ -1258,7 +1270,7 @@ private static Parser PropertyListComparisonExprParser( var comparisonOperatorParser = ComparisonOperatorParser(config).Token(); var rightSideValueParser = RightSideValueParser.Token(); - return PropertyListParser(IdentifierPathParser) + return PropertyListParser(PropertyPathParser(config)) .SelectMany(properties => comparisonOperatorParser, (properties, op) => new { properties, op }) .SelectMany(temp => rightSideValueParser, diff --git a/QueryKit/PropertyResolver.cs b/QueryKit/PropertyResolver.cs index 6a3490b..8d60f98 100644 --- a/QueryKit/PropertyResolver.cs +++ b/QueryKit/PropertyResolver.cs @@ -57,9 +57,20 @@ 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?.GetPropertyInfoByQueryName(reference)?.Name ?? reference; + return Resolve(rootType, reference, path, config); + } + + // Resolves a property path without query names. On the right side of a comparison, a query name is a value, not a property. + internal static PropertyReference ResolveWithoutQueryName(Type rootType, string reference, IQueryKitConfiguration? config) + => Resolve(rootType, reference, reference, config); + + private static PropertyReference Resolve(Type rootType, string reference, string path, IQueryKitConfiguration? config) + { + 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)); diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index a0aec17..d914ca8 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -14,6 +14,20 @@ public class QueryKitPropertyMappings internal IReadOnlyDictionary DerivedPropertyMappings => _derivedPropertyMappings; internal IReadOnlyDictionary CustomOperationMappings => _customOperationMappings; + // Every query name of a property + internal IEnumerable PropertyQueryNames => QueryNamesOf(_propertyMappings.Values); + + // Every query name of a derived property or a custom operation + internal IEnumerable DerivedOrCustomOperationQueryNames + => QueryNamesOf(_derivedPropertyMappings.Values.Concat(_customOperationMappings.Values)); + + private static IEnumerable QueryNamesOf(IEnumerable infos) + => infos + .Select(info => info.QueryName) + .Where(queryName => !string.IsNullOrEmpty(queryName)) + .Select(queryName => queryName!) + .Distinct(StringComparer.InvariantCultureIgnoreCase); + public QueryKitPropertyMapping Property(Expression>? propertySelector) { var fullPath = GetFullPropertyPath(propertySelector); diff --git a/QueryKit/SortParser.cs b/QueryKit/SortParser.cs index ac7d682..ecc71b0 100644 --- a/QueryKit/SortParser.cs +++ b/QueryKit/SortParser.cs @@ -55,8 +55,7 @@ private static SortExpressionInfo CreateSortExpression(string sortClause, throw new QueryKitParsingException($"Invalid direction: {direction}. Allowed values are '{Ascending}' and '{Descending}'."); } - var propertyPath = config?.GetPropertyPathByQueryName(propertyName) ?? propertyName; - var reference = PropertyResolver.Resolve(typeof(T), propertyPath, config); + var reference = PropertyResolver.Resolve(typeof(T), propertyName, config); if (reference.Kind != PropertyReferenceKind.CustomOperation && !reference.CanSort) { return new SortExpressionInfo diff --git a/README.md b/README.md index 8d3ca88..99e8ea8 100644 --- a/README.md +++ b/README.md @@ -580,6 +580,8 @@ var config = new QueryKitConfiguration(config => }); ``` +You can use a query name on the left side of a comparison, in a property list, and in an arithmetic expression. For example, `(first, LastName) == "Jane"` compares `FirstName` and `LastName` with `Jane`. QueryKit does not change text inside a quoted value. For example, `LastName == "first == x"` compares `LastName` with the text `first == x`. A query name ignores case with the rules of the invariant culture, so `FIRST` matches `first` in every culture, also in `tr-TR`. + #### Derived Properties You can also expose custom derived properties for consumption. Just be sure that Linq can handle them in a db query if you're using it that way.