From e20f86073cfee4479d315edf6fe61d508e1df5f1 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:05:23 +0300 Subject: [PATCH 1/4] test(integration): give authors unique names in alias filter tests can_filter_on_projections_nested and can_filter_on_child_entity_with_config filter on a random one-word author name. The tests share one database, so another test can insert an author with the same word. Then the filter returns two recipes and the test fails. Use a GUID as the name, the same as can_filter_on_projections_nested_complex. --- QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs index 99b26c6..2f8d380 100644 --- a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs +++ b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs @@ -1391,7 +1391,9 @@ public async Task can_filter_on_projections_nested() { // Arrange var testingServiceScope = new TestingServiceScope(); - var fakeAuthorOne = new FakeAuthorBuilder().Build(); + var fakeAuthorOne = new FakeAuthorBuilder() + .WithName(Guid.NewGuid().ToString()) + .Build(); var fakeRecipeOne = new FakeRecipeBuilder().Build(); fakeRecipeOne.SetAuthor(fakeAuthorOne); @@ -1472,7 +1474,9 @@ public async Task can_filter_on_child_entity_with_config() { // Arrange var testingServiceScope = new TestingServiceScope(); - var fakeAuthorOne = new FakeAuthorBuilder().Build(); + var fakeAuthorOne = new FakeAuthorBuilder() + .WithName(Guid.NewGuid().ToString()) + .Build(); var fakeRecipeOne = new FakeRecipeBuilder().Build(); fakeRecipeOne.SetAuthor(fakeAuthorOne); From efb46586ff7f270443c59dc1c53208ec1413a379 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:05:43 +0300 Subject: [PATCH 2/4] perf(filter): send filter values to ef core as parameters Each filter value was an Expression.Constant, so EF Core wrote it into the SQL as a literal. Each new value made a new SQL text. EF Core compiled a new query, and Postgres made a new plan, for each value. Put each value in a FilterValue holder and read its field. EF Core treats this read as a captured variable and sends the value as a parameter. The in-lists become one array parameter (= ANY (@Value) on Npgsql). Arithmetic literals, dates, enums, bools, and guids are parameters too. Null stays a constant, so the SQL keeps IS NULL. Nullable enum values are now one value of the nullable type. The expression prints January, not new Nullable`1(January). The unit tests print holder reads inline with ToDisplayString(). --- .../Tests/FilterParameterTests.cs | 62 ++++++ .../CustomFilterPropertyTests.cs | 38 ++-- QueryKit.UnitTests/ExpressionDisplay.cs | 51 +++++ QueryKit.UnitTests/FilterParserTests.cs | 192 +++++++++--------- .../FilterValueParameterTests.cs | 73 +++++++ QueryKit.UnitTests/HasConversionTests.cs | 4 +- QueryKit.UnitTests/OperatorAliasTests.cs | 30 +-- QueryKit/Expressions/ArithmeticExpression.cs | 2 +- QueryKit/FilterParser.cs | 63 ++---- QueryKit/FilterValue.cs | 26 +++ QueryKit/Operators/ComparisonOperator.cs | 16 +- 11 files changed, 369 insertions(+), 188 deletions(-) create mode 100644 QueryKit.IntegrationTests/Tests/FilterParameterTests.cs create mode 100644 QueryKit.UnitTests/ExpressionDisplay.cs create mode 100644 QueryKit.UnitTests/FilterValueParameterTests.cs create mode 100644 QueryKit/FilterValue.cs diff --git a/QueryKit.IntegrationTests/Tests/FilterParameterTests.cs b/QueryKit.IntegrationTests/Tests/FilterParameterTests.cs new file mode 100644 index 0000000..0b2689e --- /dev/null +++ b/QueryKit.IntegrationTests/Tests/FilterParameterTests.cs @@ -0,0 +1,62 @@ +namespace QueryKit.IntegrationTests.Tests; + +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SharedTestingHelper.Fakes; +using WebApiTestProject.Entities; + +public class FilterParameterTests() : TestBase +{ + [Theory] + [InlineData("""Title == "lamb" """, """Title == "chicken" """)] + [InlineData("""Title @=* "lamb" """, """Title @=* "chicken" """)] + [InlineData("Age > 30", "Age > 18")] + [InlineData("Rating > 3.5", "Rating > 4.5")] + [InlineData("BirthMonth == 1", "BirthMonth == 2")] + [InlineData("Favorite == true", "Favorite == false")] + [InlineData("SpecificDate > 2022-07-01T00:00:03Z", "SpecificDate > 2023-01-01T00:00:00Z")] + [InlineData("SpecificDateTime > 2022-07-01T00:00:03Z", "SpecificDateTime > 2023-01-01T00:00:00Z")] + [InlineData("Date == 2022-07-01", "Date == 2023-01-01")] + [InlineData("Time == 00:00:03", "Time == 12:30:00")] + [InlineData("""Id == "aa648248-cb69-4217-ac95-d7484795afb2" """, """Id == "bb648248-cb69-4217-ac95-d7484795afb2" """)] + [InlineData("""Title ^^ ["lamb", "chicken"]""", """Title ^^ ["beef", "pork", "tofu"]""")] + [InlineData("""Title ^^* ["lamb", "chicken"]""", """Title ^^* ["beef", "pork", "tofu"]""")] + [InlineData("""Title !^^ ["lamb", "chicken"]""", """Title !^^ ["beef", "pork", "tofu"]""")] + [InlineData("(Age + 5) > 30", "(Age + 7) > 18")] + public void filters_that_differ_only_in_values_share_one_parameterized_query(string first, string second) + { + var testingServiceScope = new TestingServiceScope(); + + var firstSql = SqlWithoutParameterValues(testingServiceScope, first); + var secondSql = SqlWithoutParameterValues(testingServiceScope, second); + + firstSql.Should().Contain("@"); + firstSql.Should().Be(secondSql); + } + + [Fact] + public async Task in_list_is_one_array_parameter_and_still_filters() + { + var testingServiceScope = new TestingServiceScope(); + var lamb = new FakeTestingPersonBuilder().WithTitle($"lamb {Guid.NewGuid()}").Build(); + var chicken = new FakeTestingPersonBuilder().WithTitle($"chicken {Guid.NewGuid()}").Build(); + var beef = new FakeTestingPersonBuilder().WithTitle($"beef {Guid.NewGuid()}").Build(); + await testingServiceScope.InsertAsync(lamb, chicken, beef); + + var input = $"""Title ^^* ["{lamb.Title!.ToUpper()}", "{chicken.Title}"]"""; + var query = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input); + var people = await query.ToListAsync(); + + SqlWithoutParameterValues(query).Should().Contain("= ANY (@"); + people.Select(x => x.Id).Should().BeEquivalentTo(new[] { lamb.Id, chicken.Id }); + } + + private static string SqlWithoutParameterValues(TestingServiceScope testingServiceScope, string input) + => SqlWithoutParameterValues(testingServiceScope.DbContext().People.ApplyQueryKitFilter(input)); + + // ToQueryString() writes each parameter value in a "-- @p='...'" comment line before the SQL. + private static string SqlWithoutParameterValues(IQueryable query) + => string.Join('\n', query.ToQueryString() + .Split('\n') + .Where(line => !line.StartsWith("--"))); +} diff --git a/QueryKit.UnitTests/CustomFilterPropertyTests.cs b/QueryKit.UnitTests/CustomFilterPropertyTests.cs index 8b627a2..d5f472e 100644 --- a/QueryKit.UnitTests/CustomFilterPropertyTests.cs +++ b/QueryKit.UnitTests/CustomFilterPropertyTests.cs @@ -17,7 +17,7 @@ public void can_have_child_prop_name_ownsone() var value = faker.Lorem.Word(); var input = $"""PhysicalAddress.State == "{value}" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be($"""x => (x.PhysicalAddress.State == "{value}")"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.PhysicalAddress.State == "{value}")"""); } [Fact] @@ -32,7 +32,7 @@ public void can_have_custom_child_prop_name_ownsone() config.Property(x => x.PhysicalAddress.State).HasQueryName("state"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => (x.PhysicalAddress.State == "{value}")"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.PhysicalAddress.State == "{value}")"""); } [Fact(Skip = "Will need something like this if i want to support HasConversion in efcore.")] @@ -42,7 +42,7 @@ public void can_have_child_prop_name_for_efcore_HasConversion() var value = faker.Lorem.Word(); var input = $"""Email.Value == "{value}" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be($"""x => (x.Email == "{value}")"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Email == "{value}")"""); } [Fact(Skip = "Will need something like this if i want to support HasConversion in efcore.")] @@ -57,7 +57,7 @@ public void can_have_custom_child_prop_name_for_efcore_HasConversion() config.Property(x => x.Email).HasQueryName("email"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => (x.Email == "{value}")"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Email == "{value}")"""); } [Fact] @@ -72,7 +72,7 @@ public void can_have_custom_prop_name_for_string() config.Property(x => x.Title!).HasQueryName("special_title"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => (x.Title == "{value}")"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "{value}")"""); } [Fact] @@ -87,7 +87,7 @@ public void can_handle_alias_in_value() config.Property(x => x.Title!).HasQueryName("special_title"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => (x.Title == "{value} with special_value")"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "{value} with special_value")"""); } [Fact] @@ -102,7 +102,7 @@ public void can_handle_alias_in_value_with_operator_after_it() config.Property(x => x.Title!).HasQueryName("special_title"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => (x.Title == "{value} with special_value @=* a thing")"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "{value} with special_value @=* a thing")"""); } [Fact] @@ -119,7 +119,7 @@ public void can_have_custom_prop_name_for_multiple_props() config.Property(x => x.Id).HasQueryName("identifier"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (x.Id == {guidValue}))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (x.Id == {guidValue}))"""); } [Fact] @@ -135,7 +135,7 @@ public void can_have_custom_prop_name_for_some_props() config.Property(x => x.Title!).HasQueryName("special_title"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (x.Id == {guidValue}))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (x.Id == {guidValue}))"""); } [Fact] @@ -150,7 +150,7 @@ public void can_handle_case_insensitive_custom_props() config.Property(x => x.Title!).HasQueryName("specialtitle"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => (x.Title == "{value}")"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "{value}")"""); } [Fact] @@ -167,7 +167,7 @@ public void can_have_custom_prop_excluded_from_filter() config.Property(x => x.Id).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (True == True))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (True == True))"""); } [Fact] @@ -184,7 +184,7 @@ public void can_have_custom_prop_excluded_from_filter_with_custom_propname() config.Property(x => x.Id).HasQueryName("identifier").PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (True == True))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (True == True))"""); } [Fact] @@ -197,7 +197,7 @@ public void can_have_custom_prop_name_with_in_operator() config.Property(x => x.Title!).HasQueryName("special_title"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be("""x => value(System.Collections.Generic.List`1[System.String]).Contains(x.Title)"""); } @@ -215,7 +215,7 @@ public void can_have_custom_prop_name_with_regex_special_character_operators(str config.Property(x => x.Title!).HasQueryName("special_title"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Contain("x.Title"); + filterExpression.ToDisplayString().Should().Contain("x.Title"); } [Theory] @@ -232,7 +232,7 @@ public void can_have_custom_prop_name_with_has_operators(string comparisonOperat config.Property(x => x.Tags).HasQueryName("special_tags"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Contain("x.Tags"); + filterExpression.ToDisplayString().Should().Contain("x.Tags"); } [Fact] @@ -247,7 +247,7 @@ public void can_have_custom_prop_work_with_collection_filters() config.Property(x => x.Title).HasQueryName("special_title"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be( + filterExpression.ToDisplayString().Should().Be( $"""x => ((x.Title == "{stringValue}") AndAlso x.Ingredients.Select(y => y.Name).Any(z => (z == "flour")))"""); } @@ -263,7 +263,7 @@ public void can_have_derived_prop_work_with_collection_filters() config.DerivedProperty(x => x.Title + x.Directions).HasQueryName("special_title_directions"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be( + filterExpression.ToDisplayString().Should().Be( $"""x => (((x.Title + x.Directions) == "{stringValue}") AndAlso x.Ingredients.Select(y => y.Name).Any(z => (z == "flour")))"""); } @@ -280,7 +280,7 @@ public void filter_prevented_props_always_have_true_equals_true_regardless_of_co config.Property(x => x.Id).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => (True == True)"""); + filterExpression.ToDisplayString().Should().Be($"""x => (True == True)"""); } [Fact] @@ -311,6 +311,6 @@ public void can_handle_nonexistent_property() config.AllowUnknownProperties = true; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be("x => (True == True)"); + filterExpression.ToDisplayString().Should().Be("x => (True == True)"); } } \ No newline at end of file diff --git a/QueryKit.UnitTests/ExpressionDisplay.cs b/QueryKit.UnitTests/ExpressionDisplay.cs new file mode 100644 index 0000000..ee09c73 --- /dev/null +++ b/QueryKit.UnitTests/ExpressionDisplay.cs @@ -0,0 +1,51 @@ +namespace QueryKit.UnitTests; + +using System.Linq.Expressions; + +// QueryKit sends filter values as FilterValue field reads so that EF Core makes SQL parameters. +// ToString() prints these reads as value(QueryKit.FilterValue`1[...]).Value, so the tests put the +// values back inline. Date and time values print as constructor calls, the same as before. +public static class ExpressionDisplay +{ + public static string ToDisplayString(this Expression expression) + => new InlineFilterValues().Visit(expression)!.ToString(); + + private sealed class InlineFilterValues : ExpressionVisitor + { + protected override Expression VisitMember(MemberExpression node) + { + if (node.Expression is not ConstantExpression { Value: { } holder } + || !holder.GetType().IsGenericType + || holder.GetType().GetGenericTypeDefinition() != typeof(FilterValue<>)) + return base.VisitMember(node); + + var value = ((System.Reflection.FieldInfo)node.Member).GetValue(holder); + return Inline(value, node.Type); + } + + private static Expression Inline(object? value, Type type) + { + var underlying = Nullable.GetUnderlyingType(type); + var isDateOrTime = value is DateTime or DateTimeOffset or DateOnly or TimeOnly; + if (!isDateOrTime) + return Expression.Constant(value, type); + + Expression newExpr = value switch + { + DateTime dt => New(new[] { typeof(long), typeof(DateTimeKind) }, dt.Ticks, dt.Kind), + DateTimeOffset dto => New(new[] { typeof(long), typeof(TimeSpan) }, dto.Ticks, dto.Offset), + DateOnly date => New(new[] { typeof(int), typeof(int), typeof(int) }, date.Year, date.Month, date.Day), + TimeOnly time => New(new[] { typeof(int), typeof(int), typeof(int), typeof(int), typeof(int) }, + time.Hour, time.Minute, time.Second, time.Millisecond, time.Microsecond), + _ => throw new InvalidOperationException() + }; + + return underlying == null + ? newExpr + : Expression.New(type.GetConstructor(new[] { underlying })!, newExpr); + } + + private static NewExpression New(Type[] parameterTypes, params object[] args) + => Expression.New(typeof(T).GetConstructor(parameterTypes)!, args.Select(Expression.Constant)); + } +} diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index a4724fc..02921cd 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -18,7 +18,7 @@ public void escaped_double_quote_with_more_than_3_double_quotes() { var input = """""""""Title == """"lamb is great on a "gee-ro" not a "gy-ro" sandwich"""" """""""""; var filterExpression = FilterParser.ParseFilter(input); - var asString = filterExpression.ToString(); + var asString = filterExpression.ToDisplayString(); asString.Should() .Be(""""x => (x.Title == "lamb is great on a "gee-ro" not a "gy-ro" sandwich")""""); } @@ -29,7 +29,7 @@ public void escaped_double_quote() var input = """""Title == """lamb is great on a "gee-ro" not a "gy-ro" sandwich""" """""; var filterExpression = FilterParser.ParseFilter(input); - var asString = filterExpression.ToString(); + var asString = filterExpression.ToDisplayString(); asString.Should() .Be(""""x => (x.Title == "lamb is great on a "gee-ro" not a "gy-ro" sandwich")""""); } @@ -41,8 +41,8 @@ public void complex_with_lots_of_types() """""((Title @=* "waffle & chicken" && Age > 30) || Id == "aa648248-cb69-4217-ac95-d7484795afb2" || Title == "lamb" || Title == null) && (Age < 18 || (BirthMonth == 1 && Title _= "ally")) || Rating > 3.5 || SpecificDate == 2022-07-01T00:00:03Z && (Date == 2022-07-01 || Time == 00:00:03)"""""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() - .Be(""""x => (((((((((x.Title != null) AndAlso x.Title.ToLower().Contains("waffle & chicken".ToLower())) AndAlso (x.Age > 30)) OrElse (x.Id == aa648248-cb69-4217-ac95-d7484795afb2)) OrElse (x.Title == "lamb")) OrElse (x.Title == null)) AndAlso ((x.Age < 18) OrElse ((x.BirthMonth == new Nullable`1(January)) AndAlso x.Title.StartsWith("ally")))) OrElse (x.Rating > 3.5)) OrElse ((x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00))) AndAlso ((x.Date == new Nullable`1(new DateOnly(2022, 7, 1))) OrElse (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0))))))""""); + filterExpression.ToDisplayString().Should() + .Be(""""x => (((((((((x.Title != null) AndAlso x.Title.ToLower().Contains("waffle & chicken".ToLower())) AndAlso (x.Age > 30)) OrElse (x.Id == aa648248-cb69-4217-ac95-d7484795afb2)) OrElse (x.Title == "lamb")) OrElse (x.Title == null)) AndAlso ((x.Age < 18) OrElse ((x.BirthMonth == January) AndAlso x.Title.StartsWith("ally")))) OrElse (x.Rating > 3.5)) OrElse ((x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00))) AndAlso ((x.Date == new Nullable`1(new DateOnly(2022, 7, 1))) OrElse (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0))))))""""); } [Fact] @@ -50,7 +50,7 @@ public void order_of_ops_quote_on_string() { var input = """(Title @=* "waffle" || Age > 30) || Age < 18"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => ((((x.Title != null) AndAlso x.Title.ToLower().Contains("waffle".ToLower())) OrElse (x.Age > 30)) OrElse (x.Age < 18))""""); } @@ -59,7 +59,7 @@ public void simple_string() { var input = """"Title @=* "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be(""""x => ((x.Title != null) AndAlso x.Title.ToLower().Contains("waffle".ToLower()))""""); + filterExpression.ToDisplayString().Should().Be(""""x => ((x.Title != null) AndAlso x.Title.ToLower().Contains("waffle".ToLower()))""""); } [Fact] @@ -67,7 +67,7 @@ public void can_handle_null() { var input = "Title == null"; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Title == null)"); + filterExpression.ToDisplayString().Should().Be("x => (x.Title == null)"); } [Fact] @@ -76,7 +76,7 @@ public void can_handle_guid_with_double_quotes() var guid = Guid.NewGuid(); var input = $"""Id == "{guid}" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be($"x => (x.Id == {guid})"); + filterExpression.ToDisplayString().Should().Be($"x => (x.Id == {guid})"); } [Fact] @@ -85,7 +85,7 @@ public void can_handle_guid_without_double_quotes() var guid = Guid.NewGuid(); var input = $"""Id == {guid} """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be($"x => (x.Id == {guid})"); + filterExpression.ToDisplayString().Should().Be($"x => (x.Id == {guid})"); } [Fact] @@ -93,7 +93,7 @@ public void can_handle_guid_with_null() { var input = $"""SecondaryId == null """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be($"x => (x.SecondaryId == null)"); + filterExpression.ToDisplayString().Should().Be($"x => (x.SecondaryId == null)"); } [Fact] @@ -101,7 +101,7 @@ public void equality_operator() { var input = """Title == "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Title == \"lamb\")"); + filterExpression.ToDisplayString().Should().Be("x => (x.Title == \"lamb\")"); } [Fact] @@ -109,7 +109,7 @@ public void inequality_operator() { var input = """Title != "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Title != \"lamb\")"); + filterExpression.ToDisplayString().Should().Be("x => (x.Title != \"lamb\")"); } [Fact] @@ -117,7 +117,7 @@ public void greater_than_operator() { var input = """Age > 30"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Age > 30)"); + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 30)"); } [Fact] @@ -125,7 +125,7 @@ public void greater_than_or_equal_to_operator() { var input = """Age >= 30"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Age >= 30)"); + filterExpression.ToDisplayString().Should().Be("x => (x.Age >= 30)"); } [Fact] @@ -133,7 +133,7 @@ public void less_than_operator() { var input = """Age < 30"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Age < 30)"); + filterExpression.ToDisplayString().Should().Be("x => (x.Age < 30)"); } [Fact] @@ -141,7 +141,7 @@ public void less_than_or_equal_to_operator() { var input = """Age <= 30"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Age <= 30)"); + filterExpression.ToDisplayString().Should().Be("x => (x.Age <= 30)"); } [Fact] @@ -149,7 +149,7 @@ public void and_operator() { var input = """Title == "lamb" && Age > 30"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => ((x.Title == \"lamb\") AndAlso (x.Age > 30))"); + filterExpression.ToDisplayString().Should().Be("x => ((x.Title == \"lamb\") AndAlso (x.Age > 30))"); } [Fact] @@ -157,7 +157,7 @@ public void or_operator() { var input = """Title == "lamb" || Age > 30"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => ((x.Title == \"lamb\") OrElse (x.Age > 30))"); + filterExpression.ToDisplayString().Should().Be("x => ((x.Title == \"lamb\") OrElse (x.Age > 30))"); } [Fact] @@ -165,7 +165,7 @@ public void contains_operator() { var input = """Title @=* "waffle" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => ((x.Title != null) AndAlso x.Title.ToLower().Contains(\"waffle\".ToLower()))"); + filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso x.Title.ToLower().Contains(\"waffle\".ToLower()))"); } [Fact] @@ -173,7 +173,7 @@ public void starts_with_operator() { var input = """Title _= "lam" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => x.Title.StartsWith(\"lam\")"); + filterExpression.ToDisplayString().Should().Be("x => x.Title.StartsWith(\"lam\")"); } [Fact] @@ -181,7 +181,7 @@ public void ends_with_operator_case_insensitive() { var input = """Title _-=* "b" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => ((x.Title != null) AndAlso x.Title.ToLower().EndsWith(\"b\".ToLower()))"); + filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso x.Title.ToLower().EndsWith(\"b\".ToLower()))"); } [Fact] @@ -189,7 +189,7 @@ public void ends_with_operator() { var input = """Title _-= "b" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => x.Title.EndsWith(\"b\")"); + filterExpression.ToDisplayString().Should().Be("x => x.Title.EndsWith(\"b\")"); } [Fact] @@ -197,7 +197,7 @@ public void test_logical_operators() { var input = """(Age == 35) && (Favorite == true) || (Age < 18)"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => (((x.Age == 35) AndAlso (x.Favorite == True)) OrElse (x.Age < 18))""""); } @@ -206,7 +206,7 @@ public void can_handle_case_insensitive_props() { var input = """(age == 35) && (favorite == true) || (age < 18)"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => (((x.Age == 35) AndAlso (x.Favorite == True)) OrElse (x.Age < 18))""""); } @@ -215,7 +215,7 @@ public void simple_in_operator_for_nullable_int() { var input = """Age ^^ [20, 30, 40]"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => value(System.Collections.Generic.List`1[System.Nullable`1[System.Int32]]).Contains(x.Age)""""); } @@ -224,7 +224,7 @@ public void simple_in_operator_for_guid() { var input = """Id ^^ ["6d623e92-d2cf-4496-a2df-f49fa77328ee"]"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => value(System.Collections.Generic.List`1[System.Guid]).Contains(x.Id)""""); } @@ -233,7 +233,7 @@ public void can_handle_parentheses_and_logical_operators() { var input = """(Title == "lamb") && (Age > 30) || (Title == "chicken") && (Age < 18)"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (((x.Title == \"lamb\") AndAlso (x.Age > 30)) OrElse ((x.Title == \"chicken\") AndAlso (x.Age < 18)))"); + filterExpression.ToDisplayString().Should().Be("x => (((x.Title == \"lamb\") AndAlso (x.Age > 30)) OrElse ((x.Title == \"chicken\") AndAlso (x.Age < 18)))"); } [Fact] @@ -241,7 +241,7 @@ public void can_handle_decimal_comparison() { var input = """Rating >= 3.5"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Rating >= 3.5)"); + filterExpression.ToDisplayString().Should().Be("x => (x.Rating >= 3.5)"); } [Fact] @@ -249,7 +249,7 @@ public void can_handle_date_only() { var input = """Date == 2022-07-01"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be(""""x => (x.Date == new Nullable`1(new DateOnly(2022, 7, 1)))""""); + filterExpression.ToDisplayString().Should().Be(""""x => (x.Date == new Nullable`1(new DateOnly(2022, 7, 1)))""""); } [Fact] @@ -257,7 +257,7 @@ public void can_handle_date_time_offset() { var input = """SpecificDate == 2022-07-01T00:00:03Z"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be(""""x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00)))""""); + filterExpression.ToDisplayString().Should().Be(""""x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00)))""""); } [Fact] @@ -265,7 +265,7 @@ public void can_handle_datetime() { var input = """SpecificDateTime == 2022-07-01T00:00:03"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be(""""x => (x.SpecificDateTime == new DateTime(637922304030000000, Local))""""); + filterExpression.ToDisplayString().Should().Be(""""x => (x.SpecificDateTime == new DateTime(637922304030000000, Local))""""); } [Fact] @@ -273,7 +273,7 @@ public void can_handle_datetime_comparison_with_timezone() { var input = """SpecificDate == 2022-07-01T00:00:03+01:00"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 01:00:00)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 01:00:00)))"); } [Theory] @@ -284,7 +284,7 @@ public void can_handle_date_time_offset_another(string format) var dateTimeOffset = DateTimeOffset.Parse("2022-07-01T00:00:03Z").ToString(format); var input = $"""SpecificDate == "{dateTimeOffset}" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be(""""x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00)))""""); + filterExpression.ToDisplayString().Should().Be(""""x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00)))""""); } [Fact] @@ -292,7 +292,7 @@ public void can_handle_datetime_another() { var input = """SpecificDateTime == "2022-07-01T00:00:03" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be(""""x => (x.SpecificDateTime == new DateTime(637922304030000000, Local))""""); + filterExpression.ToDisplayString().Should().Be(""""x => (x.SpecificDateTime == new DateTime(637922304030000000, Local))""""); } [Fact] @@ -300,7 +300,7 @@ public void can_handle_datetime_utc() { var input = """SpecificDateTime == "2022-07-01T00:00:03Z" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be(""""x => (x.SpecificDateTime == new DateTime(637922304030000000, Utc))""""); + filterExpression.ToDisplayString().Should().Be(""""x => (x.SpecificDateTime == new DateTime(637922304030000000, Utc))""""); } [Fact] @@ -308,7 +308,7 @@ public void can_handle_datetime_comparison_with_timezone_another() { var input = """SpecificDate == "2022-07-01T00:00:03+01:00" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 01:00:00)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 01:00:00)))"); } [Fact] @@ -316,7 +316,7 @@ public void can_handle_time_only() { var input = "Time == 12:30:00"; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Time == new Nullable`1(new TimeOnly(12, 30, 0, 0, 0)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.Time == new Nullable`1(new TimeOnly(12, 30, 0, 0, 0)))"); } [Fact] @@ -324,7 +324,7 @@ public void can_handle_datetime_comparison_with_negative_timezone() { var input = """SpecificDate == 2022-07-01T00:00:03-02:00"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, -02:00:00)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, -02:00:00)))"); } [Fact] @@ -332,7 +332,7 @@ public void can_handle_datetime_comparison_with_timezone_no_minutes() { var input = """SpecificDate == 2022-07-01T00:00:03+02"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 02:00:00)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 02:00:00)))"); } [Fact] @@ -340,7 +340,7 @@ public void can_handle_datetime_with_milliseconds() { var input = """SpecificDateTime == 2022-07-01T00:00:03.123"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.SpecificDateTime == new DateTime(637922304031230000, Local))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDateTime == new DateTime(637922304031230000, Local))"); } [Fact] @@ -348,7 +348,7 @@ public void can_handle_childproperty() { var input = """Email.Value == "john@example.com" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Email.Value == \"john@example.com\")"); + filterExpression.ToDisplayString().Should().Be("x => (x.Email.Value == \"john@example.com\")"); } [Fact] @@ -356,7 +356,7 @@ public void can_handle_childproperty_contains() { var input = """Email.Value @=* "example" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => ((x.Email.Value != null) AndAlso x.Email.Value.ToLower().Contains(\"example\".ToLower()))"); + filterExpression.ToDisplayString().Should().Be("x => ((x.Email.Value != null) AndAlso x.Email.Value.ToLower().Contains(\"example\".ToLower()))"); } [Fact] @@ -364,7 +364,7 @@ public void can_handle_time_comparison() { var input = """Time == 00:00:03"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0)))"); } [Fact] @@ -372,7 +372,7 @@ public void multiple_properties_and_operators() { var input = """Title _= "lamb" && Age >= 25 && Rating < 4.5 && SpecificDate <= 2022-07-01T00:00:03Z && Time == 00:00:03"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => ((((x.Title.StartsWith(\"lamb\") AndAlso (x.Age >= 25)) AndAlso (x.Rating < 4.5)) AndAlso (x.SpecificDate <= new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00)))) AndAlso (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0))))"); + filterExpression.ToDisplayString().Should().Be("x => ((((x.Title.StartsWith(\"lamb\") AndAlso (x.Age >= 25)) AndAlso (x.Rating < 4.5)) AndAlso (x.SpecificDate <= new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00)))) AndAlso (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0))))"); } [Fact] @@ -380,7 +380,7 @@ public void complex_filter_with_nested_parentheses() { var input = """(Title == "lamb" && ((Age >= 25 && Rating < 4.5) || (SpecificDate <= 2022-07-01T00:00:03Z && Time == 00:00:03)) && (Favorite == true || Email.Value _= "example"))"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("""x => (((x.Title == "lamb") AndAlso (((x.Age >= 25) AndAlso (x.Rating < 4.5)) OrElse ((x.SpecificDate <= new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00))) AndAlso (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0)))))) AndAlso ((x.Favorite == True) OrElse x.Email.Value.StartsWith("example")))"""); + filterExpression.ToDisplayString().Should().Be("""x => (((x.Title == "lamb") AndAlso (((x.Age >= 25) AndAlso (x.Rating < 4.5)) OrElse ((x.SpecificDate <= new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00))) AndAlso (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 0, 0)))))) AndAlso ((x.Favorite == True) OrElse x.Email.Value.StartsWith("example")))"""); } [Fact] @@ -388,7 +388,7 @@ public void can_handle_null_childproperty() { var input = """Email.Value == null"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Email.Value == null)"); + filterExpression.ToDisplayString().Should().Be("x => (x.Email.Value == null)"); } [Fact] @@ -396,7 +396,7 @@ public void contains_can_be_case_insensitive() { var input = """Title @=* "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => ((x.Title != null) AndAlso x.Title.ToLower().Contains(\"lamb\".ToLower()))"); + filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso x.Title.ToLower().Contains(\"lamb\".ToLower()))"); } [Fact] @@ -404,7 +404,7 @@ public void not_equals_can_be_case_insensitive() { var input = """Title !=* "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => ((x.Title == null) OrElse (x.Title.ToLower() != \"lamb\".ToLower()))"); + filterExpression.ToDisplayString().Should().Be("x => ((x.Title == null) OrElse (x.Title.ToLower() != \"lamb\".ToLower()))"); } [Fact] @@ -412,7 +412,7 @@ public void ends_with_works() { var input = """Title _-= "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => x.Title.EndsWith(\"lamb\")"); + filterExpression.ToDisplayString().Should().Be("x => x.Title.EndsWith(\"lamb\")"); } [Fact] @@ -420,7 +420,7 @@ public void ends_with_can_be_case_insensitive() { var input = """Title _-=* "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => ((x.Title != null) AndAlso x.Title.ToLower().EndsWith(\"lamb\".ToLower()))"); + filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso x.Title.ToLower().EndsWith(\"lamb\".ToLower()))"); } [Fact] @@ -428,7 +428,7 @@ public void contains_is_case_sensitive() { var input = """Title @= "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => x.Title.Contains(\"lamb\")"); + filterExpression.ToDisplayString().Should().Be("x => x.Title.Contains(\"lamb\")"); } [Fact] @@ -436,7 +436,7 @@ public void not_contains_works() { var input = """Title !@= "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => Not(x.Title.Contains(\"lamb\"))"); + filterExpression.ToDisplayString().Should().Be("x => Not(x.Title.Contains(\"lamb\"))"); } [Fact] @@ -444,7 +444,7 @@ public void can_filter_bools() { var input = """Favorite == true"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Favorite == True)"); + filterExpression.ToDisplayString().Should().Be("x => (x.Favorite == True)"); } [Fact] @@ -452,7 +452,7 @@ public void can_filter_nullable_ints() { var input = """Age >= 25"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Age >= 25)"); + filterExpression.ToDisplayString().Should().Be("x => (x.Age >= 25)"); } [Fact] @@ -460,7 +460,7 @@ public void can_filter_nullable_ints_with_not_equal() { var input = """Age != 25"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Age != 25)"); + filterExpression.ToDisplayString().Should().Be("x => (x.Age != 25)"); } [Fact] @@ -468,7 +468,7 @@ public void can_filter_with_comma() { var input = """Title == "lamb, lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Title == \"lamb, lamb\")"); + filterExpression.ToDisplayString().Should().Be("x => (x.Title == \"lamb, lamb\")"); } [Fact] @@ -476,7 +476,7 @@ public void equals_doesnt_fail_with_non_string_types() { var input = """Age == 25"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should().Be("x => (x.Age == 25)"); + filterExpression.ToDisplayString().Should().Be("x => (x.Age == 25)"); } [Fact] @@ -534,7 +534,7 @@ public void simple_child_collection_for_string_equal() { var input = """Ingredients.Name == "flour" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => (z == "flour"))""""); } @@ -543,7 +543,7 @@ public void simple_child_collection_for_string_case_insensitive_equal() { var input = """Ingredients.Name ==* "flour" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => (z.ToLower() == "flour".ToLower()))""""); } @@ -552,7 +552,7 @@ public void simple_child_collection_for_string_not_equal() { var input = """Ingredients.Name != "flour" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => (z != "flour"))""""); } @@ -561,7 +561,7 @@ public void simple_child_collection_for_string_case_insensitive_not_equal() { var input = """Ingredients.Name !=* "flour" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => (z.ToLower() != "flour".ToLower()))""""); } @@ -570,7 +570,7 @@ public void simple_child_collection_for_int_equals() { var input = """Ingredients.MinimumQuality == 5"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(z => (z == 5))""""); } @@ -579,7 +579,7 @@ public void simple_child_collection_for_int_greater_than() { var input = """Ingredients.MinimumQuality > 5"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(z => (z > 5))""""); } @@ -588,7 +588,7 @@ public void simple_child_collection_for_int_less_than() { var input = """Ingredients.MinimumQuality < 5"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(z => (z < 5))""""); } @@ -597,7 +597,7 @@ public void simple_child_collection_for_int_greater_than_or_equal() { var input = """Ingredients.MinimumQuality >= 5"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(z => (z >= 5))""""); } @@ -606,7 +606,7 @@ public void simple_child_collection_for_int_less_than_or_equal() { var input = """Ingredients.MinimumQuality <= 5"""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(z => (z <= 5))""""); } @@ -615,7 +615,7 @@ public void collection_contains_case_insensitive() { var input = """"Ingredients.Name @=* "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => z.ToLower().Contains("waffle".ToLower()))""""); } @@ -624,7 +624,7 @@ public void collection_contains() { var input = """"Ingredients.Name @= "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => z.Contains("waffle"))""""); } @@ -633,7 +633,7 @@ public void collection_starts_with() { var input = """"Ingredients.Name _= "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => z.StartsWith("waffle"))""""); } @@ -642,7 +642,7 @@ public void collection_ends_with() { var input = """"Ingredients.Name _-= "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => z.EndsWith("waffle"))""""); } @@ -651,7 +651,7 @@ public void collection_does_not_contains() { var input = """"Ingredients.Name !@= "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => Not(x.Ingredients.Select(y => y.Name).Any(z => z.Contains("waffle")))""""); } @@ -660,7 +660,7 @@ public void collection_does_not_starts_with() { var input = """"Ingredients.Name !_= "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => Not(x.Ingredients.Select(y => y.Name).Any(z => z.StartsWith("waffle")))""""); } @@ -669,7 +669,7 @@ public void collection_does_not_ends_with() { var input = """"Ingredients.Name !_-= "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => Not(x.Ingredients.Select(y => y.Name).Any(z => z.EndsWith("waffle")))""""); } @@ -678,7 +678,7 @@ public void collection_equals_with_all() { var input = """"Ingredients.Name %== "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).All(z => (z == "waffle"))""""); } @@ -687,7 +687,7 @@ public void collection_has_operator_greater_than() { var input = """"Ingredients #> 0""""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => (x.Ingredients.Count() > 0)""""); } @@ -696,7 +696,7 @@ public void collection_has_operator_equal() { var input = """"Ingredients #== 0""""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => (x.Ingredients.Count() == 0)""""); } @@ -705,7 +705,7 @@ public void collection_has_operator_not_equal() { var input = """"Ingredients #!= 3""""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => (x.Ingredients.Count() != 3)""""); } @@ -714,7 +714,7 @@ public void collection_has_operator_greater_than_equal() { var input = """"Ingredients #>= 0""""; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => (x.Ingredients.Count() >= 0)""""); } @@ -723,7 +723,7 @@ public void primitive_collection_has() { var input = """Tags ^$ "winner" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Tags.Any(z => (z == "winner"))""""); } @@ -732,7 +732,7 @@ public void primitive_collection_does_not_have() { var input = """Tags !^$ "winner" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Tags.Any(z => (z != "winner"))""""); } @@ -741,7 +741,7 @@ public void primitive_collection_has_case_insensitive() { var input = """Tags ^$* "winner" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Tags.Any(z => (z.ToLower() == "winner".ToLower()))""""); } @@ -750,7 +750,7 @@ public void primitive_collection_does_not_have_case_insensitive() { var input = """Tags !^$* "winner" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be(""""x => x.Tags.Any(z => (z.ToLower() != "winner".ToLower()))""""); } @@ -786,7 +786,7 @@ public void can_filter_with_has_conversion_configuration() filterExpression.Should().NotBeNull(); // Let's see what the actual expression looks like - var expressionString = filterExpression.ToString(); + var expressionString = filterExpression.ToDisplayString(); // Debug output - this should show us the actual expression Console.WriteLine($"Generated expression: {expressionString}"); @@ -807,7 +807,7 @@ public void case_insensitive_default_config_uses_to_lower() var filterExpression = FilterParser.ParseFilter(input); // Assert - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be("""x => ((x.Title != null) AndAlso x.Title.ToLower().Contains("waffle".ToLower()))"""); } @@ -825,7 +825,7 @@ public void case_insensitive_upper_mode_global_uses_to_upper() var filterExpression = FilterParser.ParseFilter(input, config); // Assert - should use ToUpper instead of ToLower - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be("""x => ((x.Title != null) AndAlso x.Title.ToUpper().Contains("waffle".ToUpper()))"""); } @@ -844,7 +844,7 @@ public void case_insensitive_per_property_upper_mode_overrides_global_lower() var filterExpression = FilterParser.ParseFilter(input, config); // Assert - Title should use ToUpper because of per-property override - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be("""x => ((x.Title != null) AndAlso x.Title.ToUpper().Contains("waffle".ToUpper()))"""); } @@ -862,9 +862,9 @@ public void case_insensitive_equals_upper_mode_uses_to_upper() var filterExpression = FilterParser.ParseFilter(input, config); // Assert - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Contain("ToUpper"); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .NotContain("ToLower"); } @@ -879,7 +879,7 @@ public void case_insensitive_not_equals_upper_mode_uses_to_upper() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be("x => ((x.Title == null) OrElse (x.Title.ToUpper() != \"lamb\".ToUpper()))"); } @@ -894,7 +894,7 @@ public void case_insensitive_starts_with_upper_mode_uses_to_upper() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be("x => ((x.Title != null) AndAlso x.Title.ToUpper().StartsWith(\"lamb\".ToUpper()))"); } @@ -909,7 +909,7 @@ public void case_insensitive_ends_with_upper_mode_uses_to_upper() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be("x => ((x.Title != null) AndAlso x.Title.ToUpper().EndsWith(\"lamb\".ToUpper()))"); } @@ -924,7 +924,7 @@ public void case_insensitive_not_contains_upper_mode_uses_to_upper() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be("x => ((x.Title == null) OrElse Not(x.Title.ToUpper().Contains(\"lamb\".ToUpper())))"); } @@ -939,7 +939,7 @@ public void case_insensitive_not_starts_with_upper_mode_uses_to_upper() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be("x => ((x.Title == null) OrElse Not(x.Title.ToUpper().StartsWith(\"lamb\".ToUpper())))"); } @@ -954,7 +954,7 @@ public void case_insensitive_not_ends_with_upper_mode_uses_to_upper() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be("x => ((x.Title == null) OrElse Not(x.Title.ToUpper().EndsWith(\"lamb\".ToUpper())))"); } @@ -968,7 +968,7 @@ public void case_insensitive_in_operator_upper_mode_uses_to_upper() }); var filterExpression = FilterParser.ParseFilter(input, config); - var asString = filterExpression.ToString(); + var asString = filterExpression.ToDisplayString(); asString.Should().Contain("ToUpper"); asString.Should().NotContain("ToLower"); @@ -988,7 +988,7 @@ public void case_insensitive_per_property_lower_overrides_global_upper() var filterExpression = FilterParser.ParseFilter(input, config); // Title should use ToLower because per-property overrides global Upper - filterExpression.ToString().Should() + filterExpression.ToDisplayString().Should() .Be("""x => ((x.Title != null) AndAlso x.Title.ToLower().Contains("waffle".ToLower()))"""); } } diff --git a/QueryKit.UnitTests/FilterValueParameterTests.cs b/QueryKit.UnitTests/FilterValueParameterTests.cs new file mode 100644 index 0000000..8c48fc5 --- /dev/null +++ b/QueryKit.UnitTests/FilterValueParameterTests.cs @@ -0,0 +1,73 @@ +namespace QueryKit.UnitTests; + +using System.Linq.Expressions; +using FluentAssertions; +using WebApiTestProject.Entities; + +public class FilterValueParameterTests +{ + [Theory] + [InlineData("""Title == "lamb" """)] + [InlineData("""Title @=* "waffle" """)] + [InlineData("Age > 30")] + [InlineData("Rating > 3.5")] + [InlineData("BirthMonth == 1")] + [InlineData("BirthMonth == \"January\"")] + [InlineData("Favorite == true")] + [InlineData("SpecificDate == 2022-07-01T00:00:03Z")] + [InlineData("SpecificDateTime > 2022-07-01T00:00:03")] + [InlineData("Date == 2022-07-01")] + [InlineData("""Time == "00:00:03.123456" """)] + [InlineData("""Id == "aa648248-cb69-4217-ac95-d7484795afb2" """)] + [InlineData("""Title ^^ ["lamb", "chicken"]""")] + [InlineData("""Title ^^* ["lamb", "chicken"]""")] + [InlineData("""Title !^^ ["lamb", "chicken"]""")] + [InlineData("""Title !^^* ["lamb", "chicken"]""")] + [InlineData("Age ^^ [18, 30]")] + [InlineData("(Age + 5) > 30")] + public void filter_values_are_field_reads_so_ef_core_sends_them_as_parameters(string input) + { + var filterExpression = FilterParser.ParseFilter(input); + + var constants = new ConstantCollector(); + constants.Visit(filterExpression); + + constants.Values.Should().NotBeEmpty(); + constants.Values.Should().AllSatisfy(value => + value.GetType().GetGenericTypeDefinition().Should().Be(typeof(FilterValue<>))); + } + + [Fact] + public void filter_values_keep_their_value_and_type() + { + var filterExpression = FilterParser.ParseFilter("""Time == "00:00:03.123456" """); + + var comparison = (BinaryExpression)filterExpression.Body; + var read = (MemberExpression)comparison.Right; + read.Type.Should().Be(typeof(TimeOnly?)); + Expression.Lambda>(read).Compile()().Should() + .Be(new TimeOnly(0, 0, 3, 123, 456)); + } + + [Fact] + public void null_stays_a_constant_so_the_query_uses_is_null() + { + var filterExpression = FilterParser.ParseFilter("Title == null"); + + var comparison = (BinaryExpression)filterExpression.Body; + comparison.Right.Should().BeAssignableTo() + .Which.Value.Should().BeNull(); + } + + private sealed class ConstantCollector : ExpressionVisitor + { + public List Values { get; } = new(); + + protected override Expression VisitConstant(ConstantExpression node) + { + if (node.Value != null) + Values.Add(node.Value); + return node; + } + } +} diff --git a/QueryKit.UnitTests/HasConversionTests.cs b/QueryKit.UnitTests/HasConversionTests.cs index 2eab3c9..29a623b 100644 --- a/QueryKit.UnitTests/HasConversionTests.cs +++ b/QueryKit.UnitTests/HasConversionTests.cs @@ -185,8 +185,8 @@ public void child_property_of_converted_parent_with_query_name_compares_parent() var filterWithoutQueryName = FilterParser.ParseFilter(input, configWithoutQueryName); // Assert - filterWithQueryName.ToString().Should().Be("""x => (x.Email == new EmailAddress("a@x.com"))"""); - filterWithQueryName.ToString().Should().Be(filterWithoutQueryName.ToString()); + filterWithQueryName.ToDisplayString().Should().Be("""x => (x.Email == new EmailAddress("a@x.com"))"""); + filterWithQueryName.ToDisplayString().Should().Be(filterWithoutQueryName.ToDisplayString()); } [Fact] diff --git a/QueryKit.UnitTests/OperatorAliasTests.cs b/QueryKit.UnitTests/OperatorAliasTests.cs index da74ee1..681cef1 100644 --- a/QueryKit.UnitTests/OperatorAliasTests.cs +++ b/QueryKit.UnitTests/OperatorAliasTests.cs @@ -16,7 +16,7 @@ public void can_handle_alias_text_with_space() config.EqualsOperator = "ti"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => (x.Title == "titilating")"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "titilating")"""); } [Fact] @@ -29,7 +29,7 @@ public void can_handle_alias_text_with_casing_and_space() config.EqualsOperator = "ti"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => ((x.Title == "titilating ties a ti") OrElse (x.Title == "titilater"))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "titilating ties a ti") OrElse (x.Title == "titilater"))"""); } [Fact] @@ -42,7 +42,7 @@ public void can_do_symbol_alias() config.EqualsOperator = "@"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => (x.Title == "titilating")"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "titilating")"""); } [Fact] @@ -56,7 +56,7 @@ public void can_alias_logical_operator_or() config.OrOperator = "or"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => ((x.Title == "titilating") OrElse (x.Rating > 3))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "titilating") OrElse (x.Rating > 3))"""); } [Fact] @@ -70,7 +70,7 @@ public void can_alias_logical_operator_and() config.AndOperator = "and"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => ((x.Title == "titilating") AndAlso (x.Rating > 3))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "titilating") AndAlso (x.Rating > 3))"""); } [Fact] @@ -84,7 +84,7 @@ public void can_alias_logical_operator_with_conflict() config.OrOperator = "or"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => ((x.Title == "titilating or") OrElse (x.Rating > 3))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "titilating or") OrElse (x.Rating > 3))"""); } [Fact] @@ -97,7 +97,7 @@ public void can_do_symbols_alias() config.EqualsOperator = "@@$"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => (x.Title == "titilating")"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "titilating")"""); } [Fact] @@ -110,7 +110,7 @@ public void can_do_symbols_alias_with_default_case_insensitive() config.EqualsOperator = "@@$"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => ((x.Title != null) AndAlso (x.Title.ToLower() == "titilating".ToLower()))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title != null) AndAlso (x.Title.ToLower() == "titilating".ToLower()))"""); } [Fact] @@ -124,7 +124,7 @@ public void can_do_symbols_alias_case_insensitive() config.CaseInsensitiveAppendix = "~"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => ((x.Title != null) AndAlso (x.Title.ToLower() == "titilating".ToLower()))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title != null) AndAlso (x.Title.ToLower() == "titilating".ToLower()))"""); } [Fact] @@ -138,7 +138,7 @@ public void can_do_symbols_alias_with_case_insensitive_conflicting_chars() config.CaseInsensitiveAppendix = "$"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => (x.Title == "titilating")"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "titilating")"""); } [Fact] @@ -153,7 +153,7 @@ public void can_use_contains_not_case_sensitive() config.CaseInsensitiveAppendix = "$"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => x.Title.Contains("titilating")"""); + filterExpression.ToDisplayString().Should().Be($"""x => x.Title.Contains("titilating")"""); } [Fact] @@ -167,7 +167,7 @@ public void can_do_symbols_alias_case_insensitive_with_conflicting_chars() config.CaseInsensitiveAppendix = "$"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => ((x.Title != null) AndAlso (x.Title.ToLower() == "titilating".ToLower()))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title != null) AndAlso (x.Title.ToLower() == "titilating".ToLower()))"""); } [Fact] @@ -181,7 +181,7 @@ public void can_do_symbols_alias_case_insensitive_with_no_symbols() config.CaseInsensitiveAppendix = "t"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => ((x.Title != null) AndAlso (x.Title.ToLower() == "titilating".ToLower()))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title != null) AndAlso (x.Title.ToLower() == "titilating".ToLower()))"""); } [Fact] @@ -191,7 +191,7 @@ public void can_have_custom_config_object() var config = new CustomQueryKitConfiguration(); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => (x.Title == "titilating")"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "titilating")"""); } [Fact] @@ -201,7 +201,7 @@ public void can_use_ints() var config = new CustomQueryKitConfiguration(); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToString().Should().Be($"""x => (x.Rating > 10)"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Rating > 10)"""); } public class CustomQueryKitConfiguration : QueryKitConfiguration diff --git a/QueryKit/Expressions/ArithmeticExpression.cs b/QueryKit/Expressions/ArithmeticExpression.cs index 7cab662..c492dfd 100644 --- a/QueryKit/Expressions/ArithmeticExpression.cs +++ b/QueryKit/Expressions/ArithmeticExpression.cs @@ -159,7 +159,7 @@ public LiteralArithmeticExpression(object value, Type valueType) public override Expression ToLinqExpression(ParameterExpression parameter, Type entityType) { - return Expression.Constant(Value, ValueType); + return FilterValue.Parameter(Value, ValueType); } public override Type GetExpressionType(Type entityType) diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index b269640..739d39b 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -304,7 +304,7 @@ private static Expression CreateRightExpr(Expression leftExpr, string right, Com var stringCtor = (underlyingType ?? leftExpr.Type).GetConstructor(new[] { typeof(string) }); if (stringCtor != null) { - Expression constructed = Expression.New(stringCtor, Expression.Constant(right, typeof(string))); + Expression constructed = Expression.New(stringCtor, FilterValue.Parameter(right, typeof(string))); return underlyingType == null ? constructed : Expression.Convert(constructed, leftExpr.Type); } } @@ -325,7 +325,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ { if (op.IsCountOperator() && int.TryParse(right, out var intVal)) { - return Expression.Constant(intVal, typeof(int)); + return FilterValue.Parameter(intVal, typeof(int)); } targetType = targetType.GetGenericArguments()[0]; return CreateRightExprFromType(targetType, right, op); @@ -387,45 +387,20 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ dt = DateTime.SpecifyKind(dt, DateTimeKind.Utc); } - var dtCtor = typeof(DateTime).GetConstructor(new[] { typeof(long), typeof(DateTimeKind) })!; - var newExpr = Expression.New(dtCtor, Expression.Constant(dt.Ticks), Expression.Constant(dt.Kind)); - - var isNullable = rawType == typeof(DateTime?); - if (!isNullable) return newExpr; - - var nullableDtCtor = typeof(DateTime?).GetConstructor(new[] { typeof(DateTime) })!; - newExpr = Expression.New(nullableDtCtor, newExpr); - return newExpr; + return FilterValue.Parameter(dt, rawType); } if (targetType == typeof(DateTimeOffset)) { var dtStyle = right.EndsWith("Z") ? DateTimeStyles.AdjustToUniversal : DateTimeStyles.AssumeLocal; var dto = DateTimeOffset.Parse(right, CultureInfo.InvariantCulture, dtStyle); - - var dtoCtor = typeof(DateTimeOffset).GetConstructor(new[] { typeof(long), typeof(TimeSpan) })!; - var newExpr = Expression.New(dtoCtor, Expression.Constant(dto.Ticks), Expression.Constant(dto.Offset)); - - var isNullable = rawType == typeof(DateTimeOffset?); - if (!isNullable) return newExpr; - - var nullableDtoCtor = typeof(DateTimeOffset?).GetConstructor(new[] { typeof(DateTimeOffset) })!; - newExpr = Expression.New(nullableDtoCtor, newExpr); - return newExpr; + return FilterValue.Parameter(dto, rawType); } if (targetType == typeof(DateOnly)) { var date = DateOnly.Parse(right, CultureInfo.InvariantCulture); - var dateCtor = typeof(DateOnly).GetConstructor(new[] { typeof(int), typeof(int), typeof(int) })!; - var newExpr = Expression.New(dateCtor, Expression.Constant(date.Year), Expression.Constant(date.Month), Expression.Constant(date.Day)); - - var isNullable = rawType == typeof(DateOnly?); - if (!isNullable) return newExpr; - - var nullableDateCtor = typeof(DateOnly?).GetConstructor(new[] { typeof(DateOnly) })!; - newExpr = Expression.New(nullableDateCtor, newExpr); - return newExpr; + return FilterValue.Parameter(date, rawType); } if (targetType == typeof(TimeOnly)) @@ -446,15 +421,10 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ } } - var timeCtor = typeof(TimeOnly).GetConstructor(new[] { typeof(int), typeof(int), typeof(int), typeof(int), typeof(int) })!; - var newExpr = Expression.New(timeCtor, Expression.Constant(time.Hour), Expression.Constant(time.Minute), Expression.Constant(time.Second), Expression.Constant(millisecond), Expression.Constant(microsecond)); - - var isNullable = rawType == typeof(TimeOnly?); - if (!isNullable) return newExpr; - - var nullableTimeCtor = typeof(TimeOnly?).GetConstructor(new[] { typeof(TimeOnly) })!; - newExpr = Expression.New(nullableTimeCtor, newExpr); - return newExpr; + // One microsecond is 10 ticks. The TimeOnly constructor with microseconds needs .NET 7. + var value = new TimeOnly(time.Hour, time.Minute, time.Second, millisecond) + .Add(TimeSpan.FromTicks(microsecond * 10)); + return FilterValue.Parameter(value, rawType); } if (targetType == typeof(Guid)) @@ -463,16 +433,16 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ // For equality/comparison operators, we can compare GUIDs directly (more efficient and EF-friendly) if (op.IsStringComparisonOperator()) { - return Expression.Constant(right, typeof(string)); + return FilterValue.Parameter(right, typeof(string)); } // Parse the GUID for direct comparison var guidValue = Guid.Parse(right); - return Expression.Constant(guidValue, typeof(Guid)); + return FilterValue.Parameter(guidValue, typeof(Guid)); } var convertedValue = conversionFunction(right); - return Expression.Constant(convertedValue, leftExprType); + return FilterValue.Parameter(convertedValue, leftExprType); } if (rawType.IsEnum || (Nullable.GetUnderlyingType(rawType)?.IsEnum ?? false)) @@ -511,12 +481,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ { throw new InvalidOperationException($"Unsupported value '{right}' for type '{targetType.Name}'"); } - var constant = Expression.Constant(enumValue, enumType); - - if (rawType == enumType) return constant; - - var nullableCtor = rawType.GetConstructor(new[] {enumType})!; - return Expression.New(nullableCtor, constant); + return FilterValue.Parameter(enumValue, rawType); } // for some complex derived expressions @@ -529,7 +494,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ if (bool.TryParse(right, out var boolVal)) { - return Expression.Constant(boolVal, typeof(bool)); + return FilterValue.Parameter(boolVal, typeof(bool)); } } diff --git a/QueryKit/FilterValue.cs b/QueryKit/FilterValue.cs new file mode 100644 index 0000000..764e7de --- /dev/null +++ b/QueryKit/FilterValue.cs @@ -0,0 +1,26 @@ +namespace QueryKit; + +using System.Linq.Expressions; + +// Holds one filter value for an expression tree. EF Core sends a field read on a captured object +// to the database as a parameter (@p), the same as a C# closure variable. A constant goes into the +// SQL as a literal, so each new value would compile a new query and a new database plan. +internal sealed class FilterValue +{ + public readonly T Value; + + public FilterValue(T value) + { + Value = value; + } +} + +internal static class FilterValue +{ + public static Expression Parameter(object? value, Type type) + { + var holderType = typeof(FilterValue<>).MakeGenericType(type); + var holder = Activator.CreateInstance(holderType, value); + return Expression.Field(Expression.Constant(holder, holderType), nameof(FilterValue.Value)); + } +} diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index a5bb8f8..2c8dbb0 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -558,6 +558,7 @@ public InType(bool caseInsensitive = false, bool usesAll = false) : base("^^", 1 public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { var leftType = left.Type; + object? values = null; if (right is NewArrayExpression newArrayExpression) { @@ -569,7 +570,8 @@ public override Expression GetExpression(Expression left, Expression right, T listType.GetMethod("Add")!.Invoke(list, new[] { ((ConstantExpression)value).Value }); } - right = Expression.Constant(list, listType); + values = list; + right = FilterValue.Parameter(list, listType); } // Get the Contains method with the correct generic type @@ -586,12 +588,12 @@ public override Expression GetExpression(Expression left, Expression right, T var listType = typeof(List); var caseList = Activator.CreateInstance(listType); - var originalList = ((ConstantExpression)right).Value as IEnumerable; + var originalList = values as IEnumerable; foreach (var value in originalList!) { listType.GetMethod("Add")!.Invoke(caseList, new[] { caseMode == CaseInsensitiveMode.Upper ? value.ToUpper() : value.ToLower() }); } - right = Expression.Constant(caseList, listType); + right = FilterValue.Parameter(caseList, listType); var caseLeft = Expression.Call(left, typeof(string).GetMethod(caseMethodName, Type.EmptyTypes)!); var containsCall = Expression.Call(right, containsMethod, caseLeft); @@ -801,6 +803,7 @@ public NotInType(bool caseInsensitive = false, bool usesAll = false) : base("!^^ public override Expression GetExpression(Expression left, Expression right, Type? dbContextType, CaseInsensitiveMode caseMode = CaseInsensitiveMode.Lower) { var leftType = left.Type; + object? values = null; if (right is NewArrayExpression newArrayExpression) { @@ -812,7 +815,8 @@ public override Expression GetExpression(Expression left, Expression right, T listType.GetMethod("Add")!.Invoke(list, new[] { ((ConstantExpression)value).Value }); } - right = Expression.Constant(list, listType); + values = list; + right = FilterValue.Parameter(list, listType); } // Get the Contains method with the correct generic type @@ -829,12 +833,12 @@ public override Expression GetExpression(Expression left, Expression right, T var listType = typeof(List); var caseList = Activator.CreateInstance(listType); - var originalList = ((ConstantExpression)right).Value as IEnumerable; + var originalList = values as IEnumerable; foreach (var value in originalList!) { listType.GetMethod("Add")!.Invoke(caseList, new[] { caseMode == CaseInsensitiveMode.Upper ? value.ToUpper() : value.ToLower() }); } - right = Expression.Constant(caseList, listType); + right = FilterValue.Parameter(caseList, listType); var caseLeft = Expression.Call(left, typeof(string).GetMethod(caseMethodName, Type.EmptyTypes)!); var containsExpression = Expression.Call(right, containsMethod, caseLeft); From 4323705c43b2ba4bce14c9db6b18c751467a5abd Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:17:43 +0300 Subject: [PATCH 3/4] test(integration): use a longer guid fragment in the guid contains test The tests share one database, so other people with random ids are in the table. A random guid contains "9edb" in about 1 of 2600 cases, so the filter sometimes matched a second person. An 8-character fragment makes a random match improbable. --- QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs index 2f8d380..b67a17a 100644 --- a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs +++ b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs @@ -735,7 +735,7 @@ public async Task can_filter_by_guid_contains() var fakePersonTwo = new FakeTestingPersonBuilder().Build(); await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); - var input = $"""(id @=* "9edb")"""; + var input = $"""(id @=* "9edb-a3ec")"""; // Act var queryablePeople = testingServiceScope.DbContext().People; From c0f67bcca2004ba1f178eabfb78397ab221d0121 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Tue, 29 Sep 2026 21:18:56 +0300 Subject: [PATCH 4/4] perf(parser): cache sprache parsers and alias regexes The Sprache parsers were properties, so each parse built them again. A parser in a second from clause was also built again inside a lambda on each parse. The parsers are now static readonly fields in dependency order. The recursive arithmetic parser still goes through Parse.Ref. The alias replacement built a new Regex for each query name and operator, and for each operator alias, on each parse. A static cache now keeps one Regex for each pattern. The patterns come only from the configuration. Age > 25 went from about 75 us and 167 KB to about 34 us and 55 KB for each parse. A filter with five property aliases went from about 300 us and 781 KB to about 50 us and 103 KB. --- .../FilterParserAllocationTests.cs | 58 +++++ QueryKit/AliasRegexCache.cs | 15 ++ .../QueryKitConfigurationExtensions.cs | 4 +- QueryKit/FilterParser.cs | 214 ++++++++++-------- QueryKit/QueryKitPropertyMappings.cs | 2 +- 5 files changed, 192 insertions(+), 101 deletions(-) create mode 100644 QueryKit.UnitTests/FilterParserAllocationTests.cs create mode 100644 QueryKit/AliasRegexCache.cs diff --git a/QueryKit.UnitTests/FilterParserAllocationTests.cs b/QueryKit.UnitTests/FilterParserAllocationTests.cs new file mode 100644 index 0000000..ad8fa3c --- /dev/null +++ b/QueryKit.UnitTests/FilterParserAllocationTests.cs @@ -0,0 +1,58 @@ +namespace QueryKit.UnitTests; + +using Configuration; +using FluentAssertions; +using WebApiTestProject.Entities; + +// The parsers and the alias regexes are built once, not on each parse. Each budget is about two +// times the bytes that one parse allocates, and less than the bytes that one parse allocated when +// each parse built the parsers and the regexes again. +public class FilterParserAllocationTests +{ + private const int Iterations = 50; + + [Fact] + public void a_simple_filter_does_not_build_the_parsers_again() + { + BytesForEachParse("Age > 25", null).Should().BeLessThan(100 * 1024); + } + + [Fact] + public void property_aliases_do_not_build_a_regex_on_each_parse() + { + var config = new QueryKitConfiguration(c => + { + c.Property(x => x.Title).HasQueryName("name"); + c.Property(x => x.Age).HasQueryName("years"); + c.Property(x => x.Rating).HasQueryName("score"); + c.Property(x => x.FirstName).HasQueryName("first"); + c.Property(x => x.Id).HasQueryName("key"); + }); + + BytesForEachParse("""name == "lamb" && years > 25""", config).Should().BeLessThan(250 * 1024); + } + + [Fact] + public void operator_aliases_do_not_build_a_regex_on_each_parse() + { + var config = new QueryKitConfiguration(c => + { + c.EqualsOperator = "eq"; + c.GreaterThanOperator = "gt"; + c.AndOperator = "and"; + }); + + BytesForEachParse("""Title eq "lamb" and Age gt 25""", config).Should().BeLessThan(200 * 1024); + } + + private static long BytesForEachParse(string filter, IQueryKitConfiguration? config) + { + for (var i = 0; i < Iterations; i++) + FilterParser.ParseFilter(filter, config); + + var before = GC.GetAllocatedBytesForCurrentThread(); + for (var i = 0; i < Iterations; i++) + FilterParser.ParseFilter(filter, config); + return (GC.GetAllocatedBytesForCurrentThread() - before) / Iterations; + } +} diff --git a/QueryKit/AliasRegexCache.cs b/QueryKit/AliasRegexCache.cs new file mode 100644 index 0000000..c3041be --- /dev/null +++ b/QueryKit/AliasRegexCache.cs @@ -0,0 +1,15 @@ +namespace QueryKit; + +using System.Collections.Concurrent; +using System.Text.RegularExpressions; + +// Keeps one Regex for each alias pattern, so a parse does not build a new Regex for each alias. +// The patterns come only from the configuration (query names and operator aliases), never from +// the filter text, so the number of entries stays small. +internal static class AliasRegexCache +{ + private static readonly ConcurrentDictionary Cache = new(); + + public static Regex Get(string pattern) + => Cache.GetOrAdd(pattern, p => new Regex(p, RegexOptions.IgnoreCase)); +} diff --git a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs index 5a1c67c..f196d49 100644 --- a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs +++ b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs @@ -12,7 +12,7 @@ internal static string ReplaceComparisonAliases(this IQueryKitConfiguration conf foreach (var comparisonAliasMatch in aliasedOperators) { var escapedAlias = Regex.Escape(comparisonAliasMatch.Alias); - var regex = new Regex($@"(?<=\s|^){escapedAlias}(?=\s|$)", RegexOptions.IgnoreCase); + var regex = AliasRegexCache.Get($@"(?<=\s|^){escapedAlias}(?=\s|$)"); input = regex.Replace(input, comparisonAliasMatch.Operator); } @@ -25,7 +25,7 @@ internal static string ReplaceLogicalAliases(this IQueryKitConfiguration configu foreach (var logicalAliasMatch in aliasedOperators) { var escapedAlias = Regex.Escape(logicalAliasMatch.Alias); - var regex = new Regex($@"(?<=\s|^){escapedAlias}(?=\s|$)", RegexOptions.IgnoreCase); + var regex = AliasRegexCache.Get($@"(?<=\s|^){escapedAlias}(?=\s|$)"); input = regex.Replace(input, logicalAliasMatch.Operator); } diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 739d39b..eeaa459 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -59,96 +59,63 @@ from first in Parse.Letter.Once() from rest in Parse.LetterOrDigit.XOr(Parse.Char('_')).Many() select new string(first.Concat(rest).ToArray()); - private static Parser>> PropertyListParser => + // 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 Parser ComparisonOperatorParser => + + private static readonly Parser ComparisonOperatorTextParser = + Parse.String(ComparisonOperator.EqualsOperator().Operator()).Text() + .Or(Parse.String(ComparisonOperator.NotEqualsOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.GreaterThanOrEqualOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.LessThanOrEqualOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.GreaterThanOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.LessThanOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.ContainsOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.StartsWithOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.EndsWithOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.NotContainsOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.NotStartsWithOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.NotEndsWithOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.InOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.NotInOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.SoundsLikeOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.DoesNotSoundLikeOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.HasCountEqualToOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.HasCountNotEqualToOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.HasCountGreaterThanOrEqualOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.HasCountLessThanOrEqualOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.HasCountGreaterThanOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.HasCountLessThanOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.HasOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.DoesNotHaveOperator().Operator()).Text()); + + private static readonly Parser ComparisonOperatorParser = Parse.Char(ComparisonOperator.AllPrefix).Optional().Select(opt => opt.IsDefined) .Then(hasHash => - Parse.String(ComparisonOperator.EqualsOperator().Operator()).Text() - .Or(Parse.String(ComparisonOperator.NotEqualsOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.GreaterThanOrEqualOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.LessThanOrEqualOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.GreaterThanOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.LessThanOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.ContainsOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.StartsWithOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.EndsWithOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.NotContainsOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.NotStartsWithOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.NotEndsWithOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.InOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.NotInOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.SoundsLikeOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.DoesNotSoundLikeOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.HasCountEqualToOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.HasCountNotEqualToOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.HasCountGreaterThanOrEqualOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.HasCountLessThanOrEqualOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.HasCountGreaterThanOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.HasCountLessThanOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.HasOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.DoesNotHaveOperator().Operator()).Text()) + ComparisonOperatorTextParser .SelectMany(op => Parse.Char(ComparisonOperator.CaseSensitiveAppendix).Optional(), (op, caseInsensitive) => new { op, caseInsensitive, hasHash }) .Select(x => ComparisonOperator.GetByOperatorString(x.op, x.caseInsensitive.IsDefined, x.hasHash))); private static PropertyInfo? GetPropertyInfo(Type type, string propertyName) => type.GetProperty(propertyName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance); - // Arithmetic expression parsers - private static Parser ArithmeticOperatorParser => - Parse.Char('+').Return(ArithmeticOperator.Add) - .Or(Parse.Char('-').Return(ArithmeticOperator.Subtract)) - .Or(Parse.Char('*').Return(ArithmeticOperator.Multiply)) - .Or(Parse.Char('/').Return(ArithmeticOperator.Divide)) - .Or(Parse.Char('%').Return(ArithmeticOperator.Modulo)); - - private static Parser ArithmeticTermParser => - PropertyArithmeticParser - .Or(LiteralArithmeticParser) - .Or(Parse.Ref(() => ArithmeticExpressionParser).Contained(Parse.Char('('), Parse.Char(')')).Select(expr => new GroupedArithmeticExpression(expr))); - - private static Parser PropertyArithmeticParser => - Identifier.DelimitedBy(Parse.Char('.')) - .Select(props => new PropertyArithmeticExpression(string.Join(".", props))); - - private static Parser LiteralArithmeticParser => - NumberParser.Select(numStr => - { - if (int.TryParse(numStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intVal)) - return new LiteralArithmeticExpression(intVal, typeof(int)); - if (decimal.TryParse(numStr, NumberStyles.Number, CultureInfo.InvariantCulture, out var decVal)) - return new LiteralArithmeticExpression(decVal, typeof(decimal)); - if (double.TryParse(numStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleVal)) - return new LiteralArithmeticExpression(doubleVal, typeof(double)); - - throw new InvalidOperationException($"Cannot parse number: {numStr}"); - }); - - private static Parser ArithmeticFactorParser => - Parse.ChainOperator( - ArithmeticOperatorParser.Where(op => op.Precedence == 2).Token(), // *, /, % - ArithmeticTermParser.Token(), - (op, left, right) => new BinaryArithmeticExpression(left, op, right)); + private static readonly Parser LogicalOperatorTextParser = + Parse.String(LogicalOperator.AndOperator.Operator()).Text().Or(Parse.String(LogicalOperator.OrOperator.Operator()).Text()); - private static Parser ArithmeticExpressionParser => - Parse.ChainOperator( - ArithmeticOperatorParser.Where(op => op.Precedence == 1).Token(), // +, - - ArithmeticFactorParser.Token(), - (op, left, right) => new BinaryArithmeticExpression(left, op, right)); - - public static Parser LogicalOperatorParser => + public static Parser LogicalOperatorParser { get; } = from leadingSpaces in Parse.WhiteSpace.Many() - from op in Parse.String(LogicalOperator.AndOperator.Operator()).Text().Or(Parse.String(LogicalOperator.OrOperator.Operator()).Text()) + from op in LogicalOperatorTextParser from trailingSpaces in Parse.WhiteSpace.Many() select LogicalOperator.GetByOperatorString(op); - private static Parser DoubleQuoteParser - => Parse.Char('"').Then(_ => Parse.AnyChar.Except(Parse.Char('"')).Many().Text().Then(innerValue => Parse.Char('"').Return(innerValue))); + private static readonly Parser DoubleQuoteParser + = Parse.Char('"').Then(_ => Parse.AnyChar.Except(Parse.Char('"')).Many().Text().Then(innerValue => Parse.Char('"').Return(innerValue))); /* ISO 8601 * DateTimeOffset (with offset): yyyy-MM-ddTHH:mm:ss.ffffffzzz @@ -156,22 +123,25 @@ private static Parser DoubleQuoteParser * DateTime (no offset information): yyyy-MM-ddTHH:mm:ss.ffffff * DateTime (in UTC): yyyy-MM-ddTHH:mm:ss.ffffffZ */ - private static Parser TimeFormatParser => Parse.Regex(@"\d{2}:\d{2}:\d{2}").Text(); - private static Parser DateTimeFormatParser => + private static readonly Parser TimeFormatParser = Parse.Regex(@"\d{2}:\d{2}:\d{2}").Text(); + private static readonly Parser DateTimeTimeParser = Parse.Regex(@"T\d{2}:\d{2}:\d{2}").Text().Optional().Select(x => x.GetOrElse("")); + private static readonly Parser DateTimeZoneParser = Parse.Regex(@"Z|[+-]\d{2}(:\d{2})?").Text().Optional().Select(x => x.GetOrElse("")); + private static readonly Parser DateTimeMicrosParser = Parse.Regex(@"\.\d{1,6}").Text().Optional().Select(x => x.GetOrElse("")); + private static readonly Parser DateTimeFormatParser = from dateFormat in Parse.Regex(@"\d{4}-\d{2}-\d{2}").Text() - from timeFormat in Parse.Regex(@"T\d{2}:\d{2}:\d{2}").Text().Optional().Select(x => x.GetOrElse("")) - from timeZone in Parse.Regex(@"Z|[+-]\d{2}(:\d{2})?").Text().Optional().Select(x => x.GetOrElse("")) - from micros in Parse.Regex(@"\.\d{1,6}").Text().Optional().Select(x => x.GetOrElse("")) + from timeFormat in DateTimeTimeParser + from timeZone in DateTimeZoneParser + from micros in DateTimeMicrosParser select dateFormat + timeFormat + micros + timeZone; - private static Parser NumberParser => + private static readonly Parser NumberParser = from sign in Parse.Char('-').Optional().Select(x => x.IsDefined ? "-" : "") from number in Parse.Decimal select sign + number; - private static Parser GuidFormatParser => Parse.Regex(@"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}").Text(); + private static readonly Parser GuidFormatParser = Parse.Regex(@"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}").Text(); - private static Parser RawStringLiteralParser => + private static readonly Parser RawStringLiteralParser = from openingQuotes in Parse.Regex("\"{3,}").Text() let count = openingQuotes.Length from content in Parse.AnyChar.Except(Parse.Char('"').Repeat(count)).Many().Text() @@ -183,33 +153,81 @@ from closingQuotes in Parse.Char('"').Repeat(count).Text() // comparison) once the surrounding quotes have been stripped, since both are otherwise identical strings. private readonly record struct RightSideValue(string Value, bool IsQuotedLiteral); - private static Parser RightSideValueParser => - from atSign in Parse.Char('@').Optional() - from leadingSpaces in Parse.WhiteSpace.Many() - from value in Parse.String("null").Text().Select(v => new RightSideValue(v, false)) - .Or(GuidFormatParser.Select(v => new RightSideValue(v, false))) - .XOr(DateTimeFormatParser.Select(v => new RightSideValue(v, false))) - .XOr(TimeFormatParser.Select(v => new RightSideValue(v, false))) - .XOr(NumberParser.Select(v => new RightSideValue(v, false))) - .XOr((RawStringLiteralParser.Or(DoubleQuoteParser)).Select(v => new RightSideValue(v, true))) - .XOr(SquareBracketParser.Select(v => new RightSideValue(v, false))) - .XOr(Identifier.Select(v => new RightSideValue(v, false))) // Keep this last to try property paths only if nothing else matches - from trailingSpaces in Parse.WhiteSpace.Many() - select atSign.IsDefined ? value with { Value = "@" + value.Value } : value; - - private static Parser SquareBracketParser => - from openingBracket in Parse.Char('[') - from content in Parse.String("null").Text() + private static readonly Parser> SquareBracketValuesParser = + Parse.String("null").Text() .Or(GuidFormatParser) .Or(DateTimeFormatParser) .Or(TimeFormatParser) .Or(NumberParser) .Or(RawStringLiteralParser.Or(DoubleQuoteParser)) .Or(Identifier) - .DelimitedBy(Parse.Char(',').Token()) + .DelimitedBy(Parse.Char(',').Token()); + + private static readonly Parser SquareBracketParser = + from openingBracket in Parse.Char('[') + from content in SquareBracketValuesParser from closingBracket in Parse.Char(']') select "[" + string.Join(",", content) + "]"; + private static readonly Parser RightSideValueChoiceParser = + Parse.String("null").Text().Select(v => new RightSideValue(v, false)) + .Or(GuidFormatParser.Select(v => new RightSideValue(v, false))) + .XOr(DateTimeFormatParser.Select(v => new RightSideValue(v, false))) + .XOr(TimeFormatParser.Select(v => new RightSideValue(v, false))) + .XOr(NumberParser.Select(v => new RightSideValue(v, false))) + .XOr((RawStringLiteralParser.Or(DoubleQuoteParser)).Select(v => new RightSideValue(v, true))) + .XOr(SquareBracketParser.Select(v => new RightSideValue(v, false))) + .XOr(Identifier.Select(v => new RightSideValue(v, false))); // Keep this last to try property paths only if nothing else matches + + private static readonly Parser RightSideValueParser = + from atSign in Parse.Char('@').Optional() + from leadingSpaces in Parse.WhiteSpace.Many() + from value in RightSideValueChoiceParser + from trailingSpaces in Parse.WhiteSpace.Many() + select atSign.IsDefined ? value with { Value = "@" + value.Value } : value; + + // Arithmetic expression parsers + private static readonly Parser ArithmeticOperatorParser = + Parse.Char('+').Return(ArithmeticOperator.Add) + .Or(Parse.Char('-').Return(ArithmeticOperator.Subtract)) + .Or(Parse.Char('*').Return(ArithmeticOperator.Multiply)) + .Or(Parse.Char('/').Return(ArithmeticOperator.Divide)) + .Or(Parse.Char('%').Return(ArithmeticOperator.Modulo)); + + private static readonly Parser PropertyArithmeticParser = + Identifier.DelimitedBy(Parse.Char('.')) + .Select(props => new PropertyArithmeticExpression(string.Join(".", props))); + + private static readonly Parser LiteralArithmeticParser = + NumberParser.Select(numStr => + { + if (int.TryParse(numStr, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intVal)) + return new LiteralArithmeticExpression(intVal, typeof(int)); + if (decimal.TryParse(numStr, NumberStyles.Number, CultureInfo.InvariantCulture, out var decVal)) + return new LiteralArithmeticExpression(decVal, typeof(decimal)); + if (double.TryParse(numStr, NumberStyles.Float, CultureInfo.InvariantCulture, out var doubleVal)) + return new LiteralArithmeticExpression(doubleVal, typeof(double)); + + throw new InvalidOperationException($"Cannot parse number: {numStr}"); + }); + + private static readonly Parser ArithmeticTermParser = + PropertyArithmeticParser + .Or(LiteralArithmeticParser) + .Or(Parse.Ref(() => ArithmeticExpressionParser).Contained(Parse.Char('('), Parse.Char(')')).Select(expr => new GroupedArithmeticExpression(expr))); + + private static readonly Parser ArithmeticFactorParser = + Parse.ChainOperator( + ArithmeticOperatorParser.Where(op => op.Precedence == 2).Token(), // *, /, % + ArithmeticTermParser.Token(), + (op, left, right) => new BinaryArithmeticExpression(left, op, right)); + + private static readonly Parser ArithmeticExpressionParser = + Parse.ChainOperator( + ArithmeticOperatorParser.Where(op => op.Precedence == 1).Token(), // +, - + ArithmeticFactorParser.Token(), + (op, left, right) => new BinaryArithmeticExpression(left, op, right)); + private static readonly Dictionary> TypeConversionFunctions = new() { { typeof(string), value => value }, diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index 8aed9ec..9408114 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -137,7 +137,7 @@ public string ReplaceAliasesWithPropertyPaths(string input) // 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 = new Regex($@"\b{Regex.Escape(queryKitPropertyInfo.QueryName!)}\b(?=\s*{Regex.Escape(op)})", RegexOptions.IgnoreCase); + var regex = AliasRegexCache.Get($@"\b{Regex.Escape(queryKitPropertyInfo.QueryName!)}\b(?=\s*{Regex.Escape(op)})"); if (queryKitPropertyInfo is { CanSort: false, CanFilter: false} && regex.IsMatch(input)) {