diff --git a/QueryKit.IntegrationTests/Tests/QueryNameOverUnknownTests.cs b/QueryKit.IntegrationTests/Tests/QueryNameOverUnknownTests.cs new file mode 100644 index 0000000..1464d23 --- /dev/null +++ b/QueryKit.IntegrationTests/Tests/QueryNameOverUnknownTests.cs @@ -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((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().WithMessage("*'is'*"); + people.Count.Should().Be(1); + people[0].Id.Should().Be(fakePersonOne.Id); + } +} diff --git a/QueryKit.UnitTests/QueryNameOverUnknownTests.cs b/QueryKit.UnitTests/QueryNameOverUnknownTests.cs new file mode 100644 index 0000000..17796a3 --- /dev/null +++ b/QueryKit.UnitTests/QueryNameOverUnknownTests.cs @@ -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(x => x.Age * 2).HasQueryName("double age"); + config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full-name"); + config.CustomOperation((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(input, Config); + + act.Should().ThrowExactly().WithMessage($"*'{unknownProperty}'*"); + } + + [Fact] + public void failure_before_the_query_name_throws_parsing_exception() + { + var act = () => FilterParser.ParseFilter("""Title == && is adult == true""", Config); + + act.Should().ThrowExactly(); + } + + [Fact] + public void failure_before_the_query_name_throws_its_own_format_exception() + { + var act = () => FilterParser.ParseFilter("""Age > "x" && is adult == true""", Config); + + act.Should().ThrowExactly(); + } + + [Fact] + public void failure_before_the_query_name_throws_its_own_unknown_property() + { + var act = () => FilterParser.ParseFilter("""Nope == "x" && is adult == true""", Config); + + act.Should().ThrowExactly().WithMessage("*'Nope'*"); + } + + [Fact] + public void filter_with_a_query_name_still_succeeds() + { + var filterExpression = FilterParser.ParseFilter("""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"))"""); + } +} diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 06b22bb..86606f9 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -32,11 +32,26 @@ public static Expression> ParseFilter(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(parameter, config).End().Parse(input); + try + { + expr = ExprParser(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(parameter, config).End().Parse(input); + } // When the parser removed every clause, no clause limits the result if (expr is RemovedClauseExpression) @@ -59,6 +74,8 @@ public static Expression> ParseFilter(string input, IQueryKitCo FilterValue.Parameterize = parameterizeBefore; _maxNestingDepth = maxNestingDepthBefore; _nestingDepth = nestingDepthBefore; + _queryNameOverUnknown = queryNameOverUnknownBefore; + _queryNameFallbackOff = queryNameFallbackOffBefore; } return Expression.Lambda>(expr, parameter); @@ -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. @@ -947,9 +969,12 @@ private static Parser 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(input, "query names are off", Array.Empty()) + : queryNameParser(input); if (queryName.WasSuccessful) { + _queryNameOverUnknown |= reference != null; return Result.Success(PropertyResolver.Resolve(entityType, queryName.Value, config), queryName.Remainder); }