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
47 changes: 47 additions & 0 deletions QueryKit.IntegrationTests/Tests/QueryNameOverUnknownTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
namespace QueryKit.IntegrationTests.Tests;

using Configuration;
using Exceptions;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using SharedTestingHelper.Fakes;
using WebApiTestProject.Entities;

// Like v1.14.2, a failed filter with a query name that has a space throws UnknownFilterPropertyException
// for the first word. The same query name in a filter that does not fail still filters the rows.
public class QueryNameOverUnknownTests : TestBase
{
[Fact]
public async Task failed_filter_with_a_spaced_query_name_throws_unknown_property()
{
// Arrange
var testingServiceScope = new TestingServiceScope();
var title = $"query name {Guid.NewGuid()}";
var fakePersonOne = new FakeTestingPersonBuilder()
.WithTitle(title)
.WithAge(30)
.Build();
var fakePersonTwo = new FakeTestingPersonBuilder()
.WithTitle(title)
.WithAge(10)
.Build();
await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo);

var config = new QueryKitConfiguration(config =>
{
config.CustomOperation<TestingPerson>((x, op, value) => x.Age > 17).HasQueryName("is adult");
});

// Act
var failedFilter = () => testingServiceScope.DbContext().People
.ApplyQueryKitFilter("""is adult == true && Title ==""", config);
var appliedQueryable = testingServiceScope.DbContext().People
.ApplyQueryKitFilter($"""is adult == true && Title == "{title}" """, config);
var people = await appliedQueryable.ToListAsync();

// Assert
failedFilter.Should().ThrowExactly<UnknownFilterPropertyException>().WithMessage("*'is'*");
people.Count.Should().Be(1);
people[0].Id.Should().Be(fakePersonOne.Id);
}
}
68 changes: 68 additions & 0 deletions QueryKit.UnitTests/QueryNameOverUnknownTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
namespace QueryKit.UnitTests;

using Configuration;
using Exceptions;
using FluentAssertions;
using WebApiTestProject.Entities;

// v1.14.2 read only the first word of a query name with a space or a hyphen, and threw
// UnknownFilterPropertyException for that word. A filter with such a query name that fails
// still throws the v1.14.2 exception. A filter that v1.14.2 rejected can now succeed.
public class QueryNameOverUnknownTests
{
private static readonly QueryKitConfiguration Config = new(config =>
{
config.DerivedProperty<TestingPerson>(x => x.Age * 2).HasQueryName("double age");

Check warning on line 15 in QueryKit.UnitTests/QueryNameOverUnknownTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.

Check warning on line 15 in QueryKit.UnitTests/QueryNameOverUnknownTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.

Check warning on line 15 in QueryKit.UnitTests/QueryNameOverUnknownTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.

Check warning on line 15 in QueryKit.UnitTests/QueryNameOverUnknownTests.cs

View workflow job for this annotation

GitHub Actions / test

Possible null reference return.
config.DerivedProperty<TestingPerson>(x => x.FirstName + " " + x.LastName).HasQueryName("full-name");
config.CustomOperation<TestingPerson>((x, op, value) => x.Age > 17).HasQueryName("is adult");
});

[Theory]
[InlineData("double age > 6", "double")]
[InlineData("DOUBLE AGE > 6", "DOUBLE")]
[InlineData("""full-name == "Ann Lee" && Title ==""", "full")]
[InlineData("is adult", "is")]
[InlineData("is adult ==", "is")]
[InlineData("""is adult == true && Title ==""", "is")]
[InlineData("""is adult == true && Nope == "x" """, "is")]
[InlineData("""is adult == true && Age > "x" """, "is")]
[InlineData("""((is adult == true)) || (Title == "a" && is adult ==)""", "is")]
public void failed_filter_with_a_query_name_throws_unknown_property_for_the_first_word(string input, string unknownProperty)
{
var act = () => FilterParser.ParseFilter<TestingPerson>(input, Config);

act.Should().ThrowExactly<UnknownFilterPropertyException>().WithMessage($"*'{unknownProperty}'*");
}

[Fact]
public void failure_before_the_query_name_throws_parsing_exception()
{
var act = () => FilterParser.ParseFilter<TestingPerson>("""Title == && is adult == true""", Config);

act.Should().ThrowExactly<ParsingException>();
}

[Fact]
public void failure_before_the_query_name_throws_its_own_format_exception()
{
var act = () => FilterParser.ParseFilter<TestingPerson>("""Age > "x" && is adult == true""", Config);

act.Should().ThrowExactly<FormatException>();
}

[Fact]
public void failure_before_the_query_name_throws_its_own_unknown_property()
{
var act = () => FilterParser.ParseFilter<TestingPerson>("""Nope == "x" && is adult == true""", Config);

act.Should().ThrowExactly<UnknownFilterPropertyException>().WithMessage("*'Nope'*");
}

[Fact]
public void filter_with_a_query_name_still_succeeds()
{
var filterExpression = FilterParser.ParseFilter<TestingPerson>("""is adult == true && Title == "x" """, Config);

filterExpression.ToDisplayString().Should().Be("""x => (Invoke((entity, op, value) => (Convert(entity, TestingPerson).Age > Convert(17, Nullable`1)), Convert(x, Object), ==, True) AndAlso (x.Title == "x"))""");
}
}
29 changes: 27 additions & 2 deletions QueryKit/FilterParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,26 @@ public static Expression<Func<T, bool>> ParseFilter<T>(string input, IQueryKitCo
FilterValue.Parameterize = config is IQueryKitFilterBehavior { ParameterizeFilterValues: true };
var maxNestingDepthBefore = _maxNestingDepth;
var nestingDepthBefore = _nestingDepth;
var queryNameOverUnknownBefore = _queryNameOverUnknown;
var queryNameFallbackOffBefore = _queryNameFallbackOff;
_maxNestingDepth = (config as IQueryKitParseLimits)?.MaxNestingDepth ?? QueryKitSettings.DefaultMaxNestingDepth;
_nestingDepth = 0;
_queryNameOverUnknown = false;
_queryNameFallbackOff = false;
try
{
expr = ExprParser<T>(parameter, config).End().Parse(input);
try
{
expr = ExprParser<T>(parameter, config).End().Parse(input);
}
catch (Exception) when (_queryNameOverUnknown)
{
// v1.14.2 threw UnknownFilterPropertyException where a query name now reads an unknown identifier.
// A filter that fails now also failed in v1.14.2, so parse it again without the query names to get the v1.14.2 exception.
_queryNameFallbackOff = true;
_nestingDepth = 0;
expr = ExprParser<T>(parameter, config).End().Parse(input);
}

// When the parser removed every clause, no clause limits the result
if (expr is RemovedClauseExpression)
Expand All @@ -59,6 +74,8 @@ public static Expression<Func<T, bool>> ParseFilter<T>(string input, IQueryKitCo
FilterValue.Parameterize = parameterizeBefore;
_maxNestingDepth = maxNestingDepthBefore;
_nestingDepth = nestingDepthBefore;
_queryNameOverUnknown = queryNameOverUnknownBefore;
_queryNameFallbackOff = queryNameFallbackOffBefore;
}

return Expression.Lambda<Func<T, bool>>(expr, parameter);
Expand Down Expand Up @@ -90,6 +107,11 @@ private static void EnsureWithinInputLength(string input, IQueryKitConfiguration
[ThreadStatic] private static int _maxNestingDepth;
[ThreadStatic] private static int _nestingDepth;

// Set when a derived property or custom operation query name reads text where v1.14.2 read an unknown identifier.
// When the fallback is off, the parser throws for the unknown identifier like v1.14.2.
[ThreadStatic] private static bool _queryNameOverUnknown;
[ThreadStatic] private static bool _queryNameFallbackOff;

// Parses '(' inner ')' and counts the group against MaxNestingDepth. The grammar does the count,
// so a '(' or ')' inside a quoted value cannot change it. The parser recurses once for each group,
// so the limit also limits the depth of the call stack.
Expand Down Expand Up @@ -947,9 +969,12 @@ private static Parser<PropertyReference> CreateLeftExprParser(Type entityType, I
}

// 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 = queryNameParser(input);
var queryName = _queryNameFallbackOff && reference != null
? Result.Failure<string>(input, "query names are off", Array.Empty<string>())
: queryNameParser(input);
if (queryName.WasSuccessful)
{
_queryNameOverUnknown |= reference != null;
return Result.Success(PropertyResolver.Resolve(entityType, queryName.Value, config), queryName.Remainder);
}

Expand Down
Loading