Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/skills/verify-querykit/features/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 ' ||`.
Expand Down
8 changes: 8 additions & 0 deletions .agents/skills/verify-querykit/harness/Driver/Configs.cs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ public static class Configs
s.Property<Recipe>(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<Recipe>(x => x.Title).HasQueryName("recipe-title");
s.Property<Recipe>(x => x.Rating).HasQueryName("_stars");
s.Property<Recipe>(x => x.Author.Name).HasQueryName("chef name");
})),

["derived"] = ("DerivedProperty: headline = Title + \" by \" + Author.Name, top_rated = Rating >= 4.",
() => new QueryKitConfiguration(s =>
{
Expand Down
30 changes: 30 additions & 0 deletions QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TestingPerson>(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);
}
}
134 changes: 134 additions & 0 deletions QueryKit.UnitTests/PropertyResolverTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
var input = """Rating == 1 || Age > 100""";
var config = new QueryKitConfiguration(config =>
{
config.Property<TestingPerson>(x => x.Rating).PreventFilter();

Check warning on line 46 in QueryKit.UnitTests/PropertyResolverTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.

Check warning on line 46 in QueryKit.UnitTests/PropertyResolverTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
Expand Down Expand Up @@ -71,8 +71,8 @@
var input = """(Title, FirstName) == "x" || Age > 100""";
var config = new QueryKitConfiguration(config =>
{
config.Property<TestingPerson>(x => x.Title).PreventFilter();

Check warning on line 74 in QueryKit.UnitTests/PropertyResolverTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.

Check warning on line 74 in QueryKit.UnitTests/PropertyResolverTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.
config.Property<TestingPerson>(x => x.FirstName).PreventFilter();

Check warning on line 75 in QueryKit.UnitTests/PropertyResolverTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.

Check warning on line 75 in QueryKit.UnitTests/PropertyResolverTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
Expand All @@ -86,7 +86,7 @@
var input = """(Age + 0) > 10 || Title == "a" """;
var config = new QueryKitConfiguration(config =>
{
config.Property<TestingPerson>(x => x.Age).PreventFilter();

Check warning on line 89 in QueryKit.UnitTests/PropertyResolverTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.

Check warning on line 89 in QueryKit.UnitTests/PropertyResolverTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
Expand All @@ -100,7 +100,7 @@
var input = """(Age + 0) > (Rating * 2)""";
var config = new QueryKitConfiguration(config =>
{
config.Property<TestingPerson>(x => x.Rating).PreventFilter();

Check warning on line 103 in QueryKit.UnitTests/PropertyResolverTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.

Check warning on line 103 in QueryKit.UnitTests/PropertyResolverTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
Expand Down Expand Up @@ -128,7 +128,7 @@
var input = """FirstName == Title || Age > 100""";
var config = new QueryKitConfiguration(config =>
{
config.Property<TestingPerson>(x => x.Title).PreventFilter();

Check warning on line 131 in QueryKit.UnitTests/PropertyResolverTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
Expand All @@ -142,7 +142,7 @@
var input = """FirstName == title || Age > 100""";
var config = new QueryKitConfiguration(config =>
{
config.Property<TestingPerson>(x => x.Title).PreventFilter();

Check warning on line 145 in QueryKit.UnitTests/PropertyResolverTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
Expand All @@ -156,7 +156,7 @@
var input = """(title, FirstName) == "x" """;
var config = new QueryKitConfiguration(config =>
{
config.Property<TestingPerson>(x => x.Title).PreventFilter();

Check warning on line 159 in QueryKit.UnitTests/PropertyResolverTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
Expand All @@ -170,7 +170,7 @@
var input = """title == "x" || Age > 100""";
var config = new QueryKitConfiguration(config =>
{
config.Property<TestingPerson>(x => x.Title).PreventFilter();

Check warning on line 173 in QueryKit.UnitTests/PropertyResolverTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);
Expand Down Expand Up @@ -320,6 +320,140 @@
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<TestingPerson>(x => x.FirstName).HasQueryName(queryName);
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(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<TestingPerson>(x => x.FirstName).HasQueryName("first-name");
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(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<TestingPerson>(x => x.FirstName).HasQueryName("first-name");
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(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<TestingPerson>(x => x.FirstName).HasQueryName("first");
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(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<TestingPerson>(x => x.FirstName).HasQueryName("first-name");
});

var sortExpressions = SortParser.ParseSort<TestingPerson>(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<TestingPerson>(x => x.FirstName).HasQueryName("first name");
config.Property<TestingPerson>(x => x.LastName).HasQueryName("first");
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(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<TestingPerson>(x => x.Title).HasQueryName("first");
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(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<TestingPerson>(x => x.FirstName).HasQueryName("first-name");
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(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<TestingPerson>(x => x.FirstName + " " + x.LastName).HasQueryName("full-name");
});

var filterExpression = FilterParser.ParseFilter<TestingPerson>(input, config);

filterExpression.ToDisplayString().Should().Be("""x => (((x.FirstName + " ") + x.LastName) == "Ann Lee")""");
}

[Fact]
public void query_name_in_a_value_is_not_replaced()
{
Expand Down
59 changes: 44 additions & 15 deletions QueryKit/FilterParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> 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<string> PropertyPathParser(IQueryKitConfiguration? config)
{
Parser<string> parser = i => Result.Failure<string>(i, "no query name", Array.Empty<string>());
var queryNames = config?.PropertyMappings?.QueryNames ?? Enumerable.Empty<string>();
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<string> 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<string>(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<IEnumerable<string>> PropertyListParser(Parser<string> 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<IEnumerable<IEnumerable<string>>> 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<string> ComparisonOperatorTextParser =
Parse.String(ComparisonOperator.EqualsOperator().Operator()).Text()
.Or(Parse.String(ComparisonOperator.NotEqualsOperator().Operator()).Text())
Expand Down Expand Up @@ -970,11 +1002,11 @@ private static Parser<Expression> ComparisonExprParser<T>(ParameterExpression pa

private static Parser<PropertyReference> 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!);
Expand Down Expand Up @@ -1113,7 +1145,7 @@ private static Parser<Expression> PropertyListComparisonExprParser<T>(
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,
Expand All @@ -1131,11 +1163,8 @@ private static Parser<Expression> PropertyListComparisonExprParser<T>(
// 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)
Expand Down
8 changes: 8 additions & 0 deletions QueryKit/QueryKitPropertyMappings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ public class QueryKitPropertyMappings
internal IReadOnlyDictionary<string, QueryKitPropertyInfo> DerivedPropertyMappings => _derivedPropertyMappings;
internal IReadOnlyDictionary<string, QueryKitPropertyInfo> CustomOperationMappings => _customOperationMappings;

// Every query name of a property, a derived property, or a custom operation
internal IEnumerable<string> 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<TModel> Property<TModel>(Expression<Func<TModel, object>>? propertySelector)
{
var fullPath = GetFullPropertyPath(propertySelector);
Expand Down
Loading