diff --git a/QueryKit.IntegrationTests/Tests/FilterParameterTests.cs b/QueryKit.IntegrationTests/Tests/FilterParameterTests.cs index 0b2689e..e630b88 100644 --- a/QueryKit.IntegrationTests/Tests/FilterParameterTests.cs +++ b/QueryKit.IntegrationTests/Tests/FilterParameterTests.cs @@ -2,11 +2,15 @@ namespace QueryKit.IntegrationTests.Tests; using FluentAssertions; using Microsoft.EntityFrameworkCore; +using QueryKit.Configuration; using SharedTestingHelper.Fakes; using WebApiTestProject.Entities; public class FilterParameterTests() : TestBase { + private static readonly QueryKitConfiguration ParameterizedConfig = + new(settings => settings.ParameterizeFilterValues = true); + [Theory] [InlineData("""Title == "lamb" """, """Title == "chicken" """)] [InlineData("""Title @=* "lamb" """, """Title @=* "chicken" """)] @@ -44,15 +48,47 @@ public async Task in_list_is_one_array_parameter_and_still_filters() await testingServiceScope.InsertAsync(lamb, chicken, beef); var input = $"""Title ^^* ["{lamb.Title!.ToUpper()}", "{chicken.Title}"]"""; - var query = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input); + var query = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input, ParameterizedConfig); var people = await query.ToListAsync(); SqlWithoutParameterValues(query).Should().Contain("= ANY (@"); people.Select(x => x.Id).Should().BeEquivalentTo(new[] { lamb.Id, chicken.Id }); } + [Theory] + [InlineData("""Title == "lamb" """, "'lamb'")] + [InlineData("Age > 30", "> 30")] + [InlineData("Date == 2022-07-01", "DATE '2022-07-01'")] + [InlineData("(Age + 5) > 30", "+ 5")] + public void filter_values_are_sql_literals_by_default(string input, string expectedLiteral) + { + var testingServiceScope = new TestingServiceScope(); + + var sql = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input).ToQueryString(); + + sql.Should().NotContain("@"); + sql.Should().Contain(expectedLiteral); + } + + [Fact] + public async Task in_list_is_a_literal_list_by_default_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}", "{chicken.Title}"]"""; + var query = testingServiceScope.DbContext().People.ApplyQueryKitFilter(input); + var people = await query.ToListAsync(); + + query.ToQueryString().Should().Contain($"IN ('{lamb.Title}', '{chicken.Title}')"); + 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)); + => SqlWithoutParameterValues(testingServiceScope.DbContext().People.ApplyQueryKitFilter(input, ParameterizedConfig)); // ToQueryString() writes each parameter value in a "-- @p='...'" comment line before the SQL. private static string SqlWithoutParameterValues(IQueryable query) diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 590601b..1dba6d7 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -11,6 +11,33 @@ namespace QueryKit.IntegrationTests.Tests; public class PropertyResolverTests : TestBase { + [Fact] + public async Task unknown_property_clause_under_or_is_true_by_default() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = new Faker().Lorem.Sentence(); + var fakePerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithAge(30) + .Build(); + await testingServiceScope.InsertAsync(fakePerson); + + var input = $"""Title == "{title}" && (Nope == "x" || Age > 100)"""; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().ContainSingle(x => x.Id == fakePerson.Id); + } + [Fact] public async Task unknown_property_clause_under_or_does_not_return_every_row() { @@ -26,6 +53,7 @@ public async Task unknown_property_clause_under_or_does_not_return_every_row() var input = $"""Title == "{title}" && (Nope == "x" || Age > 100)"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.AllowUnknownProperties = true; }); @@ -54,6 +82,7 @@ public async Task prevented_property_clause_under_or_does_not_return_every_row() var input = $"""Title == "{title}" && (Rating == 1 || Age > 100)"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Rating).PreventFilter(); }); @@ -82,6 +111,7 @@ public async Task prevented_property_clause_by_its_query_name_under_or_does_not_ var input = $"""Title == "{title}" && (first == "Paul" || Age > 100)"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.FirstName).HasQueryName("first").PreventFilter().PreventSort(); }); @@ -138,6 +168,7 @@ public async Task prevented_property_on_the_right_side_is_not_compared() var input = $"""Title == "{title}" && (FirstName == LastName || Age > 100)"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.LastName).PreventFilter(); }); @@ -398,6 +429,7 @@ public async Task unknown_property_in_arithmetic_removes_the_clause_when_unknown var input = $"""Title == "{title}" && ((Nope + 1) > 3 || Age > 100)"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.AllowUnknownProperties = true; }); diff --git a/QueryKit.UnitTests/ArithmeticOperatorTests.cs b/QueryKit.UnitTests/ArithmeticOperatorTests.cs new file mode 100644 index 0000000..e53be63 --- /dev/null +++ b/QueryKit.UnitTests/ArithmeticOperatorTests.cs @@ -0,0 +1,33 @@ +namespace QueryKit.UnitTests; + +using FluentAssertions; +using QueryKit.Operators; + +public class ArithmeticOperatorTests +{ + [Theory] + [InlineData("+", "+")] + [InlineData("-", "-")] + [InlineData("*", "*")] + [InlineData("/", "/")] + [InlineData("%", "%")] + public void from_symbol_returns_the_operator_for_the_symbol(string symbol, string expectedSymbol) + { +#pragma warning disable CS0618 // FromSymbol stays for v1.14.2 compatibility + var op = ArithmeticOperator.FromSymbol(symbol); +#pragma warning restore CS0618 + + op.Should().NotBeNull(); + op!.Symbol.Should().Be(expectedSymbol); + } + + [Fact] + public void from_symbol_returns_null_for_an_unknown_symbol() + { +#pragma warning disable CS0618 // FromSymbol stays for v1.14.2 compatibility + var op = ArithmeticOperator.FromSymbol("^"); +#pragma warning restore CS0618 + + op.Should().BeNull(); + } +} diff --git a/QueryKit.UnitTests/CustomFilterPropertyTests.cs b/QueryKit.UnitTests/CustomFilterPropertyTests.cs index a41cbdf..70ed347 100644 --- a/QueryKit.UnitTests/CustomFilterPropertyTests.cs +++ b/QueryKit.UnitTests/CustomFilterPropertyTests.cs @@ -142,7 +142,7 @@ public void can_have_custom_prop_excluded_from_filter() config.Property(x => x.Id).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "{stringValue}")"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (True == True))"""); } [Fact] @@ -159,7 +159,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.ToDisplayString().Should().Be($"""x => (x.Title == "{stringValue}")"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (True == True))"""); } [Fact] @@ -243,7 +243,7 @@ public void can_have_derived_prop_work_with_collection_filters() } [Fact] - public void filter_prevented_props_are_removed_regardless_of_comparison() + public void filter_prevented_props_always_have_true_equals_true_regardless_of_comparison() { var faker = new Faker(); var filterOperator = faker.PickRandom(ComparisonOperator.List.Where(x => x != ComparisonOperator.EqualsOperator()).ToList()); @@ -255,7 +255,7 @@ public void filter_prevented_props_are_removed_regardless_of_comparison() config.Property(x => x.Id).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => True"); + filterExpression.ToDisplayString().Should().Be("x => (True == True)"); } [Fact] @@ -284,6 +284,6 @@ public void can_handle_nonexistent_property() config.AllowUnknownProperties = true; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => True"); + filterExpression.ToDisplayString().Should().Be("x => (True == True)"); } } \ No newline at end of file diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 420ec16..a72fadd 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -42,7 +42,7 @@ public void complex_with_lots_of_types() var filterExpression = FilterParser.ParseFilter(input); 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 != null) 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))))))""""); + .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 != null) 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] diff --git a/QueryKit.UnitTests/FilterValueParameterTests.cs b/QueryKit.UnitTests/FilterValueParameterTests.cs index 8c48fc5..6f0e0a4 100644 --- a/QueryKit.UnitTests/FilterValueParameterTests.cs +++ b/QueryKit.UnitTests/FilterValueParameterTests.cs @@ -2,10 +2,14 @@ namespace QueryKit.UnitTests; using System.Linq.Expressions; using FluentAssertions; +using QueryKit.Configuration; using WebApiTestProject.Entities; public class FilterValueParameterTests { + private static readonly QueryKitConfiguration ParameterizedConfig = + new(settings => settings.ParameterizeFilterValues = true); + [Theory] [InlineData("""Title == "lamb" """)] [InlineData("""Title @=* "waffle" """)] @@ -27,7 +31,7 @@ public class FilterValueParameterTests [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 filterExpression = FilterParser.ParseFilter(input, ParameterizedConfig); var constants = new ConstantCollector(); constants.Visit(filterExpression); @@ -37,10 +41,45 @@ public void filter_values_are_field_reads_so_ef_core_sends_them_as_parameters(st value.GetType().GetGenericTypeDefinition().Should().Be(typeof(FilterValue<>))); } + [Theory] + [InlineData("""Title == "lamb" """, """x => (x.Title == "lamb")""")] + [InlineData("Age > 30", "x => (x.Age > 30)")] + [InlineData("BirthMonth == \"January\"", "x => (x.BirthMonth == new Nullable`1(January))")] + [InlineData("SpecificDate == 2022-07-01T00:00:03Z", + "x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 00:00:00)))")] + [InlineData("Date == 2022-07-01", "x => (x.Date == new Nullable`1(new DateOnly(2022, 7, 1)))")] + [InlineData("""Time == "00:00:03.123456" """, "x => (x.Time == new Nullable`1(new TimeOnly(0, 0, 3, 123, 456)))")] + [InlineData("(Age + 5) > 30", "x => ((x.Age + Convert(5, Nullable`1)) > Convert(30, Nullable`1))")] + public void filter_values_are_constants_by_default(string input, string expected) + { + var filterExpression = FilterParser.ParseFilter(input); + + filterExpression.ToString().Should().Be(expected); + } + + [Theory] + [InlineData("""Title == "lamb" """)] + [InlineData("SpecificDateTime > 2022-07-01T00:00:03")] + [InlineData("""Title ^^ ["lamb", "chicken"]""")] + [InlineData("""Title ^^* ["lamb", "chicken"]""")] + [InlineData("Age ^^ [18, 30]")] + [InlineData("(Age + 5) > 30")] + public void filter_values_do_not_use_the_holder_by_default(string input) + { + var filterExpression = FilterParser.ParseFilter(input); + + var constants = new ConstantCollector(); + constants.Visit(filterExpression); + + constants.Values.Should().NotBeEmpty(); + constants.Values.Should().NotContain(value => + value.GetType().IsGenericType && value.GetType().GetGenericTypeDefinition() == typeof(FilterValue<>)); + } + [Fact] public void filter_values_keep_their_value_and_type() { - var filterExpression = FilterParser.ParseFilter("""Time == "00:00:03.123456" """); + var filterExpression = FilterParser.ParseFilter("""Time == "00:00:03.123456" """, ParameterizedConfig); var comparison = (BinaryExpression)filterExpression.Body; var read = (MemberExpression)comparison.Right; diff --git a/QueryKit.UnitTests/ParseLimitsTests.cs b/QueryKit.UnitTests/ParseLimitsTests.cs index 01d06b8..f5c16ab 100644 --- a/QueryKit.UnitTests/ParseLimitsTests.cs +++ b/QueryKit.UnitTests/ParseLimitsTests.cs @@ -101,4 +101,80 @@ public void filter_within_configured_input_length_parses() var filterExpression = FilterParser.ParseFilter(input, config); filterExpression.Should().NotBeNull(); } + + [Fact] + public void configuration_that_implements_only_the_interface_uses_the_default_limits() + { + var config = new InterfaceOnlyConfiguration(); + + var filterExpression = FilterParser.ParseFilter("""Title == "salt" """, config); + filterExpression.Should().NotBeNull(); + + var tooDeep = new string('(', QueryKitSettings.DefaultMaxNestingDepth + 1) + + """Title == "salt" """ + + new string(')', QueryKitSettings.DefaultMaxNestingDepth + 1); + var actDeep = () => FilterParser.ParseFilter(tooDeep, config); + actDeep.Should().Throw() + .WithMessage($"*maximum allowed depth of {QueryKitSettings.DefaultMaxNestingDepth}*"); + + var tooLong = $"""Title == "{new string('a', QueryKitSettings.DefaultMaxInputLength)}" """; + var actLong = () => FilterParser.ParseFilter(tooLong, config); + actLong.Should().Throw() + .WithMessage($"*maximum allowed length of {QueryKitSettings.DefaultMaxInputLength}*"); + } + + [Fact] + public void configuration_that_implements_the_parse_limits_uses_its_own_limits() + { + var config = new InterfaceOnlyConfigurationWithLimits { MaxNestingDepth = 2, MaxInputLength = 100 }; + var input = new string('(', 3) + """Title == "salt" """ + new string(')', 3); + + var act = () => FilterParser.ParseFilter(input, config); + act.Should().Throw() + .WithMessage("*depth of 3*maximum allowed depth of 2*"); + } + + private sealed class InterfaceOnlyConfigurationWithLimits : InterfaceOnlyConfiguration, IQueryKitParseLimits + { + public int MaxNestingDepth { get; set; } + public int MaxInputLength { get; set; } + } + + // Implements only the IQueryKitConfiguration members of v1.14.2. This class does not compile + // when the interface gets a new member. + private class InterfaceOnlyConfiguration : IQueryKitConfiguration + { + public QueryKitPropertyMappings PropertyMappings { get; } = new(); + public string EqualsOperator { get; set; } = "=="; + public string NotEqualsOperator { get; set; } = "!="; + public string GreaterThanOperator { get; set; } = ">"; + public string LessThanOperator { get; set; } = "<"; + public string GreaterThanOrEqualOperator { get; set; } = ">="; + public string LessThanOrEqualOperator { get; set; } = "<="; + public string ContainsOperator { get; set; } = "@="; + public string StartsWithOperator { get; set; } = "_="; + public string EndsWithOperator { get; set; } = "_-="; + public string NotContainsOperator { get; set; } = "!@="; + public string NotStartsWithOperator { get; set; } = "!_="; + public string NotEndsWithOperator { get; set; } = "!_-="; + public string InOperator { get; set; } = "^^"; + public string NotInOperator { get; set; } = "!^^"; + public string SoundsLikeOperator { get; set; } = "~~"; + public string DoesNotSoundLikeOperator { get; set; } = "!~"; + public string CaseInsensitiveAppendix { get; set; } = "*"; + public string AndOperator { get; set; } = "&&"; + public string OrOperator { get; set; } = "||"; + public bool AllowUnknownProperties { get; set; } + public Type? DbContextType { get; set; } + public string HasCountEqualToOperator { get; set; } = "#=="; + public string HasCountNotEqualToOperator { get; set; } = "#!="; + public string HasCountGreaterThanOperator { get; set; } = "#>"; + public string HasCountLessThanOperator { get; set; } = "#<"; + public string HasCountGreaterThanOrEqualOperator { get; set; } = "#>="; + public string HasCountLessThanOrEqualOperator { get; set; } = "#<="; + public string HasOperator { get; set; } = "^$"; + public string DoesNotHaveOperator { get; set; } = "!^$"; + public int? MaxPropertyDepth { get; set; } + public CaseInsensitiveMode CaseInsensitiveComparison { get; set; } = CaseInsensitiveMode.Lower; + } } diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 90cc61d..0d665c3 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -9,12 +9,56 @@ namespace QueryKit.UnitTests; public class PropertyResolverTests { + [Fact] + public void unknown_property_clause_is_true_equals_true_by_default() + { + var input = """Nope == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => ((True == True) OrElse (x.Age > 100))"); + } + + [Fact] + public void prevented_property_clause_is_true_equals_true_by_default() + { + var input = """FirstName == "Ann" || Title == "s" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => ((x.FirstName == "Ann") OrElse (True == True))"""); + } + + [Fact] + public void property_list_with_only_prevented_properties_is_true_by_default() + { + var input = """(Title, FirstName) == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).PreventFilter(); + config.Property(x => x.FirstName).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (True OrElse (x.Age > 100))"); + } + [Fact] public void unknown_property_clause_is_removed_under_or() { var input = """Nope == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.AllowUnknownProperties = true; }); @@ -29,6 +73,7 @@ public void unknown_property_clause_is_removed_under_and() var input = """Age > 100 && Nope == "x" """; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.AllowUnknownProperties = true; }); @@ -43,6 +88,7 @@ public void prevented_property_clause_is_removed_under_or() var input = """Rating == 1 || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Rating).PreventFilter(); }); @@ -57,6 +103,7 @@ public void removed_clause_in_a_group_is_removed_from_the_group() var input = """Title == "a" && (Nope == "x" || Age > 100)"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.AllowUnknownProperties = true; }); @@ -71,6 +118,7 @@ public void property_list_with_only_prevented_properties_is_removed() var input = """(Title, FirstName) == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Title).PreventFilter(); config.Property(x => x.FirstName).PreventFilter(); }); @@ -86,6 +134,7 @@ public void prevented_property_in_arithmetic_removes_the_clause() var input = """(Age + 0) > 10 || Title == "a" """; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Age).PreventFilter(); }); @@ -100,6 +149,7 @@ public void prevented_property_on_the_right_side_of_arithmetic_removes_the_claus var input = """(Age + 0) > (Rating * 2)"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Rating).PreventFilter(); }); @@ -128,6 +178,7 @@ public void prevented_property_on_the_right_side_removes_the_clause() var input = """FirstName == Title || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Title).PreventFilter(); }); @@ -142,6 +193,7 @@ public void prevented_property_on_the_right_side_removes_the_clause_in_any_case( var input = """FirstName == title || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Title).PreventFilter(); }); @@ -170,6 +222,7 @@ public void prevented_property_removes_the_clause_in_any_case() var input = """title == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Title).PreventFilter(); }); @@ -184,6 +237,7 @@ public void prevented_property_with_a_query_name_removes_the_clause_when_written var input = """title == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Title).HasQueryName("t").PreventFilter(); }); @@ -241,6 +295,7 @@ public void prevented_derived_property_removes_the_clause() var input = """full == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventFilter(); }); @@ -269,6 +324,7 @@ public void prevented_custom_operation_removes_the_clause() var input = """adult == true || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.CustomOperation((x, op, value) => x.Age > 17).HasQueryName("adult").PreventFilter(); }); @@ -340,6 +396,7 @@ public void property_prevented_for_filter_and_sort_is_removed_by_its_query_name( var input = """name == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); }); @@ -354,6 +411,7 @@ public void property_prevented_for_filter_and_sort_is_removed_by_its_member_name var input = """Title == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); }); @@ -363,7 +421,7 @@ public void property_prevented_for_filter_and_sort_is_removed_by_its_member_name } [Fact] - public void alias_replacement_does_not_replace_a_query_name_in_a_nested_path() + public void alias_replacement_replaces_a_query_name_in_a_nested_path() { var config = new QueryKitConfiguration(config => { @@ -372,7 +430,7 @@ public void alias_replacement_does_not_replace_a_query_name_in_a_nested_path() var input = config.PropertyMappings.ReplaceAliasesWithPropertyPaths("""Author.Name == "x" && name == "y" """); - input.Should().Be("""Author.Name == "x" && Title == "y" """); + input.Should().Be("""Author.Title == "x" && Title == "y" """); } [Fact] @@ -405,6 +463,7 @@ public void prevented_property_path_on_the_right_side_removes_the_clause() var input = """Title == Author.Name || Directions == "x" """; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.Property(x => x.Author.Name).PreventFilter(); }); @@ -419,6 +478,7 @@ public void unknown_property_in_arithmetic_removes_the_clause_when_unknown_prope var input = """(Nope + 1) > 3 || Age > 100"""; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.AllowUnknownProperties = true; }); @@ -433,6 +493,7 @@ public void unknown_property_on_the_right_side_of_arithmetic_removes_the_clause_ var input = """(Age + 0) > Nope || Title == "a" """; var config = new QueryKitConfiguration(config => { + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; config.AllowUnknownProperties = true; }); diff --git a/QueryKit/Configuration/IQueryKitParseLimits.cs b/QueryKit/Configuration/IQueryKitParseLimits.cs new file mode 100644 index 0000000..32e80f0 --- /dev/null +++ b/QueryKit/Configuration/IQueryKitParseLimits.cs @@ -0,0 +1,12 @@ +namespace QueryKit.Configuration; + +/// +/// The limits that the filter parser applies before it reads a filter. A configuration that does not +/// implement this interface uses and +/// . +/// +public interface IQueryKitParseLimits +{ + int MaxNestingDepth { get; } + int MaxInputLength { get; } +} diff --git a/QueryKit/Configuration/IgnoredClauseBehavior.cs b/QueryKit/Configuration/IgnoredClauseBehavior.cs new file mode 100644 index 0000000..742e1e6 --- /dev/null +++ b/QueryKit/Configuration/IgnoredClauseBehavior.cs @@ -0,0 +1,14 @@ +namespace QueryKit.Configuration; + +/// +/// Controls what the filter parser does with a clause that it ignores: a clause on a property that has +/// PreventFilter, or on an unknown property when AllowUnknownProperties is true. +/// +public enum IgnoredClauseBehavior +{ + /// Default. Replaces the clause with (true == true). Under an OR, the whole OR is then true. + ReplaceWithTrue = 0, + + /// Removes the clause. A logical operator with a removed side keeps only its other side. + Remove = 1 +} diff --git a/QueryKit/Configuration/QueryKitConfiguration.cs b/QueryKit/Configuration/QueryKitConfiguration.cs index 219a8b9..ac2ef03 100644 --- a/QueryKit/Configuration/QueryKitConfiguration.cs +++ b/QueryKit/Configuration/QueryKitConfiguration.cs @@ -33,12 +33,10 @@ public interface IQueryKitConfiguration public string HasOperator { get; set; } public string DoesNotHaveOperator { get; set; } public int? MaxPropertyDepth { get; set; } - public int MaxNestingDepth { get; set; } - public int MaxInputLength { get; set; } public CaseInsensitiveMode CaseInsensitiveComparison { get; set; } } -public class QueryKitConfiguration : IQueryKitConfiguration +public class QueryKitConfiguration : IQueryKitConfiguration, IQueryKitParseLimits { public QueryKitPropertyMappings PropertyMappings { get; } public string EqualsOperator { get; set; } @@ -74,6 +72,8 @@ public class QueryKitConfiguration : IQueryKitConfiguration public int MaxNestingDepth { get; set; } public int MaxInputLength { get; set; } public CaseInsensitiveMode CaseInsensitiveComparison { get; set; } + public bool ParameterizeFilterValues { get; set; } + public IgnoredClauseBehavior IgnoredClauseBehavior { get; set; } public QueryKitConfiguration(Action configureSettings) { @@ -115,5 +115,7 @@ public QueryKitConfiguration(Action configureSettings) MaxNestingDepth = settings.MaxNestingDepth; MaxInputLength = settings.MaxInputLength; CaseInsensitiveComparison = settings.CaseInsensitiveComparison; + ParameterizeFilterValues = settings.ParameterizeFilterValues; + IgnoredClauseBehavior = settings.IgnoredClauseBehavior; } } \ No newline at end of file diff --git a/QueryKit/Configuration/QueryKitSettings.cs b/QueryKit/Configuration/QueryKitSettings.cs index 3d26890..e79ba3c 100644 --- a/QueryKit/Configuration/QueryKitSettings.cs +++ b/QueryKit/Configuration/QueryKitSettings.cs @@ -43,6 +43,18 @@ public class QueryKitSettings public int MaxInputLength { get; set; } = DefaultMaxInputLength; public CaseInsensitiveMode CaseInsensitiveComparison { get; set; } = CaseInsensitiveMode.Lower; + /// + /// When true, filter values are field reads that EF Core sends as SQL parameters. When false (the default), + /// filter values are constants that EF Core writes into the SQL as literals. + /// + public bool ParameterizeFilterValues { get; set; } + + /// + /// What the filter parser does with a clause on a prevented or unknown property. The default is + /// , the same as v1.14.2. + /// + public IgnoredClauseBehavior IgnoredClauseBehavior { get; set; } = IgnoredClauseBehavior.ReplaceWithTrue; + public QueryKitPropertyMapping Property(Expression>? propertySelector) { return PropertyMappings.Property(propertySelector); diff --git a/QueryKit/Expressions/ArithmeticExpression.cs b/QueryKit/Expressions/ArithmeticExpression.cs index c492dfd..799b31d 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 FilterValue.Parameter(Value, ValueType); + return FilterValue.Create(Value, ValueType); } public override Type GetExpressionType(Type entityType) diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 37bac4d..fa65e5f 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -25,6 +25,8 @@ public static Expression> ParseFilter(string input, IQueryKitCo var parameter = Expression.Parameter(typeof(T), "x"); Expression expr; + var parameterizeBefore = FilterValue.Parameterize; + FilterValue.Parameterize = config is QueryKitConfiguration { ParameterizeFilterValues: true }; try { expr = ExprParser(parameter, config).End().Parse(input); @@ -45,6 +47,10 @@ public static Expression> ParseFilter(string input, IQueryKitCo { throw new ParsingException(e); } + finally + { + FilterValue.Parameterize = parameterizeBefore; + } return Expression.Lambda>(expr, parameter); } @@ -64,7 +70,7 @@ private static Expression ReplaceDerivedProperties(Expression expr, IQueryKitCon // call stack or exhausting CPU and memory during parsing. private static void EnsureWithinParseLimits(string input, IQueryKitConfiguration? config) { - var maxLength = config?.MaxInputLength ?? QueryKitSettings.DefaultMaxInputLength; + var maxLength = (config as IQueryKitParseLimits)?.MaxInputLength ?? QueryKitSettings.DefaultMaxInputLength; if (input.Length > maxLength) { throw new QueryKitInputLengthExceededException(input.Length, maxLength); @@ -74,7 +80,7 @@ private static void EnsureWithinParseLimits(string input, IQueryKitConfiguration // quoting styles (plain and raw-string style with 3+ quote marks), so a scanner that tries // to skip "quoted" spans could misjudge one of them and undercount real nesting. Counting // everything can only reject too much, never too little. - var maxDepth = config?.MaxNestingDepth ?? QueryKitSettings.DefaultMaxNestingDepth; + var maxDepth = (config as IQueryKitParseLimits)?.MaxNestingDepth ?? QueryKitSettings.DefaultMaxNestingDepth; var depth = 0; foreach (var c in input) { @@ -453,7 +459,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, FilterValue.Parameter(right, typeof(string))); + Expression constructed = Expression.New(stringCtor, FilterValue.Create(right, typeof(string))); return underlyingType == null ? constructed : Expression.Convert(constructed, leftExpr.Type); } } @@ -474,7 +480,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ { if (op.IsCountOperator() && (int.TryParse(right, out var intVal) || int.TryParse(right, NumberStyles.Integer, CultureInfo.InvariantCulture, out intVal))) { - return FilterValue.Parameter(intVal, typeof(int)); + return FilterValue.Create(intVal, typeof(int)); } targetType = targetType.GetGenericArguments()[0]; return CreateRightExprFromType(targetType, right, op); @@ -536,7 +542,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ dt = DateTime.SpecifyKind(dt, DateTimeKind.Utc); } - return FilterValue.Parameter(dt, rawType); + return FilterValue.Create(dt, rawType); } if (targetType == typeof(DateTimeOffset)) @@ -544,13 +550,13 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ var dtStyle = right.EndsWith("Z") ? DateTimeStyles.AdjustToUniversal : DateTimeStyles.AssumeLocal; var dto = DateTimeOffset.Parse(right, CultureInfo.InvariantCulture, dtStyle); // Npgsql only accepts a DateTimeOffset parameter with offset 0. The UTC value is the same instant. - return FilterValue.Parameter(dto.ToUniversalTime(), rawType); + return FilterValue.Create(dto.ToUniversalTime(), rawType); } if (targetType == typeof(DateOnly)) { var date = DateOnly.Parse(right, CultureInfo.InvariantCulture); - return FilterValue.Parameter(date, rawType); + return FilterValue.Create(date, rawType); } if (targetType == typeof(TimeOnly)) @@ -564,7 +570,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ // 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); + return FilterValue.Create(value, rawType); } if (targetType == typeof(Guid)) @@ -573,16 +579,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 FilterValue.Parameter(right, typeof(string)); + return FilterValue.Create(right, typeof(string)); } // Parse the GUID for direct comparison var guidValue = Guid.Parse(right); - return FilterValue.Parameter(guidValue, typeof(Guid)); + return FilterValue.Create(guidValue, typeof(Guid)); } var convertedValue = conversionFunction(right); - return FilterValue.Parameter(convertedValue, leftExprType); + return FilterValue.Create(convertedValue, leftExprType); } if (rawType.IsEnum || (Nullable.GetUnderlyingType(rawType)?.IsEnum ?? false)) @@ -621,7 +627,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ { throw new InvalidOperationException($"Unsupported value '{right}' for type '{targetType.Name}'"); } - return FilterValue.Parameter(enumValue, rawType); + return FilterValue.Create(enumValue, rawType); } // for some complex derived expressions @@ -634,7 +640,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ if (bool.TryParse(right, out var boolVal)) { - return FilterValue.Parameter(boolVal, typeof(bool)); + return FilterValue.Create(boolVal, typeof(bool)); } } @@ -683,7 +689,7 @@ private static Parser ArithmeticComparisonExprParser(ParameterExp var rightArithmetic = ResolveArithmeticProperties(temp.rightSide, typeof(T), config); if (leftArithmetic == null || rightArithmetic == null) { - return RemovedClauseExpression.Instance; + return IgnoredClause(config); } var leftExpr = leftArithmetic.ToLinqExpression(parameter, typeof(T)); @@ -802,7 +808,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa { if (!temp.reference.CanFilter) { - return RemovedClauseExpression.Instance; + return IgnoredClause(config); } return CreateCustomOperationExpression(parameter, temp.reference.Mapping!, temp.op, temp.right); @@ -810,13 +816,13 @@ private static Parser ComparisonExprParser(ParameterExpression pa if (temp.reference.Kind == PropertyReferenceKind.Unknown) { - return RemovedClauseExpression.Instance; + return IgnoredClause(config); } var leftExpr = CreateLeftExpr(parameter, temp.reference, config); if (leftExpr is RemovedClauseExpression) { - return leftExpr; + return IgnoredClause(config); } if (leftExpr.Type == typeof(Guid) || leftExpr.Type == typeof(Guid?)) @@ -848,7 +854,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa { if (!PropertyResolver.Resolve(parameter.Type, temp.right, config).CanFilter) { - return RemovedClauseExpression.Instance; + return IgnoredClause(config); } var rightPropertyExpr = CreateRightPropertyExpr(parameter, temp.right, config); @@ -1180,8 +1186,8 @@ private static Parser PropertyListComparisonExprParser( : Expression.OrElse(result, comparison); } - // If all properties were filtered out, remove the clause - return result ?? RemovedClauseExpression.Instance; + // If all properties were filtered out, the clause is ignored. v1.14.2 used true here, not true == true. + return result ?? (RemovesIgnoredClauses(config) ? RemovedClauseExpression.Instance : Expression.Constant(true)); }); } @@ -1217,6 +1223,15 @@ private static Parser OrExprParser(ParameterExpression parameter, CombineClauses ); + private static bool RemovesIgnoredClauses(IQueryKitConfiguration? config) + => config is QueryKitConfiguration { IgnoredClauseBehavior: IgnoredClauseBehavior.Remove }; + + // A clause on a prevented or unknown property. By default it becomes true == true, the same as v1.14.2. + private static Expression IgnoredClause(IQueryKitConfiguration? config) + => RemovesIgnoredClauses(config) + ? RemovedClauseExpression.Instance + : Expression.Equal(Expression.Constant(true), Expression.Constant(true)); + // A removed clause has no effect, so the operator keeps only the other side private static Expression CombineClauses(LogicalOperator op, Expression left, Expression right) { diff --git a/QueryKit/FilterValue.cs b/QueryKit/FilterValue.cs index 764e7de..d459436 100644 --- a/QueryKit/FilterValue.cs +++ b/QueryKit/FilterValue.cs @@ -17,10 +17,76 @@ public FilterValue(T value) internal static class FilterValue { - public static Expression Parameter(object? value, Type type) + // The parser sets this for one parse when ParameterizeFilterValues is on. Parsing is synchronous, + // so the value belongs to the thread that parses. + [ThreadStatic] private static bool _parameterize; + + public static bool Parameterize + { + get => _parameterize; + set => _parameterize = value; + } + + // Returns a field read on a FilterValue holder when parameters are on. Otherwise returns the + // same literal expression as v1.14.2. + public static Expression Create(object? value, Type type) { + if (!_parameterize) + { + return Literal(value, type); + } + var holderType = typeof(FilterValue<>).MakeGenericType(type); var holder = Activator.CreateInstance(holderType, value); return Expression.Field(Expression.Constant(holder, holderType), nameof(FilterValue.Value)); } + + // Dates and times are constructor calls, and a nullable enum wraps its constant in a Nullable + // constructor. Every other value is a constant. + private static Expression Literal(object? value, Type type) + { + var underlying = Nullable.GetUnderlyingType(type); + var valueType = underlying ?? type; + + Expression? created = value switch + { + DateTime dt when valueType == typeof(DateTime) + => New(valueType, new[] { typeof(long), typeof(DateTimeKind) }, dt.Ticks, dt.Kind), + DateTimeOffset dto when valueType == typeof(DateTimeOffset) + => New(valueType, new[] { typeof(long), typeof(TimeSpan) }, dto.Ticks, dto.Offset), + DateOnly date when valueType == typeof(DateOnly) + => New(valueType, new[] { typeof(int), typeof(int), typeof(int) }, date.Year, date.Month, date.Day), + TimeOnly time when valueType == typeof(TimeOnly) => NewTimeOnly(time), + not null when underlying is { IsEnum: true } => Expression.Constant(value, underlying), + _ => null + }; + + if (created == null) + { + return Expression.Constant(value, type); + } + + return underlying == null + ? created + : Expression.New(type.GetConstructor(new[] { underlying })!, created); + } + + // The TimeOnly constructor with microseconds needs .NET 7. Without it, the value is a constant. + private static Expression? NewTimeOnly(TimeOnly time) + { + var ctor = typeof(TimeOnly).GetConstructor(new[] { typeof(int), typeof(int), typeof(int), typeof(int), typeof(int) }); + if (ctor == null) + { + return null; + } + + var fractionalTicks = time.Ticks % TimeSpan.TicksPerSecond; + var millisecond = (int)(fractionalTicks / TimeSpan.TicksPerMillisecond); + var microsecond = (int)(fractionalTicks % TimeSpan.TicksPerMillisecond / 10); + return Expression.New(ctor, new object[] { time.Hour, time.Minute, time.Second, millisecond, microsecond } + .Select(arg => Expression.Constant(arg))); + } + + private static NewExpression New(Type type, Type[] parameterTypes, params object[] args) + => Expression.New(type.GetConstructor(parameterTypes)!, args.Select(arg => Expression.Constant(arg))); } diff --git a/QueryKit/Operators/ArithmeticOperator.cs b/QueryKit/Operators/ArithmeticOperator.cs index 90c1f9b..0fa6452 100644 --- a/QueryKit/Operators/ArithmeticOperator.cs +++ b/QueryKit/Operators/ArithmeticOperator.cs @@ -23,6 +23,17 @@ protected ArithmeticOperator(string symbol, int precedence) public static ArithmeticOperator Multiply => new MultiplyOperator(); public static ArithmeticOperator Divide => new DivideOperator(); public static ArithmeticOperator Modulo => new ModuloOperator(); + + [Obsolete("QueryKit does not use FromSymbol. The next major version removes it.")] + public static ArithmeticOperator? FromSymbol(string symbol) => symbol switch + { + "+" => Add, + "-" => Subtract, + "*" => Multiply, + "/" => Divide, + "%" => Modulo, + _ => null + }; } internal class AddOperator : ArithmeticOperator diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 97c959a..a561f50 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -589,7 +589,7 @@ public override Expression GetExpression(Expression left, Expression right, T } values = list; - right = FilterValue.Parameter(list, listType); + right = FilterValue.Create(list, listType); } // Get the Contains method with the correct generic type @@ -611,7 +611,7 @@ public override Expression GetExpression(Expression left, Expression right, T { listType.GetMethod("Add")!.Invoke(caseList, new[] { caseMode == CaseInsensitiveMode.Upper ? value.ToUpper() : value.ToLower() }); } - right = FilterValue.Parameter(caseList, listType); + right = FilterValue.Create(caseList, listType); var caseLeft = Expression.Call(left, typeof(string).GetMethod(caseMethodName, Type.EmptyTypes)!); var containsCall = Expression.Call(right, containsMethod, caseLeft); @@ -834,7 +834,7 @@ public override Expression GetExpression(Expression left, Expression right, T } values = list; - right = FilterValue.Parameter(list, listType); + right = FilterValue.Create(list, listType); } // Get the Contains method with the correct generic type @@ -856,7 +856,7 @@ public override Expression GetExpression(Expression left, Expression right, T { listType.GetMethod("Add")!.Invoke(caseList, new[] { caseMode == CaseInsensitiveMode.Upper ? value.ToUpper() : value.ToLower() }); } - right = FilterValue.Parameter(caseList, listType); + right = FilterValue.Create(caseList, listType); var caseLeft = Expression.Call(left, typeof(string).GetMethod(caseMethodName, Type.EmptyTypes)!); var containsExpression = Expression.Call(right, containsMethod, caseLeft); diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index 216231e..a0aec17 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -136,9 +136,8 @@ 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). A query name - // after a dot is a segment of a nested path, so it is not replaced. - var regex = AliasRegexCache.Get($@"(?