Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ namespace QueryKit.IntegrationTests.Tests;
public class FilterParsingRegressionTests : TestBase
{
[Fact]
public async Task operator_alias_text_inside_quoted_value_is_replaced()
public async Task operator_alias_text_inside_quoted_value_is_kept()
{
// Arrange
var testingServiceScope = new TestingServiceScope();
Expand All @@ -41,7 +41,8 @@ public async Task operator_alias_text_inside_quoted_value_is_replaced()
var people = await appliedQueryable.ToListAsync();

// Assert
people.Should().BeEmpty();
people.Count.Should().Be(1);
people[0].Id.Should().Be(fakePersonOne.Id);
}

[Fact]
Expand Down
12 changes: 6 additions & 6 deletions QueryKit.UnitTests/OperatorAliasTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -205,27 +205,27 @@ public void can_use_ints()
}

[Fact]
public void alias_text_inside_quoted_value_is_replaced()
public void alias_text_inside_quoted_value_is_not_replaced()
{
var input = """Title eq "salt and pepper or eq gt" and Rating gt 3""";

var config = new CustomQueryKitConfiguration();
var filterExpression = FilterParser.ParseFilter<Recipe>(input, config);
filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "salt && pepper || == gt") AndAlso (x.Rating > 3))""");
filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "salt and pepper or eq gt") AndAlso (x.Rating > 3))""");
}

[Fact]
public void case_insensitive_alias_text_inside_quoted_value_is_replaced()
public void case_insensitive_alias_text_inside_quoted_value_is_not_replaced()
{
var input = """Title eqi "Whisk AND fry" """;

var config = new CustomQueryKitConfiguration();
var filterExpression = FilterParser.ParseFilter<Recipe>(input, config);
filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title != null) AndAlso (x.Title.ToLower() == "Whisk && fry".ToLower()))""");
filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title != null) AndAlso (x.Title.ToLower() == "Whisk AND fry".ToLower()))""");
}

[Fact]
public void can_use_alias_operator_with_query_name_and_alias_text_in_the_value_is_replaced()
public void can_use_alias_operator_with_query_name()
{
var input = """name eq "a eq b" """;

Expand All @@ -234,7 +234,7 @@ public void can_use_alias_operator_with_query_name_and_alias_text_in_the_value_i
settings.Property<Recipe>(x => x.Title).HasQueryName("name");
});
var filterExpression = FilterParser.ParseFilter<Recipe>(input, config);
filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "a == b")""");
filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "a eq b")""");
}

public class CustomQueryKitConfiguration : QueryKitConfiguration
Expand Down
28 changes: 0 additions & 28 deletions QueryKit/Configuration/QueryKitConfigurationExtensions.cs
Original file line number Diff line number Diff line change
@@ -1,37 +1,9 @@
namespace QueryKit.Configuration;

using System.Text.RegularExpressions;
using QueryKit.Exceptions;
using QueryKit.Operators;

internal static class QueryKitConfigurationExtensions
{
internal static string ReplaceComparisonAliases(this IQueryKitConfiguration configuration, string input)
{
var aliasedOperators = ComparisonOperator.GetAliasMatches(configuration);
foreach (var comparisonAliasMatch in aliasedOperators)
{
var escapedAlias = Regex.Escape(comparisonAliasMatch.Alias);
var regex = AliasRegexCache.Get($@"(?<=\s|^){escapedAlias}(?=\s|$)");
input = regex.Replace(input, comparisonAliasMatch.Operator);
}

return input;
}

internal static string ReplaceLogicalAliases(this IQueryKitConfiguration configuration, string input)
{
var aliasedOperators = LogicalOperator.GetAliasMatches(configuration);
foreach (var logicalAliasMatch in aliasedOperators)
{
var escapedAlias = Regex.Escape(logicalAliasMatch.Alias);
var regex = AliasRegexCache.Get($@"(?<=\s|^){escapedAlias}(?=\s|$)");
input = regex.Replace(input, logicalAliasMatch.Operator);
}

return input;
}

internal static string? GetPropertyPathByQueryName(this IQueryKitConfiguration configuration, string? queryName)
{
return configuration.PropertyMappings.GetPropertyPathByQueryName(queryName);
Expand Down
22 changes: 16 additions & 6 deletions QueryKit/FilterParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ public static Expression<Func<T, bool>> ParseFilter<T>(string input, IQueryKitCo
{
EnsureWithinInputLength(input, config);

input = config?.ReplaceLogicalAliases(input) ?? input;
input = config?.ReplaceComparisonAliases(input) ?? input;
input = config?.PropertyMappings?.ReplaceAliasesWithPropertyPaths(input) ?? input;
input = ReplaceQueryNamesWithPropertyPaths(input, config);

var parameter = Expression.Parameter(typeof(T), "x");
Expression expr;
Expand Down Expand Up @@ -81,6 +79,19 @@ public static Expression<Func<T, bool>> ParseFilter<T>(string input, IQueryKitCo
return Expression.Lambda<Func<T, bool>>(expr, parameter);
}

// Each query name in front of a comparison operator or a comparison alias is replaced with its property path before the parse.
// A property that can not be filtered or sorted throws InvalidOperationException when the filter uses its query name.
private static string ReplaceQueryNamesWithPropertyPaths(string input, IQueryKitConfiguration? config)
{
if (config?.PropertyMappings == null)
{
return input;
}

var comparisonAliases = ComparisonOperator.GetAliasMatches(config).Select(x => x.Alias);
return config.PropertyMappings.ReplaceAliasesWithPropertyPaths(input, comparisonAliases);
}

private static Expression ReplaceDerivedProperties(Expression expr, IQueryKitConfiguration? config, ParameterExpression parameter)
{
if (config?.PropertyMappings == null)
Expand Down Expand Up @@ -187,13 +198,12 @@ private static Parser<IEnumerable<string>> PropertyListParser(Parser<string> pro

private static Parser<ComparisonOperator> ComparisonOperatorParser(IQueryKitConfiguration? config)
{
var operatorParser = CanonicalComparisonOperatorParser.Or(ComparisonOperatorAliasParser(config));
var operatorParser = ComparisonOperatorAliasParser(config).Or(CanonicalComparisonOperatorParser);
return Parse.Char(ComparisonOperator.AllPrefix).Optional().Select(opt => opt.IsDefined)
.Then(hasHash => operatorParser.Select(x => ComparisonOperator.GetByOperatorString(x.Operator, x.CaseInsensitive, hasHash)));
}

// The rewrite before the parse replaces each alias that stands between whitespace, like v1.14.2.
// The grammar reads an alias that the rewrite did not replace, for example `(Age)eq 3`.
// Aliases are matched in the grammar (not by rewriting the input) so text inside quoted values is never changed.
// Longer aliases are tried first so an alias that starts with another alias (e.g. `@@$$` and `@@$`) still matches.
private static Parser<(string Operator, bool CaseInsensitive)> ComparisonOperatorAliasParser(IQueryKitConfiguration? config)
{
Expand Down
12 changes: 9 additions & 3 deletions QueryKit/QueryKitPropertyMappings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -124,20 +124,26 @@ protected override Expression VisitParameter(ParameterExpression node)
}

public string ReplaceAliasesWithPropertyPaths(string input)
=> ReplaceAliasesWithPropertyPaths(input, Enumerable.Empty<string>());

// A comparison alias is an operator only as a whole word, so the filter text keeps its aliases and the parser reads them.
internal string ReplaceAliasesWithPropertyPaths(string input, IEnumerable<string> comparisonAliases)
{
var operators = ComparisonOperator.List.Select(x => x.Operator()).ToList();
var operatorPatterns = ComparisonOperator.List.Select(x => $@"\s*{Regex.Escape(x.Operator())}")
.Concat(comparisonAliases.Select(alias => $@"\s+{Regex.Escape(alias)}(?=\s|$)"))
.ToList();

foreach (QueryKitPropertyInfo queryKitPropertyInfo in _propertyMappings.Values)
{
var propertyPath = GetPropertyPathByQueryName(queryKitPropertyInfo.QueryName);
if (!string.IsNullOrEmpty(propertyPath))
{
foreach (var op in operators)
foreach (var operatorPattern in operatorPatterns)
{
// Use regular expression to isolate left side of the expression. Query names and
// operators are matched literally, so escape any regex metacharacters they contain
// (e.g. the `^` in `^^` would otherwise be read as a start-of-line anchor).
var regex = AliasRegexCache.Get($@"\b{Regex.Escape(queryKitPropertyInfo.QueryName!)}\b(?=\s*{Regex.Escape(op)})");
var regex = AliasRegexCache.Get($@"\b{Regex.Escape(queryKitPropertyInfo.QueryName!)}\b(?={operatorPattern})");

if (queryKitPropertyInfo is { CanSort: false, CanFilter: false} && regex.IsMatch(input))
{
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,8 @@ var filterExpression = FilterParser.ParseFilter<Recipe>(input, config);
> `Title @@$ "titilating"` ✅
> `Title@@$"titilating"` ❌

QueryKit reads a custom operator only where an operator can stand. Operator text inside a quoted value does not change. For example, `Title eq "salt and pepper"` compares `Title` with `salt and pepper`.

#### Allow Unknown Properties

By default, QueryKit will throw an error if it doesn't recognize a property name, If you want to loosen the reigns here a bit, you can set `AllowUnknownProperties` to `true` in your config. When active, unknown properties will be ignored in the expression resolution.
Expand Down
Loading