diff --git a/.agents/skills/verify-querykit/features/configuration.md b/.agents/skills/verify-querykit/features/configuration.md index ad58ae2..57c07e1 100644 --- a/.agents/skills/verify-querykit/features/configuration.md +++ b/.agents/skills/verify-querykit/features/configuration.md @@ -25,9 +25,10 @@ Preconditions: - A run is up and `qk doctor` prints only `ok` lines. - The seed data matches `features/README.md`. -- `qk configs` lists `aliases`, `derived`, `custom-operation`, `word-operators`, `allow-unknown`, `max-depth-0`, and `upper`. +- `qk configs` lists `aliases`, `loose-names`, `derived`, `custom-operation`, `word-operators`, `allow-unknown`, `max-depth-0`, and `upper`. - **Query names.** Run `qk run configuration-query-name --config aliases --filter 'chef == "Julia Child" && name _= "S"'`. Both targets give `["Salt Bread"]`. +- **Query names that are not identifiers.** Run `qk run configuration-loose-query-names --config loose-names --filter 'recipe-title == "Pancakes" || _stars > 4 || chef name == "Gordon Ramsay"'`. Both targets give `["Pancakes", "Beef Stew"]`. - **Prevent filter.** Run `qk run configuration-prevent-filter --config aliases --filter 'Rating > 1'`. Exit `0`. Both targets give all four recipes. The `expression` is `x => (True == True)`. - **Prevent sort.** Run `qk run configuration-prevent-sort --config aliases --sort 'Price'`. Exit `0`. Both targets give the seed order `["Pancakes", "Beef Stew", "Salt Bread", "Plain Water"]`, not the price order. - **Derived properties.** Run `qk run configuration-derived --config derived --filter 'headline @=* "julia" && top_rated == true'`. Both targets give `Pancakes` and `Salt Bread`. The `sql` contains `|| ' by ' ||`. diff --git a/.agents/skills/verify-querykit/harness/Driver/Configs.cs b/.agents/skills/verify-querykit/harness/Driver/Configs.cs index f039f90..b72c164 100644 --- a/.agents/skills/verify-querykit/harness/Driver/Configs.cs +++ b/.agents/skills/verify-querykit/harness/Driver/Configs.cs @@ -21,6 +21,14 @@ public static class Configs s.Property(x => x.Price).PreventSort(); })), + ["loose-names"] = ("HasQueryName with text that is not an identifier: Title->recipe-title, Rating->_stars, Author.Name->chef name.", + () => new QueryKitConfiguration(s => + { + s.Property(x => x.Title).HasQueryName("recipe-title"); + s.Property(x => x.Rating).HasQueryName("_stars"); + s.Property(x => x.Author.Name).HasQueryName("chef name"); + })), + ["derived"] = ("DerivedProperty: headline = Title + \" by \" + Author.Name, top_rated = Rating >= 4.", () => new QueryKitConfiguration(s => { diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 590601b..2704fd7 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -409,4 +409,34 @@ public async Task unknown_property_in_arithmetic_removes_the_clause_when_unknown // Assert people.Should().BeEmpty(); } + + [Theory] + [InlineData("first-name")] + [InlineData("_first")] + [InlineData("first name")] + public async Task query_name_that_is_not_a_plain_identifier_filters_by_its_property(string queryName) + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var firstName = new Faker().Lorem.Sentence(); + var fakePerson = new FakeTestingPersonBuilder() + .WithFirstName(firstName) + .Build(); + await testingServiceScope.InsertAsync(fakePerson); + + var input = $"""{queryName} == "{firstName}" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.FirstName).HasQueryName(queryName); + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().ContainSingle(); + people[0].Id.Should().Be(fakePerson.Id); + } } diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 90cc61d..238561a 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -320,6 +320,140 @@ public void query_name_in_arithmetic_resolves_to_its_property() filterExpression.ToDisplayString().Should().Contain("x.Rating"); } + [Theory] + [InlineData("first-name")] + [InlineData("_first")] + [InlineData("first name")] + [InlineData("person.first")] + [InlineData("first_name")] + public void query_name_that_is_not_a_plain_identifier_resolves_to_its_property(string queryName) + { + var input = $"""{queryName} == "Ann" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.FirstName).HasQueryName(queryName); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "Ann")"""); + } + + [Fact] + public void query_name_with_a_hyphen_resolves_in_every_case() + { + var input = """FIRST-NAME == "Ann" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.FirstName).HasQueryName("first-name"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "Ann")"""); + } + + [Fact] + public void query_name_with_a_hyphen_in_a_value_is_not_replaced() + { + var input = """Title == "first-name == x" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.FirstName).HasQueryName("first-name"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "first-name == x")"""); + } + + [Fact] + public void query_name_on_the_right_side_is_a_value() + { + var input = """Title == first"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.FirstName).HasQueryName("first"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "first")"""); + } + + [Fact] + public void query_name_with_a_hyphen_sorts_by_its_property() + { + var input = "first-name desc"; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.FirstName).HasQueryName("first-name"); + }); + + var sortExpressions = SortParser.ParseSort(input, config); + + sortExpressions.Should().ContainSingle(); + sortExpressions[0].Expression!.ToString().Should().Be("x => Convert(x.FirstName, Object)"); + sortExpressions[0].IsAscending.Should().BeFalse(); + } + + [Fact] + public void longer_query_name_wins_over_a_query_name_it_starts_with() + { + var input = """first name == "Ann" && first == "Lee" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.FirstName).HasQueryName("first name"); + config.Property(x => x.LastName).HasQueryName("first"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => ((x.FirstName == "Ann") AndAlso (x.LastName == "Lee"))"""); + } + + [Fact] + public void query_name_does_not_match_the_start_of_a_longer_property_name() + { + var input = """FirstName == "Ann" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("first"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "Ann")"""); + } + + [Fact] + 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 => + { + config.Property(x => x.FirstName).HasQueryName("first-name"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => ((x.FirstName == "x") OrElse (x.Title == "x"))"""); + } + + [Fact] + public void derived_property_query_name_with_a_hyphen_resolves_to_its_expression() + { + var input = """full-name == "Ann Lee" """; + var config = new QueryKitConfiguration(config => + { + config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full-name"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (((x.FirstName + " ") + x.LastName) == "Ann Lee")"""); + } + [Fact] public void query_name_in_a_value_is_not_replaced() { diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 37bac4d..f34de33 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -98,16 +98,48 @@ from first in Parse.Letter.Once() from rest in Parse.LetterOrDigit.XOr(Parse.Char('_')).Many() select new string(first.Concat(rest).ToArray()); + 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. + private static Parser PropertyPathParser(IQueryKitConfiguration? config) + { + Parser parser = i => Result.Failure(i, "no query name", Array.Empty()); + var queryNames = config?.PropertyMappings?.QueryNames ?? Enumerable.Empty(); + foreach (var queryName in queryNames.OrderByDescending(x => x.Length)) + { + 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 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 Parser> PropertyListParser(Parser propertyPathParser) + { + var propertiesParser = propertyPathParser.Token().DelimitedBy(Parse.Char(',').Token()); + return from openParen in Parse.Char('(') + from properties in propertiesParser + from closeParen in Parse.Char(')') + select properties; + } + // Each parser is built once. A parser in a second or later `from` clause is built in a lambda // that runs on each parse, so keep those parsers in fields too. A field can only use fields that // are declared above it, so the recursive arithmetic parser goes through Parse.Ref. - private static readonly Parser>> PropertyListParser = - from openParen in Parse.Char('(') - from properties in Identifier.DelimitedBy(Parse.Char('.')).Token() - .DelimitedBy(Parse.Char(',').Token()) - from closeParen in Parse.Char(')') - select properties; - private static readonly Parser ComparisonOperatorTextParser = Parse.String(ComparisonOperator.EqualsOperator().Operator()).Text() .Or(Parse.String(ComparisonOperator.NotEqualsOperator().Operator()).Text()) @@ -970,11 +1002,11 @@ private static Parser ComparisonExprParser(ParameterExpression pa private static Parser CreateLeftExprParser(Type entityType, IQueryKitConfiguration? config) { - var leftIdentifierParser = Identifier.DelimitedBy(Parse.Char('.')).Token(); + var leftPropertyParser = PropertyPathParser(config).Token(); - return leftIdentifierParser.Select(left => + return leftPropertyParser.Select(left => { - var reference = PropertyResolver.Resolve(entityType, string.Join(".", left), config); + var reference = PropertyResolver.Resolve(entityType, left, config); if (reference.Kind == PropertyReferenceKind.Unknown && config?.AllowUnknownProperties != true) { throw new UnknownFilterPropertyException(reference.UnknownSegment!); @@ -1113,7 +1145,7 @@ private static Parser PropertyListComparisonExprParser( var comparisonOperatorParser = ComparisonOperatorParser(config).Token(); var rightSideValueParser = RightSideValueParser.Token(); - return PropertyListParser + return PropertyListParser(PropertyPathParser(config)) .SelectMany(properties => comparisonOperatorParser, (properties, op) => new { properties, op }) .SelectMany(temp => rightSideValueParser, @@ -1131,11 +1163,8 @@ private static Parser PropertyListComparisonExprParser( // we use AND instead of OR so that all properties must NOT match var isNegativeOperator = temp.op.Operator().StartsWith("!") || temp.op.Operator().Contains("!="); - foreach (var propertyPath in temp.properties) + foreach (var fullPropPath in temp.properties) { - var propertyPathList = propertyPath.ToList(); - var fullPropPath = string.Join(".", propertyPathList); - // Build expression for each property. A property list does not support custom operations. var reference = PropertyResolver.Resolve(parameter.Type, fullPropPath, config); if (!reference.CanFilter) diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index 216231e..229bb4f 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -14,6 +14,14 @@ public class QueryKitPropertyMappings internal IReadOnlyDictionary DerivedPropertyMappings => _derivedPropertyMappings; internal IReadOnlyDictionary CustomOperationMappings => _customOperationMappings; + // Every query name of a property, a derived property, or a custom operation + internal IEnumerable QueryNames + => _propertyMappings.Values.Concat(_derivedPropertyMappings.Values).Concat(_customOperationMappings.Values) + .Select(info => info.QueryName) + .Where(queryName => !string.IsNullOrEmpty(queryName)) + .Select(queryName => queryName!) + .Distinct(StringComparer.InvariantCultureIgnoreCase); + public QueryKitPropertyMapping Property(Expression>? propertySelector) { var fullPath = GetFullPropertyPath(propertySelector);