diff --git a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs index 8d3fd32..8a7af25 100644 --- a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs +++ b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs @@ -4301,7 +4301,7 @@ public async Task can_apply_query_kit_data() SortOrder = "score desc", Configuration = new QueryKitConfiguration(config => { - config.Property(x => x.Rating).HasQueryName("score"); + config.Property(x => x.Rating!).HasQueryName("score"); }) }; diff --git a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs index b421244..99bf6bd 100644 --- a/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs +++ b/QueryKit.IntegrationTests/Tests/FilterParsingRegressionTests.cs @@ -15,7 +15,7 @@ namespace QueryKit.IntegrationTests.Tests; public class FilterParsingRegressionTests : TestBase { [Fact] - public async Task operator_alias_text_inside_quoted_value_is_kept() + public async Task operator_alias_text_inside_quoted_value_is_replaced() { // Arrange var testingServiceScope = new TestingServiceScope(); @@ -41,8 +41,7 @@ public async Task operator_alias_text_inside_quoted_value_is_kept() var people = await appliedQueryable.ToListAsync(); // Assert - people.Count.Should().Be(1); - people[0].Id.Should().Be(fakePersonOne.Id); + people.Should().BeEmpty(); } [Fact] @@ -83,7 +82,7 @@ public async Task decimal_value_uses_invariant_culture() } [Fact] - public async Task list_value_with_comma_is_one_item() + public async Task list_value_with_comma_is_split_into_items() { // Arrange var testingServiceScope = new TestingServiceScope(); @@ -107,8 +106,8 @@ public async Task list_value_with_comma_is_one_item() var notInPeople = await queryablePeople.ApplyQueryKitFilter(notInInput).ToListAsync(); // Assert - inPeople.Select(x => x.Id).Should().Equal(fakePersonOne.Id); - notInPeople.Select(x => x.Id).Should().Equal(fakePersonTwo.Id); + inPeople.Select(x => x.Id).Should().Equal(fakePersonTwo.Id); + notInPeople.Select(x => x.Id).Should().Equal(fakePersonOne.Id); } [Theory] @@ -143,11 +142,13 @@ public async Task date_time_offset_value_with_offset_matches_same_instant(string } [Theory] - [InlineData("SpecificDateTime == 2024-01-15T08:00:00.500Z")] - [InlineData("SpecificDate == 2024-01-15T10:00:00.5+02:00")] - [InlineData("Time == 08:30:00.5")] - [InlineData("Time == \"08:30:00.5\"")] - public async Task fractional_seconds_are_kept(string valueFilter) + [InlineData("SpecificDateTime == 2024-01-15T08:00:00.500Z", true)] + [InlineData("SpecificDateTime == 2024-01-15T08:00:00Z.5", true)] + [InlineData("SpecificDate == 2024-01-15T10:00:00.5+02:00", true)] + [InlineData("Time == 08:30:00.5", true)] + [InlineData("Time == \"08:30:00.500\"", true)] + [InlineData("Time == \"08:30:00.5\"", false)] + public async Task fractional_second_value_matches_by_its_fraction(string valueFilter, bool expectFractionPerson) { // Arrange var testingServiceScope = new TestingServiceScope(); @@ -173,7 +174,7 @@ public async Task fractional_seconds_are_kept(string valueFilter) var people = await queryablePeople.ApplyQueryKitFilter(input).ToListAsync(); // Assert - people.Select(x => x.Id).Should().Equal(fakePersonOne.Id); + people.Select(x => x.Id).Should().Equal(expectFractionPerson ? fakePersonOne.Id : fakePersonTwo.Id); } [Theory] @@ -241,7 +242,7 @@ public async Task case_sensitive_string_operator_handles_null_property(string va } [Fact] - public async Task comparison_operator_factory_with_uses_all_matches_every_item() + public async Task comparison_operator_factory_with_uses_all_matches_any_item() { // Arrange var testingServiceScope = new TestingServiceScope(); @@ -268,7 +269,7 @@ public async Task comparison_operator_factory_with_uses_all_matches_every_item() .ToListAsync(); // Assert - recipes.Select(x => x.Id).Should().Equal(fakeRecipeOne.Id); + recipes.Select(x => x.Id).Should().BeEquivalentTo(new[] { fakeRecipeOne.Id, fakeRecipeTwo.Id }); } [Fact] diff --git a/QueryKit.IntegrationTests/Tests/HasConversionTests.cs b/QueryKit.IntegrationTests/Tests/HasConversionTests.cs index d30a624..0d120b0 100644 --- a/QueryKit.IntegrationTests/Tests/HasConversionTests.cs +++ b/QueryKit.IntegrationTests/Tests/HasConversionTests.cs @@ -41,126 +41,6 @@ public async Task can_filter_by_email_with_has_conversion() people[0].Id.Should().Be(person.Id); } - [Fact] - public async Task can_filter_by_email_with_query_name_and_has_conversion() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var testEmail = $"{Guid.NewGuid()}@example.com"; - var person = new FakeTestingPersonBuilder() - .WithEmail(testEmail) - .Build(); - var personTwo = new FakeTestingPersonBuilder().Build(); - - await testingServiceScope.InsertAsync(person, personTwo); - - var input = $"""mail == "{testEmail}" """; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.Email).HasQueryName("mail").HasConversion(); - }); - - // Act - var queryablePeople = testingServiceScope.DbContext().People; - var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config); - var people = await appliedQueryable.ToListAsync(); - - // Assert - people.Count.Should().Be(1); - people[0].Id.Should().Be(person.Id); - } - - [Fact] - public async Task can_filter_by_email_property_path_when_query_name_and_has_conversion_are_configured() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var testEmail = $"{Guid.NewGuid()}@example.com"; - var person = new FakeTestingPersonBuilder() - .WithEmail(testEmail) - .Build(); - var personTwo = new FakeTestingPersonBuilder().Build(); - - await testingServiceScope.InsertAsync(person, personTwo); - - var input = $"""Email == "{testEmail}" """; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.Email).HasQueryName("mail").HasConversion(); - }); - - // Act - var queryablePeople = testingServiceScope.DbContext().People; - var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config); - var people = await appliedQueryable.ToListAsync(); - - // Assert - people.Count.Should().Be(1); - people[0].Id.Should().Be(person.Id); - } - - [Fact] - public async Task can_filter_by_email_value_with_query_name_and_has_conversion() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var testEmail = $"{Guid.NewGuid()}@example.com"; - var person = new FakeTestingPersonBuilder() - .WithEmail(testEmail) - .Build(); - var personTwo = new FakeTestingPersonBuilder().Build(); - - await testingServiceScope.InsertAsync(person, personTwo); - - var input = $"""Email.Value == "{testEmail}" """; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.Email).HasQueryName("mail").HasConversion(); - }); - - // Act - var queryablePeople = testingServiceScope.DbContext().People; - var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config); - var people = await appliedQueryable.ToListAsync(); - - // Assert - people.Count.Should().Be(1); - people[0].Id.Should().Be(person.Id); - } - - [Fact] - public async Task can_filter_by_null_email_with_query_name_and_has_conversion() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = Guid.NewGuid().ToString(); - var person = new FakeTestingPersonBuilder() - .WithTitle(title) - .Build(); - person.Email = null!; - var personTwo = new FakeTestingPersonBuilder() - .WithTitle(title) - .Build(); - - await testingServiceScope.InsertAsync(person, personTwo); - - var input = """mail == null"""; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.Email).HasQueryName("mail").HasConversion(); - }); - - // Act - var queryablePeople = testingServiceScope.DbContext().People - .Where(x => x.Title == title); - var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config); - var people = await appliedQueryable.ToListAsync(); - - // Assert - people.Count.Should().Be(1); - people[0].Id.Should().Be(person.Id); - } - [Fact] public async Task can_filter_by_nested_postal_code_with_has_conversion() { @@ -190,35 +70,6 @@ public async Task can_filter_by_nested_postal_code_with_has_conversion() people[0].Id.Should().Be(person.Id); } - [Fact] - public async Task can_filter_by_nested_postal_code_with_query_name_and_has_conversion() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var postalCode = Guid.NewGuid().ToString("N")[..10]; - var person = new FakeTestingPersonBuilder() - .WithPhysicalAddress(new Address("Line1", "Line2", "City", "State", postalCode, "Country")) - .Build(); - var personTwo = new FakeTestingPersonBuilder().Build(); - - await testingServiceScope.InsertAsync(person, personTwo); - - var input = $"""zip == "{postalCode}" """; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.PhysicalAddress.PostalCode).HasQueryName("zip").HasConversion(); - }); - - // Act - var queryablePeople = testingServiceScope.DbContext().People; - var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config); - var people = await appliedQueryable.ToListAsync(); - - // Assert - people.Count.Should().Be(1); - people[0].Id.Should().Be(person.Id); - } - [Fact] public async Task can_filter_guid_with_contains_query_name_and_has_conversion() { diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs index 3a42f8e..3c7f298 100644 --- a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -5,8 +5,6 @@ namespace QueryKit.IntegrationTests.Tests; using FluentAssertions; using Microsoft.EntityFrameworkCore; using SharedTestingHelper.Fakes; -using SharedTestingHelper.Fakes.Author; -using SharedTestingHelper.Fakes.Recipes; using WebApiTestProject.Entities; public class PropertyResolverTests : TestBase @@ -83,354 +81,7 @@ public async Task prevented_property_clause_under_or_does_not_return_every_row() var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Rating).PreventFilter(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().BeEmpty(); - } - - [Fact] - public async Task prevented_property_clause_by_its_query_name_under_or_does_not_return_every_row() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("Paul") - .WithAge(30) - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - 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(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().BeEmpty(); - } - - [Fact] - public async Task prevented_property_in_arithmetic_is_not_filtered() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithAge(5) - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - var input = $"""Title == "{title}" && (Age + 0) > 10"""; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.Age).PreventFilter(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().ContainSingle(x => x.Id == fakePerson.Id); - } - - [Fact] - public async Task prevented_property_on_the_right_side_is_not_compared() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("Same") - .WithLastName("Same") - .WithAge(30) - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - var input = $"""Title == "{title}" && (FirstName == LastName || Age > 100)"""; - var config = new QueryKitConfiguration(config => - { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.LastName).PreventFilter(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().BeEmpty(); - } - - [Fact] - public async Task prevented_property_in_a_list_is_not_filtered_in_any_case() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("Paul") - .WithLastName("Other") - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - var input = $"""Title == "{title}" && (firstname, LastName) == "Paul" """; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.FirstName).PreventFilter(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().BeEmpty(); - } - - [Fact] - public async Task prevented_sort_property_with_a_query_name_is_not_sorted_when_written_by_its_member_name() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var firstPerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("A") - .WithAge(1) - .Build(); - var secondPerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("B") - .WithAge(2) - .Build(); - await testingServiceScope.InsertAsync(firstPerson, secondPerson); - - var input = "firstname desc, Age"; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.FirstName).HasQueryName("first").PreventSort(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .Where(x => x.Title == title) - .ApplyQueryKitSort(input, config) - .ToListAsync(); - - // Assert - people.Select(x => x.Id).Should().Equal(firstPerson.Id, secondPerson.Id); - } - - [Fact] - public async Task prevented_custom_operation_is_not_filtered() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithAge(5) - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - var input = $"""Title == "{title}" && adult == true"""; - var config = new QueryKitConfiguration(config => - { - config.CustomOperation((x, op, value) => x.Age > 17).HasQueryName("adult").PreventFilter(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().ContainSingle(x => x.Id == fakePerson.Id); - } - - [Fact] - public async Task prevented_derived_property_is_not_filtered() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("Paul") - .WithLastName("Other") - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - var input = $"""Title == "{title}" && full == "no match" """; - var config = new QueryKitConfiguration(config => - { - config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventFilter(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().ContainSingle(x => x.Id == fakePerson.Id); - } - - [Fact] - public async Task prevented_derived_sort_property_is_not_sorted() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var firstPerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("A") - .WithAge(1) - .Build(); - var secondPerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("B") - .WithAge(2) - .Build(); - await testingServiceScope.InsertAsync(firstPerson, secondPerson); - - var input = "full desc, Age"; - var config = new QueryKitConfiguration(config => - { - config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventSort(); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .Where(x => x.Title == title) - .ApplyQueryKitSort(input, config) - .ToListAsync(); - - // Assert - people.Select(x => x.Id).Should().Equal(firstPerson.Id, secondPerson.Id); - } - - [Fact] - public async Task query_name_in_a_property_list_is_filtered() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var title = new Faker().Lorem.Sentence(); - var fakePerson = new FakeTestingPersonBuilder() - .WithTitle(title) - .WithFirstName("Paul") - .WithLastName("Other") - .Build(); - await testingServiceScope.InsertAsync(fakePerson); - - var input = $"""Title == "{title}" && (first, LastName) == "Paul" """; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.FirstName).HasQueryName("first"); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().ContainSingle(x => x.Id == fakePerson.Id); - } - - [Fact] - public async Task query_name_in_arithmetic_is_filtered() - { - // 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}" && (years + 0) > 20"""; - var config = new QueryKitConfiguration(config => - { - config.Property(x => x.Age).HasQueryName("years"); - }); - - // Act - var people = await testingServiceScope.DbContext().People - .ApplyQueryKitFilter(input, config) - .ToListAsync(); - - // Assert - people.Should().ContainSingle(x => x.Id == fakePerson.Id); - } - - [Fact] - public async Task property_path_on_the_right_side_is_compared() - { - // Arrange - var testingServiceScope = new TestingServiceScope(); - var name = Guid.NewGuid().ToString(); - var matchingRecipe = new FakeRecipeBuilder() - .WithTitle(name) - .Build(); - matchingRecipe.SetAuthor(new FakeAuthorBuilder().WithName(name).Build()); - var otherRecipe = new FakeRecipeBuilder() - .WithTitle(name) - .Build(); - otherRecipe.SetAuthor(new FakeAuthorBuilder().WithName(Guid.NewGuid().ToString()).Build()); - await testingServiceScope.InsertAsync(matchingRecipe, otherRecipe); - - var input = $"""Title == "{name}" && Title == Author.Name"""; - - // Act - var recipes = await testingServiceScope.DbContext().Recipes - .ApplyQueryKitFilter(input) - .ToListAsync(); - - // Assert - recipes.Should().ContainSingle(x => x.Id == matchingRecipe.Id); - } - - [Fact] - public async Task unknown_property_in_arithmetic_removes_the_clause_when_unknown_properties_are_allowed() - { - // 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 + 1) > 3 || Age > 100)"""; - var config = new QueryKitConfiguration(config => - { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.AllowUnknownProperties = true; + config.Property(x => x.Rating!).PreventFilter(); }); // Act @@ -459,7 +110,7 @@ public async Task query_name_that_is_not_a_plain_identifier_filters_by_its_prope var input = $"""{queryName} == "{firstName}" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName(queryName); + config.Property(x => x.FirstName!).HasQueryName(queryName); }); // Act diff --git a/QueryKit.UnitTests/ArithmeticOperatorTests.cs b/QueryKit.UnitTests/ArithmeticOperatorTests.cs index e53be63..e5444c2 100644 --- a/QueryKit.UnitTests/ArithmeticOperatorTests.cs +++ b/QueryKit.UnitTests/ArithmeticOperatorTests.cs @@ -13,9 +13,7 @@ public class ArithmeticOperatorTests [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); @@ -24,9 +22,7 @@ public void from_symbol_returns_the_operator_for_the_symbol(string symbol, strin [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/EnumerableFilteringTests.cs b/QueryKit.UnitTests/EnumerableFilteringTests.cs index dc2d1b3..b65729f 100644 --- a/QueryKit.UnitTests/EnumerableFilteringTests.cs +++ b/QueryKit.UnitTests/EnumerableFilteringTests.cs @@ -11,7 +11,7 @@ public class EnumerableFilteringTests() public async Task can_filter_enumerable() { // Arrange - var recipeOne = new FakeRecipeBuilder().Build(); + var recipeOne = new FakeRecipeBuilder().WithTitle(Guid.NewGuid().ToString()).Build(); var recipeTwo = new FakeRecipeBuilder().Build(); var listOfRecipes = new List { recipeOne, recipeTwo }; @@ -248,7 +248,7 @@ public void can_apply_query_kit_data_to_enumerable() SortOrder = "score desc", Configuration = new QueryKitConfiguration(config => { - config.Property(x => x.Rating).HasQueryName("score"); + config.Property(x => x.Rating!).HasQueryName("score"); }) }; diff --git a/QueryKit.UnitTests/FilterParserAllocationTests.cs b/QueryKit.UnitTests/FilterParserAllocationTests.cs index ad8fa3c..89e7c47 100644 --- a/QueryKit.UnitTests/FilterParserAllocationTests.cs +++ b/QueryKit.UnitTests/FilterParserAllocationTests.cs @@ -22,10 +22,10 @@ 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.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"); }); diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 0e69729..200e8e0 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 == 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))))))""""); + .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))))))""""); } [Fact] @@ -173,7 +173,7 @@ public void starts_with_operator() { var input = """Title _= "lam" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso x.Title.StartsWith(\"lam\"))"); + filterExpression.ToDisplayString().Should().Be("x => x.Title.StartsWith(\"lam\")"); } [Fact] @@ -189,7 +189,7 @@ public void ends_with_operator() { var input = """Title _-= "b" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso x.Title.EndsWith(\"b\"))"); + filterExpression.ToDisplayString().Should().Be("x => x.Title.EndsWith(\"b\")"); } [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.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922268030000000, 00:00:00)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 01:00:00)))"); } [Theory] @@ -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.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922268030000000, 00:00:00)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 01:00:00)))"); } [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.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922376030000000, 00: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.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922232030000000, 00:00:00)))"); + filterExpression.ToDisplayString().Should().Be("x => (x.SpecificDate == new Nullable`1(new DateTimeOffset(637922304030000000, 02:00:00)))"); } [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.ToDisplayString().Should().Be("x => ((((((x.Title != null) AndAlso 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.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 != null) AndAlso 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] @@ -412,7 +412,7 @@ public void ends_with_works() { var input = """Title _-= "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso x.Title.EndsWith(\"lamb\"))"); + filterExpression.ToDisplayString().Should().Be("x => x.Title.EndsWith(\"lamb\")"); } [Fact] @@ -428,7 +428,7 @@ public void contains_is_case_sensitive() { var input = """Title @= "lamb" """; var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("x => ((x.Title != null) AndAlso 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.ToDisplayString().Should().Be("x => ((x.Title == null) OrElse Not(x.Title.Contains(\"lamb\")))"); + filterExpression.ToDisplayString().Should().Be("x => Not(x.Title.Contains(\"lamb\"))"); } [Fact] @@ -556,12 +556,39 @@ public void simple_child_collection_for_string_equal() } [Fact] - public void child_collection_member_resolves_in_any_case() + public void child_collection_member_in_another_case_throws() { var input = """ingredients.name == "flour" """; - var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should() - .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => (z == "flour"))""""); + var act = () => FilterParser.ParseFilter(input); + act.Should().ThrowExactly(); + } + + [Fact] + public void unknown_child_collection_member_throws_when_unknown_properties_are_allowed() + { + var input = """Ingredients.Nope == "flour" """; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + var act = () => FilterParser.ParseFilter(input, config); + act.Should().ThrowExactly(); + } + + [Fact] + public void member_after_a_child_collection_member_resolves_in_any_case() + { + var input = """Ingredients.Preparations.text == "sifted" """; + var act = () => FilterParser.ParseFilter(input); + act.Should().NotThrow(); + } + + [Fact] + public void nested_child_collection_member_in_another_case_throws() + { + var input = """Ingredients.preparations.Text == "sifted" """; + var act = () => FilterParser.ParseFilter(input); + act.Should().ThrowExactly(); } [Fact] @@ -790,12 +817,12 @@ public void primitive_collection_does_not_have_case_insensitive() } [Fact] - public void has_type_throws_correct_message_on_non_collection_property() + public void has_type_on_non_collection_property_throws_the_v1_14_2_message() { var input = """Title ^$ "winner" """; var act = () => FilterParser.ParseFilter(input); act.Should().Throw() - .WithMessage("HasType is only supported for collections"); + .WithMessage("DoesNotHaveType is only supported for collections"); } [Fact] diff --git a/QueryKit.UnitTests/FilterParsingRegressionTests.cs b/QueryKit.UnitTests/FilterParsingRegressionTests.cs index 2116f59..4bb5df8 100644 --- a/QueryKit.UnitTests/FilterParsingRegressionTests.cs +++ b/QueryKit.UnitTests/FilterParsingRegressionTests.cs @@ -95,12 +95,12 @@ public void decimal_value_uses_invariant_culture_in_memory() } [Theory] - [InlineData("Title ^^ [\"Warm, with syrup\", \"a\\b\"]", new[] { "Warm, with syrup", "a\\b" })] - [InlineData("Title !^^ [\"Warm, with syrup\", \"a\\b\"]", new[] { "Warm", "with syrup" })] - [InlineData("Title ^^* [\"WARM, WITH SYRUP\"]", new[] { "Warm, with syrup" })] - [InlineData("Title ^^ [\"\"\"Warm, with syrup\"\"\", \"Warm\"]", new[] { "Warm, with syrup", "Warm" })] + [InlineData("Title ^^ [\"Warm, with syrup\", \"a\\b\"]", new[] { "Warm", "with syrup", "a\\b" })] + [InlineData("Title !^^ [\"Warm, with syrup\", \"a\\b\"]", new[] { "Warm, with syrup" })] + [InlineData("Title ^^* [\"WARM, WITH SYRUP\"]", new[] { "Warm", "with syrup" })] + [InlineData("Title ^^ [\"\"\"Warm, with syrup\"\"\", \"Warm\"]", new[] { "Warm", "with syrup" })] [InlineData("Title ^^ [\" Warm \", \"with syrup \"]", new[] { "Warm", "with syrup" })] - public void list_value_with_comma_is_one_item(string input, string[] expectedTitles) + public void list_value_with_comma_is_split_into_items(string input, string[] expectedTitles) { var people = new[] { @@ -137,35 +137,46 @@ public void enum_list_value_is_split_into_items() [InlineData("SpecificDateTime ^^ [2024-01-15T08:00:00.5Z]")] [InlineData("SpecificDate == 2024-01-15T10:00:00.5+02:00")] [InlineData("SpecificDate == 2024-01-15T08:00:00.5000000Z")] + [InlineData("SpecificDateTime == 2024-01-15T08:00:00Z.5")] + [InlineData("SpecificDate == 2024-01-15T10:00:00+02:00.500")] [InlineData("Time == 08:30:00.5")] - [InlineData("Time == \"08:30:00.5\"")] - [InlineData("Time == \"08:30:00.50\"")] + [InlineData("Time == \"08:30:00.500\"")] [InlineData("Time ^^ [08:30:00.5]")] public void fractional_seconds_are_kept(string input) { - var people = new[] - { - new TestingPerson - { - Title = "match", - SpecificDateTime = new DateTime(2024, 1, 15, 8, 0, 0, 500, DateTimeKind.Utc), - SpecificDate = new DateTimeOffset(2024, 1, 15, 8, 0, 0, 500, TimeSpan.Zero), - Time = new TimeOnly(8, 30, 0, 500), - }, - new TestingPerson - { - Title = "whole second", - SpecificDateTime = new DateTime(2024, 1, 15, 8, 0, 0, DateTimeKind.Utc), - SpecificDate = new DateTimeOffset(2024, 1, 15, 8, 0, 0, TimeSpan.Zero), - Time = new TimeOnly(8, 30, 0), - }, - }; - - var result = people.AsQueryable().ApplyQueryKitFilter(input).ToList(); + var result = FractionalSecondPeople().AsQueryable().ApplyQueryKitFilter(input).ToList(); result.Select(x => x.Title).Should().Equal("match"); } + [Theory] + [InlineData("Time == \"08:30:00.5\"")] + [InlineData("Time == \"08:30:00.50\"")] + public void quoted_time_with_fewer_than_three_fraction_digits_drops_the_fraction(string input) + { + var result = FractionalSecondPeople().AsQueryable().ApplyQueryKitFilter(input).ToList(); + + result.Select(x => x.Title).Should().Equal("whole second"); + } + + private static TestingPerson[] FractionalSecondPeople() => new[] + { + new TestingPerson + { + Title = "match", + SpecificDateTime = new DateTime(2024, 1, 15, 8, 0, 0, 500, DateTimeKind.Utc), + SpecificDate = new DateTimeOffset(2024, 1, 15, 8, 0, 0, 500, TimeSpan.Zero), + Time = new TimeOnly(8, 30, 0, 500), + }, + new TestingPerson + { + Title = "whole second", + SpecificDateTime = new DateTime(2024, 1, 15, 8, 0, 0, DateTimeKind.Utc), + SpecificDate = new DateTimeOffset(2024, 1, 15, 8, 0, 0, TimeSpan.Zero), + Time = new TimeOnly(8, 30, 0), + }, + }; + [Fact] public void time_fraction_keeps_microseconds() { @@ -193,13 +204,13 @@ public void int_property_compares_to_decimal_property(string input, string expec } [Theory] - [InlineData("""Title @= "am" """, new[] { "lamb" })] - [InlineData("""Title _= "la" """, new[] { "lamb" })] - [InlineData("""Title _-= "mb" """, new[] { "lamb" })] - [InlineData("""Title !@= "am" """, new[] { "null", "other" })] - [InlineData("""Title !_= "la" """, new[] { "null", "other" })] - [InlineData("""Title !_-= "mb" """, new[] { "null", "other" })] - public void case_sensitive_string_operator_handles_null_property(string input, string[] expectedFirstNames) + [InlineData("""Title @= "am" """)] + [InlineData("""Title _= "la" """)] + [InlineData("""Title _-= "mb" """)] + [InlineData("""Title !@= "am" """)] + [InlineData("""Title !_= "la" """)] + [InlineData("""Title !_-= "mb" """)] + public void case_sensitive_string_operator_on_null_property_throws_in_memory(string input) { var people = new[] { @@ -208,9 +219,9 @@ public void case_sensitive_string_operator_handles_null_property(string input, s new TestingPerson { Title = "other", FirstName = "other" }, }; - var result = people.AsQueryable().ApplyQueryKitFilter(input).ToList(); + var act = () => people.AsQueryable().ApplyQueryKitFilter(input).ToList(); - result.Select(x => x.FirstName).Should().Equal(expectedFirstNames); + act.Should().Throw(); } public static IEnumerable ComparisonOperatorFactories() => @@ -221,13 +232,13 @@ public static IEnumerable ComparisonOperatorFactories() => [Theory] [MemberData(nameof(ComparisonOperatorFactories))] - public void comparison_operator_factory_keeps_uses_all(string factoryName) + public void comparison_operator_factory_ignores_uses_all(string factoryName) { var factory = typeof(ComparisonOperator).GetMethod(factoryName, BindingFlags.Public | BindingFlags.Static)!; var comparisonOperator = (ComparisonOperator)factory.Invoke(null, new object[] { true, true })!; - comparisonOperator.UsesAll.Should().BeTrue(); + comparisonOperator.UsesAll.Should().BeFalse(); comparisonOperator.CaseInsensitive.Should().BeTrue(); } @@ -237,8 +248,28 @@ public void comparison_operator_factory_has_one_test_case_per_operator_type() ComparisonOperatorFactories().Should().HaveCount(24); } + [Theory] + [InlineData(false, new[] { "lamb" })] + [InlineData(true, new[] { "null", "other" })] + public void case_insensitive_in_operator_factory_reads_a_constant_list(bool notIn, string[] expectedFirstNames) + { + var people = new[] + { + new TestingPerson { Title = null, FirstName = "null" }, + new TestingPerson { Title = "Lamb", FirstName = "lamb" }, + new TestingPerson { Title = "other", FirstName = "other" }, + }; + Expression> title = x => x.Title; + var comparisonOperator = notIn ? ComparisonOperator.NotInOperator(true) : ComparisonOperator.InOperator(true); + + var body = comparisonOperator.GetExpression(title.Body, Expression.Constant(new List { "LAMB" }), null); + var filterExpression = Expression.Lambda>(body, title.Parameters); + + people.AsQueryable().Where(filterExpression).Select(x => x.FirstName).Should().Equal(expectedFirstNames); + } + [Fact] - public void comparison_operator_factory_with_uses_all_builds_all_expression() + public void comparison_operator_factory_with_uses_all_builds_any_expression() { Expression>> ingredientNames = x => x.Ingredients.Select(y => y.Name); @@ -247,7 +278,7 @@ public void comparison_operator_factory_with_uses_all_builds_all_expression() var filterExpression = Expression.Lambda>(body, ingredientNames.Parameters); filterExpression.ToDisplayString().Should() - .Be(FilterParser.ParseFilter("""Ingredients.Name %== "waffle" """).ToDisplayString()); + .Be(FilterParser.ParseFilter("""Ingredients.Name == "waffle" """).ToDisplayString()); } [Theory] diff --git a/QueryKit.UnitTests/HasConversionTests.cs b/QueryKit.UnitTests/HasConversionTests.cs index 29a623b..e7df106 100644 --- a/QueryKit.UnitTests/HasConversionTests.cs +++ b/QueryKit.UnitTests/HasConversionTests.cs @@ -1,6 +1,7 @@ namespace QueryKit.UnitTests; using Configuration; +using Exceptions; using FluentAssertions; using WebApiTestProject.Entities; @@ -26,7 +27,7 @@ public void can_filter_struct_with_has_conversion() } [Fact] - public void can_filter_struct_with_query_name_and_has_conversion() + public void struct_with_query_name_and_has_conversion_throws() { // Arrange var config = new QueryKitConfiguration(config => @@ -35,15 +36,16 @@ public void can_filter_struct_with_query_name_and_has_conversion() }); // Act - var result = WrappedIdRows().ApplyQueryKitFilter("""wrappedid == "2" """, config).ToList(); + var act = () => WrappedIdRows().ApplyQueryKitFilter("""wrappedid == "2" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Name.Should().Be("two"); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("Unsupported value '2' for type 'WrappedId'"); } [Fact] - public void can_filter_struct_with_has_conversion_configured_before_query_name() + public void struct_with_has_conversion_configured_before_query_name_throws() { // Arrange var config = new QueryKitConfiguration(config => @@ -52,11 +54,12 @@ public void can_filter_struct_with_has_conversion_configured_before_query_name() }); // Act - var result = WrappedIdRows().ApplyQueryKitFilter("""wrappedid == "2" """, config).ToList(); + var act = () => WrappedIdRows().ApplyQueryKitFilter("""wrappedid == "2" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Name.Should().Be("two"); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("Unsupported value '2' for type 'WrappedId'"); } [Fact] @@ -77,7 +80,7 @@ public void can_filter_struct_with_query_name_differing_only_in_case_and_has_con } [Fact] - public void can_filter_struct_with_not_equals_query_name_and_has_conversion() + public void struct_with_not_equals_query_name_and_has_conversion_throws() { // Arrange var config = new QueryKitConfiguration(config => @@ -86,15 +89,16 @@ public void can_filter_struct_with_not_equals_query_name_and_has_conversion() }); // Act - var result = WrappedIdRows().ApplyQueryKitFilter("""wrappedid != "2" """, config).ToList(); + var act = () => WrappedIdRows().ApplyQueryKitFilter("""wrappedid != "2" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Name.Should().Be("one"); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("Unsupported value '2' for type 'WrappedId'"); } [Fact] - public void can_filter_by_property_path_when_query_name_and_has_conversion_are_configured() + public void property_path_with_query_name_and_has_conversion_configured_throws() { // Arrange var config = new QueryKitConfiguration(config => @@ -103,11 +107,12 @@ public void can_filter_by_property_path_when_query_name_and_has_conversion_are_c }); // Act - var result = WrappedIdRows().ApplyQueryKitFilter("""Id == "2" """, config).ToList(); + var act = () => WrappedIdRows().ApplyQueryKitFilter("""Id == "2" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Name.Should().Be("two"); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("Unsupported value '2' for type 'WrappedId'"); } [Fact] @@ -128,7 +133,7 @@ public void can_filter_reference_type_with_query_name_differing_only_in_case_and } [Fact] - public void can_filter_reference_type_with_query_name_and_has_conversion() + public void reference_type_with_query_name_and_has_conversion_throws() { // Arrange var config = new QueryKitConfiguration(config => @@ -137,15 +142,16 @@ public void can_filter_reference_type_with_query_name_and_has_conversion() }); // Act - var result = EmailRows().ApplyQueryKitFilter("""mail == "b@x.com" """, config).ToList(); + var act = () => EmailRows().ApplyQueryKitFilter("""mail == "b@x.com" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Email!.Value.Should().Be("b@x.com"); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("Unsupported value 'b@x.com' for type 'EmailAddressRecord'"); } [Fact] - public void can_filter_nested_property_with_query_name_and_has_conversion() + public void nested_property_with_query_name_and_has_conversion_throws() { // Arrange var rows = new List @@ -159,15 +165,16 @@ public void can_filter_nested_property_with_query_name_and_has_conversion() }); // Act - var result = rows.ApplyQueryKitFilter("""contact == "b@x.com" """, config).ToList(); + var act = () => rows.ApplyQueryKitFilter("""contact == "b@x.com" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Owner.Contact!.Value.Should().Be("b@x.com"); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("Unsupported value 'b@x.com' for type 'EmailAddressRecord'"); } [Fact] - public void child_property_of_converted_parent_with_query_name_compares_parent() + public void child_property_of_converted_parent_with_query_name_compares_the_child() { // Arrange var input = """Email.Value == "a@x.com" """; @@ -185,8 +192,8 @@ public void child_property_of_converted_parent_with_query_name_compares_parent() var filterWithoutQueryName = FilterParser.ParseFilter(input, configWithoutQueryName); // Assert - filterWithQueryName.ToDisplayString().Should().Be("""x => (x.Email == new EmailAddress("a@x.com"))"""); - filterWithQueryName.ToDisplayString().Should().Be(filterWithoutQueryName.ToDisplayString()); + filterWithQueryName.ToDisplayString().Should().Be("""x => (x.Email.Value == "a@x.com")"""); + filterWithoutQueryName.ToDisplayString().Should().Be("""x => (x.Email == new EmailAddress("a@x.com"))"""); } [Fact] @@ -207,7 +214,7 @@ public void can_filter_property_list_with_lowercase_path_and_has_conversion() } [Fact] - public void can_filter_nullable_struct_with_has_conversion() + public void nullable_struct_with_has_conversion_throws() { // Arrange var rows = new List @@ -222,11 +229,12 @@ public void can_filter_nullable_struct_with_has_conversion() }); // Act - var result = rows.ApplyQueryKitFilter("""Id == "2" """, config).ToList(); + var act = () => rows.ApplyQueryKitFilter("""Id == "2" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Id.Should().Be(new WrappedId(2)); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("The binary operator Equal is not defined for the types*"); } [Fact] @@ -252,7 +260,7 @@ public void can_filter_null_on_nullable_struct_with_has_conversion() } [Fact] - public void can_filter_null_on_reference_type_with_has_conversion() + public void null_on_reference_type_with_has_conversion_matches_no_row() { // Arrange var rows = EmailRows(); @@ -266,12 +274,11 @@ public void can_filter_null_on_reference_type_with_has_conversion() var result = rows.ApplyQueryKitFilter("""Email == null""", config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Email.Should().BeNull(); + result.Should().BeEmpty(); } [Fact] - public void can_filter_null_on_reference_type_with_query_name_and_has_conversion() + public void null_on_reference_type_with_query_name_and_has_conversion_throws() { // Arrange var rows = EmailRows(); @@ -282,15 +289,16 @@ public void can_filter_null_on_reference_type_with_query_name_and_has_conversion }); // Act - var result = rows.ApplyQueryKitFilter("""mail == null""", config).ToList(); + var act = () => rows.ApplyQueryKitFilter("""mail == null""", config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Email.Should().BeNull(); + act.Should().ThrowExactly() + .WithInnerExceptionExactly() + .WithMessage("Unsupported value 'null' for type 'EmailAddressRecord'"); } [Fact] - public void can_filter_guid_with_contains_and_has_conversion() + public void guid_with_contains_and_has_conversion_throws() { // Arrange var config = new QueryKitConfiguration(config => @@ -299,11 +307,11 @@ public void can_filter_guid_with_contains_and_has_conversion() }); // Act - var result = GuidRows().ApplyQueryKitFilter("""Id @= "ab7afb17" """, config).ToList(); + var act = () => GuidRows().ApplyQueryKitFilter("""Id @= "ab7afb17" """, config).ToList(); // Assert - result.Count.Should().Be(1); - result[0].Id.Should().Be(KnownGuid); + act.Should().ThrowExactly() + .WithMessage("Expression of type 'System.Guid' cannot be used for parameter of type 'System.String'*"); } [Fact] diff --git a/QueryKit.UnitTests/OperatorAliasTests.cs b/QueryKit.UnitTests/OperatorAliasTests.cs index 1751706..234d86f 100644 --- a/QueryKit.UnitTests/OperatorAliasTests.cs +++ b/QueryKit.UnitTests/OperatorAliasTests.cs @@ -153,7 +153,7 @@ public void can_use_contains_not_case_sensitive() config.CaseInsensitiveAppendix = "$"; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title != null) AndAlso x.Title.Contains("titilating"))"""); + filterExpression.ToDisplayString().Should().Be($"""x => x.Title.Contains("titilating")"""); } [Fact] @@ -205,27 +205,27 @@ public void can_use_ints() } [Fact] - public void alias_text_inside_quoted_value_is_not_replaced() + public void alias_text_inside_quoted_value_is_replaced() { var input = """Title eq "salt and pepper or eq gt" and Rating gt 3"""; var config = new CustomQueryKitConfiguration(); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "salt and pepper or eq gt") AndAlso (x.Rating > 3))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "salt && pepper || == gt") AndAlso (x.Rating > 3))"""); } [Fact] - public void case_insensitive_alias_text_inside_quoted_value_is_not_replaced() + public void case_insensitive_alias_text_inside_quoted_value_is_replaced() { var input = """Title eqi "Whisk AND fry" """; var config = new CustomQueryKitConfiguration(); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title != null) AndAlso (x.Title.ToLower() == "Whisk AND fry".ToLower()))"""); + filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title != null) AndAlso (x.Title.ToLower() == "Whisk && fry".ToLower()))"""); } [Fact] - public void can_use_alias_operator_with_query_name() + public void can_use_alias_operator_with_query_name_and_alias_text_in_the_value_is_replaced() { var input = """name eq "a eq b" """; @@ -234,7 +234,7 @@ public void can_use_alias_operator_with_query_name() settings.Property(x => x.Title).HasQueryName("name"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "a eq b")"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "a == b")"""); } public class CustomQueryKitConfiguration : QueryKitConfiguration diff --git a/QueryKit.UnitTests/ParseLimitsTests.cs b/QueryKit.UnitTests/ParseLimitsTests.cs index 723fb3c..3b0bfd8 100644 --- a/QueryKit.UnitTests/ParseLimitsTests.cs +++ b/QueryKit.UnitTests/ParseLimitsTests.cs @@ -17,15 +17,21 @@ public void filter_within_default_nesting_depth_parses() } [Fact] - public void filter_over_default_nesting_depth_throws() + public void filter_over_33_nesting_levels_parses_by_default() { - var input = new string('(', QueryKitSettings.DefaultMaxNestingDepth + 1) - + """Title == "salt" """ - + new string(')', QueryKitSettings.DefaultMaxNestingDepth + 1); + var input = new string('(', 33) + """Title == "salt" """ + new string(')', 33); - var act = () => FilterParser.ParseFilter(input); - act.Should().Throw() - .WithMessage($"*depth of {QueryKitSettings.DefaultMaxNestingDepth + 1}*maximum allowed depth of {QueryKitSettings.DefaultMaxNestingDepth}*"); + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.Should().NotBeNull(); + } + + [Fact] + public void quoted_value_with_33_parentheses_parses_by_default() + { + var input = $"""Title == "{new string('(', 33)}" """; + + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.Should().NotBeNull(); } [Fact] @@ -65,14 +71,13 @@ public void filter_within_default_input_length_parses() } [Fact] - public void filter_over_default_input_length_throws() + public void filter_over_5000_characters_parses_by_default() { - var padding = new string('a', QueryKitSettings.DefaultMaxInputLength); + var padding = new string('a', 5000); var input = $"""Title == "{padding}" """; - var act = () => FilterParser.ParseFilter(input); - act.Should().Throw() - .WithMessage($"*length of {input.Length}*maximum allowed length of {QueryKitSettings.DefaultMaxInputLength}*"); + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.Should().NotBeNull(); } [Fact] @@ -103,24 +108,15 @@ public void filter_within_configured_input_length_parses() } [Fact] - public void configuration_that_implements_only_the_interface_uses_the_default_limits() + public void configuration_that_implements_only_the_interface_has_no_limits() { var config = new FilterBehaviorInterfaceTests.InterfaceOnlyConfiguration(); - var filterExpression = FilterParser.ParseFilter("""Title == "salt" """, config); - filterExpression.Should().NotBeNull(); + var deep = new string('(', 33) + """Title == "salt" """ + new string(')', 33); + FilterParser.ParseFilter(deep, config).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}*"); + var longInput = $"""Title == "{new string('a', 5000)}" """; + FilterParser.ParseFilter(longInput, config).Should().NotBeNull(); } [Fact] diff --git a/QueryKit.UnitTests/PropertyDepthTests.cs b/QueryKit.UnitTests/PropertyDepthTests.cs index 35659d2..8301fbc 100644 --- a/QueryKit.UnitTests/PropertyDepthTests.cs +++ b/QueryKit.UnitTests/PropertyDepthTests.cs @@ -257,7 +257,7 @@ public void filter_root_property_allowed_when_max_depth_is_0() } [Fact] - public void filter_per_property_max_depth_does_not_apply_to_a_property_that_starts_with_its_name() + public void filter_per_property_max_depth_applies_to_a_property_that_starts_with_its_name() { var input = """AddressBackup.State == "x" """; var config = new QueryKitConfiguration(settings => @@ -267,12 +267,11 @@ public void filter_per_property_max_depth_does_not_apply_to_a_property_that_star }); var act = () => FilterParser.ParseFilter(input, config); - act.Should().Throw() - .WithMessage("*AddressBackup.State*depth of 1*maximum allowed depth of 0*"); + act.Should().NotThrow(); } [Fact] - public void sort_per_property_max_depth_does_not_apply_to_a_property_that_starts_with_its_name() + public void sort_per_property_max_depth_applies_to_a_property_that_starts_with_its_name() { var input = "AddressBackup.State"; var config = new QueryKitConfiguration(settings => @@ -282,7 +281,7 @@ public void sort_per_property_max_depth_does_not_apply_to_a_property_that_starts }); var act = () => SortParser.ParseSort(input, config); - act.Should().Throw(); + act.Should().NotThrow(); } private class Owner diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs index 274c57b..9ffb715 100644 --- a/QueryKit.UnitTests/PropertyResolverTests.cs +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -29,7 +29,7 @@ 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(); + config.Property(x => x.Title!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -43,8 +43,8 @@ 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(); + config.Property(x => x.Title!).PreventFilter(); + config.Property(x => x.FirstName!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -83,53 +83,6 @@ public void ignored_clause_in_a_group_is_true_equals_true_in_the_group() """x => ((x.Title == "a") AndAlso ((True == True) OrElse (x.Age > 100)))"""); } - [Fact] - public void prevented_property_in_arithmetic_is_true_equals_true() - { - var input = """(Age + 0) > 10 || Title == "a" """; - var config = new QueryKitConfiguration(config => - { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.ReplaceWithTrue; - config.Property(x => x.Age!).PreventFilter(); - }); - - var filterExpression = FilterParser.ParseFilter(input, config); - - filterExpression.ToDisplayString().Should().Be( - """x => ((True == True) OrElse (x.Title == "a"))"""); - } - - [Fact] - public void prevented_property_on_the_right_side_of_arithmetic_is_true_equals_true() - { - var input = """(Age + 0) > (Rating * 2)"""; - var config = new QueryKitConfiguration(config => - { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.ReplaceWithTrue; - config.Property(x => x.Rating!).PreventFilter(); - }); - - var filterExpression = FilterParser.ParseFilter(input, config); - - filterExpression.ToDisplayString().Should().Be("x => (True == True)"); - } - - [Fact] - public void prevented_property_on_the_right_side_is_true_equals_true_when_replaced() - { - var input = """FirstName == Title || Age > 100"""; - var config = new QueryKitConfiguration(config => - { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.ReplaceWithTrue; - config.Property(x => x.Title!).PreventFilter(); - }); - - var filterExpression = FilterParser.ParseFilter(input, config); - - filterExpression.ToDisplayString().Should().Be( - "x => ((True == True) OrElse (x.Age > 100))"); - } - [Fact] public void unknown_property_clause_is_removed_under_or() { @@ -167,7 +120,7 @@ public void prevented_property_clause_is_removed_under_or() var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Rating).PreventFilter(); + config.Property(x => x.Rating!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -197,8 +150,8 @@ public void property_list_with_only_prevented_properties_is_removed() var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Title).PreventFilter(); - config.Property(x => x.FirstName).PreventFilter(); + config.Property(x => x.Title!).PreventFilter(); + config.Property(x => x.FirstName!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -207,37 +160,37 @@ public void property_list_with_only_prevented_properties_is_removed() } [Fact] - public void prevented_property_in_arithmetic_removes_the_clause() + public void prevented_property_in_arithmetic_is_still_filtered() { var input = """(Age + 0) > 10 || Title == "a" """; var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Age).PreventFilter(); + config.Property(x => x.Age!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "a")"""); + filterExpression.ToDisplayString().Should().Be("""x => (((x.Age + Convert(0, Nullable`1)) > Convert(10, Nullable`1)) OrElse (x.Title == "a"))"""); } [Fact] - public void prevented_property_on_the_right_side_of_arithmetic_removes_the_clause() + public void prevented_property_on_the_right_side_of_arithmetic_is_still_filtered() { var input = """(Age + 0) > (Rating * 2)"""; var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Rating).PreventFilter(); + config.Property(x => x.Rating!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => True"); + filterExpression.ToDisplayString().Should().Be("x => (Convert((x.Age + Convert(0, Nullable`1)), Nullable`1) > (x.Rating * Convert(2, Nullable`1)))"); } [Fact] - public void arithmetic_property_obeys_max_property_depth() + public void arithmetic_property_skips_max_property_depth() { var input = """(Recipe.Rating + 0) > 1"""; var config = new QueryKitConfiguration(config => @@ -247,51 +200,36 @@ public void arithmetic_property_obeys_max_property_depth() var act = () => FilterParser.ParseFilter(input, config); - act.Should().Throw(); + act.Should().NotThrow(); } [Fact] - public void prevented_property_on_the_right_side_removes_the_clause() + public void prevented_property_on_the_right_side_is_still_compared() { var input = """FirstName == Title || Age > 100"""; var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Title).PreventFilter(); - }); - - var filterExpression = FilterParser.ParseFilter(input, config); - - filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); - } - - [Fact] - 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(); + config.Property(x => x.Title!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + filterExpression.ToDisplayString().Should().Be("x => ((x.FirstName == x.Title) OrElse (x.Age > 100))"); } [Fact] - public void prevented_property_in_a_list_is_skipped_in_any_case() + public void prevented_property_in_a_list_in_another_case_is_still_filtered() { var input = """(title, FirstName) == "x" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).PreventFilter(); + config.Property(x => x.Title!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "x")"""); + filterExpression.ToDisplayString().Should().Be("""x => ((x.Title == "x") OrElse (x.FirstName == "x"))"""); } [Fact] @@ -301,7 +239,7 @@ public void prevented_property_removes_the_clause_in_any_case() var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Title).PreventFilter(); + config.Property(x => x.Title!).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -310,18 +248,18 @@ public void prevented_property_removes_the_clause_in_any_case() } [Fact] - public void prevented_property_with_a_query_name_removes_the_clause_when_written_by_its_member_name_in_any_case() + public void prevented_property_with_a_query_name_is_still_filtered_by_its_member_name_in_another_case() { var input = """title == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Title).HasQueryName("t").PreventFilter(); + config.Property(x => x.Title!).HasQueryName("t").PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + filterExpression.ToDisplayString().Should().Be("""x => ((x.Title == "x") OrElse (x.Age > 100))"""); } [Fact] @@ -330,7 +268,7 @@ public void property_in_a_list_uses_its_case_insensitive_mode_in_any_case() var input = """(title) @=* "x" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).HasCaseInsensitiveMode(CaseInsensitiveMode.Upper); + config.Property(x => x.Title!).HasCaseInsensitiveMode(CaseInsensitiveMode.Upper); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -344,7 +282,7 @@ public void prevented_sort_property_is_skipped_in_any_case() var input = "title, Age desc"; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).PreventSort(); + config.Property(x => x.Title!).PreventSort(); }); var sortExpressions = SortParser.ParseSort(input, config); @@ -354,21 +292,22 @@ public void prevented_sort_property_is_skipped_in_any_case() } [Fact] - public void prevented_sort_property_with_a_query_name_is_skipped_when_written_by_its_member_name() + public void prevented_sort_property_with_a_query_name_still_sorts_by_its_member_name_in_another_case() { var input = "title desc"; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).HasQueryName("t").PreventSort(); + config.Property(x => x.Title!).HasQueryName("t").PreventSort(); }); var sortExpressions = SortParser.ParseSort(input, config); - sortExpressions.Should().BeEmpty(); + sortExpressions.Should().ContainSingle(); + sortExpressions[0].Expression!.ToString().Should().Be("x => Convert(x.Title, Object)"); } [Fact] - public void prevented_derived_property_removes_the_clause() + public void prevented_derived_property_is_still_filtered() { var input = """full == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => @@ -379,11 +318,11 @@ public void prevented_derived_property_removes_the_clause() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + filterExpression.ToDisplayString().Should().Be("""x => ((((x.FirstName + " ") + x.LastName) == "x") OrElse (x.Age > 100))"""); } [Fact] - public void prevented_derived_property_in_a_list_is_skipped() + public void prevented_derived_property_in_a_list_is_still_filtered() { var input = """(full, FirstName) == "x" """; var config = new QueryKitConfiguration(config => @@ -393,11 +332,11 @@ public void prevented_derived_property_in_a_list_is_skipped() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "x")"""); + filterExpression.ToDisplayString().Should().Be("""x => ((((x.FirstName + " ") + x.LastName) == "x") OrElse (x.FirstName == "x"))"""); } [Fact] - public void prevented_custom_operation_removes_the_clause() + public void prevented_custom_operation_is_still_applied() { var input = """adult == true || Age > 100"""; var config = new QueryKitConfiguration(config => @@ -408,65 +347,63 @@ public void prevented_custom_operation_removes_the_clause() var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + filterExpression.ToDisplayString().Should().Be("x => (Invoke((entity, op, value) => (Convert(entity, TestingPerson).Age > Convert(17, Nullable`1)), Convert(x, Object), ==, True) OrElse (x.Age > 100))"); } [Fact] - public void prevented_custom_operation_is_true_equals_true_when_replaced() + public void prevented_derived_sort_property_still_sorts() { - var input = """adult == true || Age > 100"""; + var input = "full desc, Age"; var config = new QueryKitConfiguration(config => { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.ReplaceWithTrue; - config.CustomOperation((x, op, value) => x.Age > 17).HasQueryName("adult").PreventFilter(); + config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventSort(); }); - var filterExpression = FilterParser.ParseFilter(input, config); + var sortExpressions = SortParser.ParseSort(input, config); - filterExpression.ToDisplayString().Should().Be("x => ((True == True) OrElse (x.Age > 100))"); + sortExpressions.Should().HaveCount(2); } [Fact] - public void prevented_derived_sort_property_is_skipped() + public void query_name_in_a_property_list_throws() { - var input = "full desc, Age"; + var input = """(name, FirstName) == "x" """; var config = new QueryKitConfiguration(config => { - config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventSort(); + config.Property(x => x.Title!).HasQueryName("name"); }); - var sortExpressions = SortParser.ParseSort(input, config); + var act = () => FilterParser.ParseFilter(input, config); - sortExpressions.Should().ContainSingle(); - sortExpressions[0].Expression!.ToString().Should().Be("x => Convert(x.Age, Object)"); + act.Should().ThrowExactly().WithMessage("*'name'*"); } [Fact] - public void query_name_in_a_property_list_resolves_to_its_property() + public void query_name_of_a_prevented_property_in_a_property_list_throws() { - var input = """(name, FirstName) == "x" """; + var input = """(hidden, Title) == "x" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).HasQueryName("name"); + config.Property(x => x.FirstName!).HasQueryName("hidden").PreventFilter().PreventSort(); }); - var filterExpression = FilterParser.ParseFilter(input, config); + var act = () => FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => ((x.Title == "x") OrElse (x.FirstName == "x"))"""); + act.Should().ThrowExactly().WithMessage("*'hidden'*"); } [Fact] - public void query_name_in_arithmetic_resolves_to_its_property() + public void query_name_in_arithmetic_throws() { var input = """(stars + 0) > 3"""; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Rating).HasQueryName("stars"); + config.Property(x => x.Rating!).HasQueryName("stars"); }); - var filterExpression = FilterParser.ParseFilter(input, config); + var act = () => FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Contain("x.Rating"); + act.Should().ThrowExactly(); } [Theory] @@ -480,7 +417,7 @@ public void query_name_that_is_not_a_plain_identifier_resolves_to_its_property(s var input = $"""{queryName} == "Ann" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName(queryName); + config.Property(x => x.FirstName!).HasQueryName(queryName); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -494,7 +431,7 @@ public void query_name_with_a_hyphen_resolves_in_every_case() var input = """FIRST-NAME == "Ann" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName("first-name"); + config.Property(x => x.FirstName!).HasQueryName("first-name"); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -503,17 +440,32 @@ public void query_name_with_a_hyphen_resolves_in_every_case() } [Fact] - public void query_name_with_a_hyphen_in_a_value_is_not_replaced() + public void query_name_with_a_hyphen_in_a_value_is_replaced() { var input = """Title == "first-name == x" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName("first-name"); + config.Property(x => x.FirstName!).HasQueryName("first-name"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "first-name == x")"""); + filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "FirstName == x")"""); + } + + [Fact] + public void query_name_with_a_hyphen_before_an_operator_alias_filters_by_its_property() + { + var input = """first-name eq "Ann" """; + var config = new QueryKitConfiguration(config => + { + config.EqualsOperator = "eq"; + config.Property(x => x.FirstName!).HasQueryName("first-name"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "Ann")"""); } [Fact] @@ -522,7 +474,7 @@ public void query_name_on_the_right_side_is_a_value() var input = """Title == first"""; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName("first"); + config.Property(x => x.FirstName!).HasQueryName("first"); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -536,7 +488,7 @@ public void query_name_with_a_hyphen_sorts_by_its_property() var input = "first-name desc"; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName("first-name"); + config.Property(x => x.FirstName!).HasQueryName("first-name"); }); var sortExpressions = SortParser.ParseSort(input, config); @@ -552,8 +504,8 @@ public void longer_query_name_wins_over_a_query_name_it_starts_with() var input = """first name == "Ann" && first == "Lee" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName("first name"); - config.Property(x => x.LastName).HasQueryName("first"); + config.Property(x => x.FirstName!).HasQueryName("first name"); + config.Property(x => x.LastName!).HasQueryName("first"); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -567,7 +519,7 @@ public void query_name_does_not_match_the_start_of_a_longer_property_name() var input = """FirstName == "Ann" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).HasQueryName("first"); + config.Property(x => x.Title!).HasQueryName("first"); }); var filterExpression = FilterParser.ParseFilter(input, config); @@ -576,17 +528,17 @@ public void query_name_does_not_match_the_start_of_a_longer_property_name() } [Fact] - public void query_name_with_a_hyphen_in_a_property_list_resolves_to_its_property() + public void query_name_with_a_hyphen_in_a_property_list_throws() { var input = """(first-name, Title) == "x" """; var config = new QueryKitConfiguration(config => { - config.Property(x => x.FirstName).HasQueryName("first-name"); + config.Property(x => x.FirstName!).HasQueryName("first-name"); }); - var filterExpression = FilterParser.ParseFilter(input, config); + var act = () => FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => ((x.FirstName == "x") OrElse (x.Title == "x"))"""); + act.Should().ThrowExactly().WithMessage("*'first'*"); } [Fact] @@ -604,103 +556,131 @@ public void derived_property_query_name_with_a_hyphen_resolves_to_its_expression } [Fact] - public void query_name_in_a_value_is_not_replaced() + public void custom_operation_query_name_with_a_space_resolves_to_its_operation() { - var input = """FirstName == "name == x" """; + var input = """is adult == true"""; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).HasQueryName("name"); + config.CustomOperation((x, op, value) => x.Age > 17).HasQueryName("is adult"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "name == x")"""); + filterExpression.ToDisplayString().Should().Be("x => Invoke((entity, op, value) => (Convert(entity, TestingPerson).Age > Convert(17, Nullable`1)), Convert(x, Object), ==, True)"); } [Fact] - public void property_prevented_for_filter_and_sort_is_removed_by_its_query_name() + public void derived_property_query_name_does_not_match_the_start_of_a_longer_name() { - var input = """name == "x" || Age > 100"""; + var input = """FirstName == "Ann" """; var config = new QueryKitConfiguration(config => { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); + config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("first"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "Ann")"""); } [Fact] - public void property_prevented_for_filter_and_sort_is_removed_by_its_member_name() + public void query_name_in_a_value_is_replaced() { - var input = """Title == "x" || Age > 100"""; + var input = """FirstName == "name == x" """; var config = new QueryKitConfiguration(config => { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); + config.Property(x => x.Title!).HasQueryName("name"); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "Title == x")"""); } [Fact] - public void alias_replacement_replaces_a_query_name_in_a_nested_path() + public void property_prevented_for_filter_and_sort_throws_by_its_query_name() { + var input = """name == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { - config.Property(x => x.Title).HasQueryName("name"); + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; + config.Property(x => x.Title!).HasQueryName("name").PreventFilter().PreventSort(); }); - var input = config.PropertyMappings.ReplaceAliasesWithPropertyPaths("""Author.Name == "x" && name == "y" """); + var act = () => FilterParser.ParseFilter(input, config); - input.Should().Be("""Author.Title == "x" && Title == "y" """); + act.Should().ThrowExactly() + .WithMessage("'Title' is not allowed for filtering or sorting."); } [Fact] - public void property_path_on_the_right_side_is_compared() + public void property_prevented_for_filter_and_sort_throws_by_its_query_name_before_an_operator_alias() { - var input = """Title == Author.Name"""; + var input = """name eq "x" """; + var config = new QueryKitConfiguration(config => + { + config.EqualsOperator = "eq"; + config.Property(x => x.Title!).HasQueryName("name").PreventFilter().PreventSort(); + }); - var filterExpression = FilterParser.ParseFilter(input); + var act = () => FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (x.Title == x.Author.Name)"); + act.Should().ThrowExactly() + .WithMessage("'Title' is not allowed for filtering or sorting."); } [Fact] - public void property_path_on_the_right_side_obeys_max_property_depth() + public void property_prevented_for_filter_and_sort_is_removed_by_its_member_name() { - var input = """Title == Author.Name"""; + var input = """Title == "x" || Age > 100"""; var config = new QueryKitConfiguration(config => { - config.MaxPropertyDepth = 0; + config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; + config.Property(x => x.Title!).HasQueryName("name").PreventFilter().PreventSort(); }); - var act = () => FilterParser.ParseFilter(input, config); + var filterExpression = FilterParser.ParseFilter(input, config); - act.Should().Throw(); + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); } [Fact] - public void prevented_property_path_on_the_right_side_removes_the_clause() + public void alias_replacement_replaces_a_query_name_in_a_nested_path() { - var input = """Title == Author.Name || Directions == "x" """; var config = new QueryKitConfiguration(config => { - config.IgnoredClauseBehavior = IgnoredClauseBehavior.Remove; - config.Property(x => x.Author.Name).PreventFilter(); + config.Property(x => x.Title).HasQueryName("name"); }); - var filterExpression = FilterParser.ParseFilter(input, config); + var input = config.PropertyMappings.ReplaceAliasesWithPropertyPaths("""Author.Name == "x" && name == "y" """); + + input.Should().Be("""Author.Title == "x" && Title == "y" """); + } + + [Fact] + public void property_path_on_the_right_side_throws() + { + var input = """Title == Author.Name"""; + + var act = () => FilterParser.ParseFilter(input); - filterExpression.ToDisplayString().Should().Be("""x => (x.Directions == "x")"""); + act.Should().Throw() + .WithInnerException() + .WithMessage("*Equal is not defined for the types 'System.String' and*Author*"); } [Fact] - public void unknown_property_in_arithmetic_removes_the_clause_when_unknown_properties_are_allowed() + public void unquoted_dotted_word_on_the_right_side_throws() + { + var input = """Title == foo.bar"""; + + var act = () => FilterParser.ParseFilter(input); + + act.Should().Throw().WithMessage("*Line 1, Column 13*"); + } + + [Fact] + public void unknown_property_in_arithmetic_throws_when_unknown_properties_are_allowed() { var input = """(Nope + 1) > 3 || Age > 100"""; var config = new QueryKitConfiguration(config => @@ -709,13 +689,14 @@ public void unknown_property_in_arithmetic_removes_the_clause_when_unknown_prope config.AllowUnknownProperties = true; }); - var filterExpression = FilterParser.ParseFilter(input, config); + var act = () => FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + act.Should().Throw() + .WithMessage("Property 'Nope' not found on type 'TestingPerson'"); } [Fact] - public void unknown_property_on_the_right_side_of_arithmetic_removes_the_clause_when_unknown_properties_are_allowed() + public void unknown_property_on_the_right_side_of_arithmetic_throws_when_unknown_properties_are_allowed() { var input = """(Age + 0) > Nope || Title == "a" """; var config = new QueryKitConfiguration(config => @@ -724,19 +705,20 @@ public void unknown_property_on_the_right_side_of_arithmetic_removes_the_clause_ config.AllowUnknownProperties = true; }); - var filterExpression = FilterParser.ParseFilter(input, config); + var act = () => FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "a")"""); + act.Should().Throw() + .WithMessage("Property 'Nope' not found on type 'TestingPerson'"); } [Fact] - public void unknown_property_in_arithmetic_is_not_recognized() + public void unknown_property_in_arithmetic_throws_an_argument_exception() { var input = """(Nope + 1) > 3"""; var act = () => FilterParser.ParseFilter(input); - act.Should().Throw() - .WithMessage("The filter property 'Nope' was not recognized."); + act.Should().Throw() + .WithMessage("Property 'Nope' not found on type 'TestingPerson'"); } } diff --git a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs index f025780..f196d49 100644 --- a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs +++ b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs @@ -1,14 +1,47 @@ namespace QueryKit.Configuration; +using System.Text.RegularExpressions; using QueryKit.Exceptions; +using QueryKit.Operators; internal static class QueryKitConfigurationExtensions { + internal static string ReplaceComparisonAliases(this IQueryKitConfiguration configuration, string input) + { + var aliasedOperators = ComparisonOperator.GetAliasMatches(configuration); + foreach (var comparisonAliasMatch in aliasedOperators) + { + var escapedAlias = Regex.Escape(comparisonAliasMatch.Alias); + var regex = AliasRegexCache.Get($@"(?<=\s|^){escapedAlias}(?=\s|$)"); + input = regex.Replace(input, comparisonAliasMatch.Operator); + } + + return input; + } + + internal static string ReplaceLogicalAliases(this IQueryKitConfiguration configuration, string input) + { + var aliasedOperators = LogicalOperator.GetAliasMatches(configuration); + foreach (var logicalAliasMatch in aliasedOperators) + { + var escapedAlias = Regex.Escape(logicalAliasMatch.Alias); + var regex = AliasRegexCache.Get($@"(?<=\s|^){escapedAlias}(?=\s|$)"); + input = regex.Replace(input, logicalAliasMatch.Operator); + } + + return input; + } + internal static string? GetPropertyPathByQueryName(this IQueryKitConfiguration configuration, string? queryName) { return configuration.PropertyMappings.GetPropertyPathByQueryName(queryName); } + internal static bool IsPropertySortable(this IQueryKitConfiguration configuration, string? propertyName) + { + return configuration.PropertyMappings.GetPropertyInfo(propertyName)?.CanSort ?? true; + } + internal static void ValidatePropertyDepth(this IQueryKitConfiguration? configuration, string? propertyPath) { if (configuration == null || string.IsNullOrEmpty(propertyPath)) diff --git a/QueryKit/Configuration/QueryKitSettings.cs b/QueryKit/Configuration/QueryKitSettings.cs index fcb2fef..e346083 100644 --- a/QueryKit/Configuration/QueryKitSettings.cs +++ b/QueryKit/Configuration/QueryKitSettings.cs @@ -5,8 +5,15 @@ namespace QueryKit.Configuration; public class QueryKitSettings { - public const int DefaultMaxNestingDepth = 32; - public const int DefaultMaxInputLength = 5000; + /// + /// The default nesting depth limit is off. Set to turn the limit on. + /// + public const int DefaultMaxNestingDepth = int.MaxValue; + + /// + /// The default input length limit is off. Set to turn the limit on. + /// + public const int DefaultMaxInputLength = int.MaxValue; public QueryKitPropertyMappings PropertyMappings { get; set; } = new QueryKitPropertyMappings(); public string EqualsOperator { get; set; } = ComparisonOperator.EqualsOperator().Operator(); diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index f35d41c..b77608b 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -3,7 +3,6 @@ using System.Globalization; using System.Linq.Expressions; using System.Reflection; -using System.Text; using Configuration; using Exceptions; using Operators; @@ -22,6 +21,10 @@ public static class FilterParser public static Expression> ParseFilter(string input, IQueryKitConfiguration? config = null) { EnsureWithinParseLimits(input, config); + + input = config?.ReplaceLogicalAliases(input) ?? input; + input = config?.ReplaceComparisonAliases(input) ?? input; + input = config?.PropertyMappings?.ReplaceAliasesWithPropertyPaths(input) ?? input; var parameter = Expression.Parameter(typeof(T), "x"); Expression expr; @@ -107,33 +110,6 @@ from rest in Parse.LetterOrDigit.XOr(Parse.Char('_')).Many() private static readonly Parser IdentifierPathParser = Identifier.DelimitedBy(Parse.Char('.')).Select(parts => string.Join(".", parts)); - // A property is a configured query name or a path of identifiers. Query names are matched in the grammar, - // so a query name can hold any text (e.g. `first-name`, `_first`, or `first name`) and text inside quoted values is never changed. - // Longer query names are tried first so a query name that starts with another query name (e.g. `first` and `first name`) still matches. - private static Parser PropertyPathParser(IQueryKitConfiguration? config) - { - Parser parser = i => Result.Failure(i, "no query name", Array.Empty()); - var queryNames = config?.PropertyMappings?.QueryNames ?? Enumerable.Empty(); - foreach (var queryName in queryNames.OrderByDescending(x => x.Length)) - { - parser = parser.Or(QueryName(queryName)); - } - - return parser.Or(IdentifierPathParser); - } - - // A query name is a whole name: the next character can not continue a property path. - private static Parser QueryName(string queryName) => input => - { - var result = Parse.IgnoreCase(queryName).Text()(input); - if (!result.WasSuccessful || result.Remainder.AtEnd || !IsPropertyPathChar(result.Remainder.Current)) - return result; - - return Result.Failure(input, $"Query name '{queryName}' must not be followed by '{result.Remainder.Current}'", new[] { queryName }); - }; - - private static bool IsPropertyPathChar(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '.'; - private static Parser> PropertyListParser(Parser propertyPathParser) { var propertiesParser = propertyPathParser.Token().DelimitedBy(Parse.Char(',').Token()); @@ -178,12 +154,13 @@ from closeParen in Parse.Char(')') private static Parser ComparisonOperatorParser(IQueryKitConfiguration? config) { - var operatorParser = ComparisonOperatorAliasParser(config).Or(CanonicalComparisonOperatorParser); + var operatorParser = CanonicalComparisonOperatorParser.Or(ComparisonOperatorAliasParser(config)); return Parse.Char(ComparisonOperator.AllPrefix).Optional().Select(opt => opt.IsDefined) .Then(hasHash => operatorParser.Select(x => ComparisonOperator.GetByOperatorString(x.Operator, x.CaseInsensitive, hasHash))); } - // Aliases are matched in the grammar (not by rewriting the input) so text inside quoted values is never changed. + // The rewrite before the parse replaces each alias that stands between whitespace, like v1.14.2. + // The grammar reads an alias that the rewrite did not replace, for example `(Age)eq 3`. // Longer aliases are tried first so an alias that starts with another alias (e.g. `@@$$` and `@@$`) still matches. private static Parser<(string Operator, bool CaseInsensitive)> ComparisonOperatorAliasParser(IQueryKitConfiguration? config) { @@ -237,12 +214,14 @@ private static readonly Parser DoubleQuoteParser private static readonly Parser DateTimeTimeParser = Parse.Regex(@"T\d{2}:\d{2}:\d{2}").Text().Optional().Select(x => x.GetOrElse("")); private static readonly Parser DateTimeMicrosParser = Parse.Regex(@"\.\d{1,7}").Text().Optional().Select(x => x.GetOrElse("")); private static readonly Parser DateTimeZoneParser = Parse.Regex(@"Z|[+-]\d{2}(:\d{2})?").Text().Optional().Select(x => x.GetOrElse("")); + // v1.14.2 read the zone before the fraction, so 2022-07-01T00:00:02Z.5 is a valid value. A zone after the fraction is also valid. private static readonly Parser DateTimeFormatParser = from dateFormat in Parse.Regex(@"\d{4}-\d{2}-\d{2}").Text() from timeFormat in DateTimeTimeParser + from zoneBeforeMicros in DateTimeZoneParser from micros in DateTimeMicrosParser - from timeZone in DateTimeZoneParser - select dateFormat + timeFormat + micros + timeZone; + from zoneAfterMicros in zoneBeforeMicros == "" ? DateTimeZoneParser : Parse.Return("") + select dateFormat + timeFormat + micros + zoneBeforeMicros + zoneAfterMicros; // A number with a '.' decimal point, or with the decimal separator of the current culture. // The longer match wins, so '4.5' parses in every culture and '4,5' still parses in a culture that uses ','. @@ -294,37 +273,7 @@ from closingQuotes in Parse.Char('"').Repeat(count).Text() from openingBracket in Parse.Char('[') from content in SquareBracketValuesParser from closingBracket in Parse.Char(']') - select "[" + string.Join(",", content.Select(EscapeListItem)) + "]"; - - // List items are joined with ',' so quoted items that contain ',' or '\' are escaped and split back with SplitListItems, which trims each item - private static string EscapeListItem(string item) - => item.Replace(@"\", @"\\").Replace(",", @"\,"); - - private static List SplitListItems(string list) - { - var items = new List(); - var current = new StringBuilder(); - var content = list.Substring(1, list.Length - 2); - for (var i = 0; i < content.Length; i++) - { - if (content[i] == '\\' && i + 1 < content.Length) - { - current.Append(content[++i]); - } - else if (content[i] == ',') - { - items.Add(current.ToString().Trim()); - current.Clear(); - } - else - { - current.Append(content[i]); - } - } - items.Add(current.ToString().Trim()); - - return items; - } + select "[" + string.Join(",", content) + "]"; private static readonly Parser RightSideValueChoiceParser = Parse.String("null").Text().Select(v => new RightSideValue(v, false)) @@ -334,7 +283,7 @@ private static List SplitListItems(string list) .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.DelimitedBy(Parse.Char('.')).Select(v => new RightSideValue(string.Join(".", v), false))); // Keep this last to try property paths only if nothing else matches + .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() @@ -397,6 +346,11 @@ from trailingSpaces in Parse.WhiteSpace.Many() select LogicalOperator.GetByOperatorString(match.Operator))); } + // Npgsql only accepts a DateTimeOffset parameter with offset 0, so a parameter gets the same instant in UTC. + // A literal keeps its offset, like v1.14.2. + private static DateTimeOffset ToParameterOffset(DateTimeOffset value) + => FilterValue.Parameterize ? value.ToUniversalTime() : value; + private static readonly Dictionary> TypeConversionFunctions = new() { { typeof(string), value => value }, @@ -411,7 +365,7 @@ from trailingSpaces in Parse.WhiteSpace.Many() { typeof(short), value => short.Parse(value, CultureInfo.InvariantCulture) }, { typeof(byte), value => byte.Parse(value, CultureInfo.InvariantCulture) }, { typeof(DateTime), value => DateTime.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal) }, - { typeof(DateTimeOffset), value => DateTimeOffset.Parse(value).ToUniversalTime() }, + { typeof(DateTimeOffset), value => ToParameterOffset(DateTimeOffset.Parse(value)) }, { typeof(DateOnly), value => DateOnly.Parse(value) }, { typeof(TimeOnly), value => TimeOnly.Parse(value) }, { typeof(TimeSpan), value => TimeSpan.Parse(value) }, @@ -421,7 +375,7 @@ from trailingSpaces in Parse.WhiteSpace.Many() { typeof(sbyte), value => sbyte.Parse(value, CultureInfo.InvariantCulture) }, }; - private static Expression CreateRightExpr(Expression leftExpr, string right, ComparisonOperator op, + private static Expression CreateRightExpr(Expression leftExpr, string right, bool rightIsQuotedLiteral, ComparisonOperator op, IQueryKitConfiguration? config = null, string? propertyPath = null) { var targetType = leftExpr.Type; @@ -473,26 +427,17 @@ private static Expression CreateRightExpr(Expression leftExpr, string right, Com // Check if this property uses HasConversion if (config?.PropertyMappings != null && !string.IsNullOrEmpty(propertyPath)) { - var propertyConfig = config.PropertyMappings.GetPropertyInfo(propertyPath); + var propertyConfig = config.PropertyMappings.GetPropertyInfoByQueryName(propertyPath); if (propertyConfig?.UsesConversion == true && propertyConfig.ConversionTargetType != null) { // For HasConversion properties, try to create a constant of the original type // by constructing it from the string value using a constructor that takes the target type if (propertyConfig.ConversionTargetType == typeof(string)) { - // A null literal compares against null instead of being passed to the constructor - var underlyingType = Nullable.GetUnderlyingType(leftExpr.Type); - if (right == "null" && (!leftExpr.Type.IsValueType || underlyingType != null)) - { - return Expression.Constant(null, leftExpr.Type); - } - - // Nullable structs are constructed from their underlying type, then converted back - var stringCtor = (underlyingType ?? leftExpr.Type).GetConstructor(new[] { typeof(string) }); + var stringCtor = leftExpr.Type.GetConstructor(new[] { typeof(string) }); if (stringCtor != null) { - Expression constructed = Expression.New(stringCtor, FilterValue.Create(right, typeof(string))); - return underlyingType == null ? constructed : Expression.Convert(constructed, leftExpr.Type); + return Expression.New(stringCtor, FilterValue.Create(right, typeof(string))); } } @@ -501,10 +446,10 @@ private static Expression CreateRightExpr(Expression leftExpr, string right, Com } } - return CreateRightExprFromType(targetType, right, op); + return CreateRightExprFromType(targetType, right, rightIsQuotedLiteral, op); } - private static Expression CreateRightExprFromType(Type leftExprType, string right, ComparisonOperator op) + private static Expression CreateRightExprFromType(Type leftExprType, string right, bool rightIsQuotedLiteral, ComparisonOperator op) { var isEnumerable = IsEnumerable(leftExprType); var targetType = leftExprType; @@ -515,7 +460,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ return FilterValue.Create(intVal, typeof(int)); } targetType = targetType.GetGenericArguments()[0]; - return CreateRightExprFromType(targetType, right, op); + return CreateRightExprFromType(targetType, right, rightIsQuotedLiteral, op); } var rawType = targetType; @@ -542,7 +487,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ { targetType = typeof(string); } - var values = SplitListItems(right); + var values = right.Trim('[', ']').Split(',').Select(x => x.Trim()).ToList(); var elementType = targetType.IsArray ? targetType.GetElementType()! : targetType; var expressions = values.Select(x => @@ -581,8 +526,7 @@ 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.Create(dto.ToUniversalTime(), rawType); + return FilterValue.Create(ToParameterOffset(dto), rawType); } if (targetType == typeof(DateOnly)) @@ -595,9 +539,30 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ { var time = TimeOnly.Parse(right, CultureInfo.InvariantCulture); - var fractionalTicks = time.Ticks % TimeSpan.TicksPerSecond; - var millisecond = (int)(fractionalTicks / TimeSpan.TicksPerMillisecond); - var microsecond = (int)(fractionalTicks % TimeSpan.TicksPerMillisecond / 10); + int millisecond = 0, microsecond = 0; + if (rightIsQuotedLiteral) + { + // Like v1.14.2, the milliseconds of a quoted value need at least 3 fraction digits and the microseconds need at least 6. + if (right.Contains('.')) + { + var fractionalSeconds = right.Split('.')[1]; + if (fractionalSeconds.Length >= 3) + { + millisecond = int.Parse(fractionalSeconds.Substring(0, 3)); + } + if (fractionalSeconds.Length >= 6) + { + microsecond = int.Parse(fractionalSeconds.Substring(3, 3)); + } + } + } + else + { + // v1.14.2 did not accept an unquoted fraction, so an unquoted value keeps its full fraction. + var fractionalTicks = time.Ticks % TimeSpan.TicksPerSecond; + millisecond = (int)(fractionalTicks / TimeSpan.TicksPerMillisecond); + microsecond = (int)(fractionalTicks % TimeSpan.TicksPerMillisecond / 10); + } // One microsecond is 10 ticks. The TimeOnly constructor with microseconds needs .NET 7. var value = new TimeOnly(time.Hour, time.Minute, time.Second, millisecond) @@ -634,7 +599,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ if (right.StartsWith("[") && right.EndsWith("]")) { - var values = SplitListItems(right); + var values = right.Trim('[', ']').Split(',').Select(x => x.Trim()).ToList(); var elementType = targetType.IsArray ? targetType.GetElementType() : targetType; var expressions = values.Select(x => @@ -717,58 +682,14 @@ private static Parser ArithmeticComparisonExprParser(ParameterExp .SelectMany(temp => parenthesizedArithmetic.Or(rightSideValueParser.Select(value => CreateArithmeticFromValue(value.Value))), (temp, rightSide) => new { temp.leftArithmetic, temp.op, rightSide }) .Select(temp => { - var leftArithmetic = ResolveArithmeticProperties(temp.leftArithmetic, typeof(T), config); - var rightArithmetic = ResolveArithmeticProperties(temp.rightSide, typeof(T), config); - if (leftArithmetic == null || rightArithmetic == null) - { - return IgnoredClause(config); - } - - var leftExpr = leftArithmetic.ToLinqExpression(parameter, typeof(T)); - var rightExpr = rightArithmetic.ToLinqExpression(parameter, typeof(T)); + var leftExpr = temp.leftArithmetic.ToLinqExpression(parameter, typeof(T)); + var rightExpr = temp.rightSide.ToLinqExpression(parameter, typeof(T)); var (leftCompatible, rightCompatible) = EnsureCompatibleTypes(leftExpr, rightExpr); return temp.op.GetExpression(leftCompatible, rightCompatible, config?.DbContextType); }); } - // Resolves each property in an arithmetic expression to its member path. - // Returns null when a property cannot be filtered, because then the parser removes the clause. - private static ArithmeticExpression? ResolveArithmeticProperties(ArithmeticExpression expr, Type entityType, IQueryKitConfiguration? config) - { - switch (expr) - { - case PropertyArithmeticExpression property: - var reference = PropertyResolver.Resolve(entityType, property.PropertyPath, config); - if (!reference.CanFilter) - { - return null; - } - - // Arithmetic supports only members, so a derived property or a custom operation is unknown here - if (reference.Kind != PropertyReferenceKind.Member) - { - if (config?.AllowUnknownProperties == true) - { - return null; - } - - throw new UnknownFilterPropertyException(reference.UnknownSegment!); - } - - return new PropertyArithmeticExpression(reference.Path); - case BinaryArithmeticExpression binary: - var left = ResolveArithmeticProperties(binary.Left, entityType, config); - var right = ResolveArithmeticProperties(binary.Right, entityType, config); - return left == null || right == null ? null : new BinaryArithmeticExpression(left, binary.Operator, right); - case GroupedArithmeticExpression grouped: - var inner = ResolveArithmeticProperties(grouped.Inner, entityType, config); - return inner == null ? null : new GroupedArithmeticExpression(inner); - default: - return expr; - } - } - private static bool ContainsArithmeticOperator(ArithmeticExpression expr) { return expr switch @@ -808,6 +729,11 @@ private static bool IsValidPropertyName(string value) value.All(c => char.IsLetterOrDigit(c) || c == '_' || c == '.'); } + // The filter settings of a left-side property: by the name that its query name maps to, in the exact case. + // Derived properties and custom operations are not in this lookup. + private static QueryKitPropertyInfo? GetFilterPropertyInfo(string text, IQueryKitConfiguration? config) + => config?.PropertyMappings?.GetPropertyInfo(config.PropertyMappings.GetPropertyPathByQueryName(text) ?? text); + private static CaseInsensitiveMode ResolveCaseMode(string? propertyPath, IQueryKitConfiguration? config) { if (!string.IsNullOrEmpty(propertyPath) && config?.PropertyMappings != null) @@ -838,11 +764,6 @@ private static Parser ComparisonExprParser(ParameterExpression pa { if (temp.reference.Kind == PropertyReferenceKind.CustomOperation) { - if (!temp.reference.CanFilter) - { - return IgnoredClause(config); - } - return CreateCustomOperationExpression(parameter, temp.reference.Mapping!, temp.op, temp.right); } @@ -871,12 +792,12 @@ private static Parser ComparisonExprParser(ParameterExpression pa if (temp.op.IsStringComparisonOperator()) { var guidStringExpr = HandleGuidConversion(leftExpr, leftExpr.Type); - return temp.op.GetExpression(guidStringExpr, CreateRightExpr(guidStringExpr, temp.right, temp.op, config, guidPropertyPath), + return temp.op.GetExpression(guidStringExpr, CreateRightExpr(leftExpr, temp.right, temp.rightIsQuotedLiteral, temp.op, config, guidPropertyPath), config?.DbContextType, ResolveCaseMode(guidPropertyPath, config)); } // For non-string operators, use direct GUID comparison - return temp.op.GetExpression(leftExpr, CreateRightExpr(leftExpr, temp.right, temp.op, config, guidPropertyPath), + return temp.op.GetExpression(leftExpr, CreateRightExpr(leftExpr, temp.right, temp.rightIsQuotedLiteral, temp.op, config, guidPropertyPath), config?.DbContextType); } @@ -884,11 +805,6 @@ private static Parser ComparisonExprParser(ParameterExpression pa // A quoted string literal is always a value, even when its text matches a property name. if (!temp.rightIsQuotedLiteral && IsPropertyPath(temp.right, parameter.Type)) { - if (!PropertyResolver.Resolve(parameter.Type, temp.right, config).CanFilter) - { - return IgnoredClause(config); - } - var rightPropertyExpr = CreateRightPropertyExpr(parameter, temp.right, config); if (rightPropertyExpr != null) { @@ -991,7 +907,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa } } - var rightExpr = CreateRightExpr(leftExprForComparison, temp.right, temp.op, config, propertyPath); + var rightExpr = CreateRightExpr(leftExprForComparison, temp.right, temp.rightIsQuotedLiteral, temp.op, config, propertyPath); // Handle nested collection filtering if (leftExprForComparison is MethodCallExpression methodCall && IsNestedCollectionExpression(methodCall)) @@ -1008,33 +924,87 @@ private static Parser ComparisonExprParser(ParameterExpression pa private static Parser CreateLeftExprParser(Type entityType, IQueryKitConfiguration? config) { - var leftPropertyParser = PropertyPathParser(config).Token(); - - return leftPropertyParser.Select(left => + var leftPropertyParser = IdentifierPathParser.Token(); + var queryNameParser = DerivedOrCustomOperationQueryNameParser(config).Token(); + return input => { - var reference = PropertyResolver.Resolve(entityType, left, config); - if (reference.Kind == PropertyReferenceKind.Unknown && config?.AllowUnknownProperties != true) + var left = leftPropertyParser(input); + var reference = left.WasSuccessful ? PropertyResolver.Resolve(entityType, left.Value, config) : null; + if (reference != null && (reference.Kind != PropertyReferenceKind.Unknown || config?.AllowUnknownProperties == true)) { - throw new UnknownFilterPropertyException(reference.UnknownSegment!); + return Result.Success(reference, left.Remainder); } - return reference; - }); + // v1.14.2 did not accept the text here, so a derived property or custom operation query name can not change an accepted filter. + var queryName = queryNameParser(input); + if (queryName.WasSuccessful) + { + return Result.Success(PropertyResolver.Resolve(entityType, queryName.Value, config), queryName.Remainder); + } + + if (reference == null) + { + return Result.Failure(left.Remainder, left.Message, left.Expectations); + } + + throw new UnknownFilterPropertyException(reference.UnknownSegment!); + }; } + // The rewrite before the parse does not replace the query name of a derived property or a custom operation, + // so the grammar reads it when the identifier path is not a property. This lets the query name hold any text (for example `full-name` or `full name`). + // Longer query names are tried first, so a query name that starts with another query name still matches. + private static Parser DerivedOrCustomOperationQueryNameParser(IQueryKitConfiguration? config) + { + Parser parser = i => Result.Failure(i, "no query name", Array.Empty()); + var mappings = config?.PropertyMappings; + if (mappings == null) + { + return parser; + } + + var queryNames = mappings.DerivedPropertyMappings.Values.Concat(mappings.CustomOperationMappings.Values) + .Select(info => info.QueryName) + .Where(queryName => !string.IsNullOrEmpty(queryName)) + .Select(queryName => queryName!) + .Distinct(StringComparer.InvariantCultureIgnoreCase) + .OrderByDescending(queryName => queryName.Length); + foreach (var queryName in queryNames) + { + parser = parser.Or(WholeQueryName(queryName)); + } + + return parser; + } + + // A query name is a whole name: the next character can not continue a property path. + private static Parser WholeQueryName(string queryName) => input => + { + var result = Parse.IgnoreCase(queryName).Text()(input); + if (!result.WasSuccessful || result.Remainder.AtEnd || !IsPropertyPathChar(result.Remainder.Current)) + { + return result; + } + + return Result.Failure(input, $"Query name '{queryName}' must not be followed by '{result.Remainder.Current}'", new[] { queryName }); + }; + + private static bool IsPropertyPathChar(char c) => char.IsLetterOrDigit(c) || c == '_' || c == '.'; + private static Expression CreateLeftExpr(ParameterExpression parameter, PropertyReference reference, IQueryKitConfiguration? config) { var propertyExpression = reference.Kind == PropertyReferenceKind.DerivedProperty ? reference.Mapping!.DerivedExpression! : CreateMemberExpression(parameter, reference.Path); - var propertyConfig = reference.Mapping; - if (propertyConfig != null && !propertyConfig.CanFilter) + if (GetFilterPropertyInfo(reference.Text, config)?.CanFilter == false) { return RemovedClauseExpression.Instance; } // Check if this property uses HasConversion + var propertyConfig = config?.PropertyMappings?.GetPropertyInfoByQueryName( + config.PropertyMappings.GetPropertyPathByQueryName(reference.Text) ?? reference.Text); if (propertyConfig?.UsesConversion == true) { // For HasConversion properties, return the property expression as-is @@ -1048,7 +1018,7 @@ private static Expression CreateLeftExpr(ParameterExpression parameter, Property nestedMemberExpression.Expression is MemberExpression parentExpression) { var parentPropertyPath = GetPropertyPath(parentExpression, parameter); - var parentPropertyConfig = config?.PropertyMappings?.GetPropertyInfo(parentPropertyPath); + var parentPropertyConfig = config?.PropertyMappings?.GetPropertyInfoByQueryName(parentPropertyPath); if (parentPropertyConfig?.UsesConversion == true) { @@ -1151,11 +1121,11 @@ private static Parser PropertyListComparisonExprParser( var comparisonOperatorParser = ComparisonOperatorParser(config).Token(); var rightSideValueParser = RightSideValueParser.Token(); - return PropertyListParser(PropertyPathParser(config)) + return PropertyListParser(IdentifierPathParser) .SelectMany(properties => comparisonOperatorParser, (properties, op) => new { properties, op }) .SelectMany(temp => rightSideValueParser, - (temp, rightValue) => new { temp.properties, temp.op, right = rightValue.Value }) + (temp, rightValue) => new { temp.properties, temp.op, right = rightValue.Value, rightIsQuotedLiteral = rightValue.IsQuotedLiteral }) .Select(temp => { if (!temp.properties.Any()) @@ -1172,12 +1142,15 @@ private static Parser PropertyListComparisonExprParser( foreach (var fullPropPath in temp.properties) { // Build expression for each property. A property list does not support custom operations. - var reference = PropertyResolver.Resolve(parameter.Type, fullPropPath, config); - if (!reference.CanFilter) + // Check if property can be filtered + var propertyConfig = config?.PropertyMappings?.GetPropertyInfo(fullPropPath); + if (propertyConfig != null && !propertyConfig.CanFilter) { continue; } + var reference = PropertyResolver.Resolve(parameter.Type, fullPropPath, config); + if (reference.Kind is PropertyReferenceKind.Unknown or PropertyReferenceKind.CustomOperation) { if (config?.AllowUnknownProperties == true) @@ -1192,11 +1165,6 @@ private static Parser PropertyListComparisonExprParser( ? reference.Mapping!.DerivedExpression! : CreateMemberExpression(parameter, reference.Path); - // Use the resolved member path for HasConversion support, since the typed path can differ in casing - var resolvedPropPath = leftExpr is MemberExpression listMemberExpr - ? GetPropertyPath(listMemberExpr, parameter) - : fullPropPath; - // Handle GUID conversion for string operators if ((leftExpr.Type == typeof(Guid) || leftExpr.Type == typeof(Guid?)) && temp.op.IsStringComparisonOperator()) @@ -1204,8 +1172,8 @@ private static Parser PropertyListComparisonExprParser( leftExpr = HandleGuidConversion(leftExpr, leftExpr.Type); } - var rightExpr = CreateRightExpr(leftExpr, temp.right, temp.op, config, resolvedPropPath); - var comparison = temp.op.GetExpression(leftExpr, rightExpr, config?.DbContextType, ResolveCaseMode(reference.Path, config)); + var rightExpr = CreateRightExpr(leftExpr, temp.right, temp.rightIsQuotedLiteral, temp.op, config, fullPropPath); + var comparison = temp.op.GetExpression(leftExpr, rightExpr, config?.DbContextType, ResolveCaseMode(fullPropPath, config)); // Combine with AND for negative operators, OR for positive operators result = result == null diff --git a/QueryKit/FilterValue.cs b/QueryKit/FilterValue.cs index 5916323..453e843 100644 --- a/QueryKit/FilterValue.cs +++ b/QueryKit/FilterValue.cs @@ -85,15 +85,11 @@ private static Expression Literal(object? value, Type type) : 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) + // The TimeOnly constructor with microseconds needs .NET 7. On net6.0, Expression.New throws + // ArgumentNullException, the same as v1.14.2. + 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 ctor = typeof(TimeOnly).GetConstructor(new[] { typeof(int), typeof(int), typeof(int), typeof(int), typeof(int) })!; var fractionalTicks = time.Ticks % TimeSpan.TicksPerSecond; var millisecond = (int)(fractionalTicks / TimeSpan.TicksPerMillisecond); var microsecond = (int)(fractionalTicks % TimeSpan.TicksPerMillisecond / 10); diff --git a/QueryKit/Operators/ArithmeticOperator.cs b/QueryKit/Operators/ArithmeticOperator.cs index 0fa6452..70be3fb 100644 --- a/QueryKit/Operators/ArithmeticOperator.cs +++ b/QueryKit/Operators/ArithmeticOperator.cs @@ -24,7 +24,6 @@ protected ArithmeticOperator(string symbol, int precedence) 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, diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 0875d29..18834f8 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -34,30 +34,30 @@ public abstract class ComparisonOperator : SmartEnum public static ComparisonOperator CaseSensitiveHasOperator = new HasType(); public static ComparisonOperator CaseSensitiveDoesNotHaveOperator = new DoesNotHaveType(); - public static ComparisonOperator EqualsOperator(bool caseInsensitive = false, bool usesAll = false) => new EqualsType(caseInsensitive, usesAll); - public static ComparisonOperator NotEqualsOperator(bool caseInsensitive = false, bool usesAll = false) => new NotEqualsType(caseInsensitive, usesAll); - public static ComparisonOperator GreaterThanOperator(bool caseInsensitive = false, bool usesAll = false) => new GreaterThanType(caseInsensitive, usesAll); - public static ComparisonOperator LessThanOperator(bool caseInsensitive = false, bool usesAll = false) => new LessThanType(caseInsensitive, usesAll); - public static ComparisonOperator GreaterThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new GreaterThanOrEqualType(caseInsensitive, usesAll); - public static ComparisonOperator LessThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new LessThanOrEqualType(caseInsensitive, usesAll); - public static ComparisonOperator ContainsOperator(bool caseInsensitive = false, bool usesAll = false) => new ContainsType(caseInsensitive, usesAll); - public static ComparisonOperator StartsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new StartsWithType(caseInsensitive, usesAll); - public static ComparisonOperator EndsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new EndsWithType(caseInsensitive, usesAll); - public static ComparisonOperator NotContainsOperator(bool caseInsensitive = false, bool usesAll = false) => new NotContainsType(caseInsensitive, usesAll); - public static ComparisonOperator NotStartsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new NotStartsWithType(caseInsensitive, usesAll); - public static ComparisonOperator NotEndsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new NotEndsWithType(caseInsensitive, usesAll); - public static ComparisonOperator InOperator(bool caseInsensitive = false, bool usesAll = false) => new InType(caseInsensitive, usesAll); - public static ComparisonOperator NotInOperator(bool caseInsensitive = false, bool usesAll = false) => new NotInType(caseInsensitive, usesAll); - public static ComparisonOperator SoundsLikeOperator(bool caseInsensitive = false, bool usesAll = false) => new SoundsLikeType(caseInsensitive, usesAll); - public static ComparisonOperator DoesNotSoundLikeOperator(bool caseInsensitive = false, bool usesAll = false) => new DoesNotSoundLikeType(caseInsensitive, usesAll); - public static ComparisonOperator HasCountEqualToOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountEqualToType(caseInsensitive, usesAll); - public static ComparisonOperator HasCountNotEqualToOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountNotEqualToType(caseInsensitive, usesAll); - public static ComparisonOperator HasCountGreaterThanOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountGreaterThanType(caseInsensitive, usesAll); - public static ComparisonOperator HasCountLessThanOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountLessThanType(caseInsensitive, usesAll); - public static ComparisonOperator HasCountGreaterThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountGreaterThanOrEqualType(caseInsensitive, usesAll); - public static ComparisonOperator HasCountLessThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountLessThanOrEqualType(caseInsensitive, usesAll); - public static ComparisonOperator HasOperator(bool caseInsensitive = false, bool usesAll = false) => new HasType(caseInsensitive, usesAll); - public static ComparisonOperator DoesNotHaveOperator(bool caseInsensitive = false, bool usesAll = false) => new DoesNotHaveType(caseInsensitive, usesAll); + public static ComparisonOperator EqualsOperator(bool caseInsensitive = false, bool usesAll = false) => new EqualsType(caseInsensitive); + public static ComparisonOperator NotEqualsOperator(bool caseInsensitive = false, bool usesAll = false) => new NotEqualsType(caseInsensitive); + public static ComparisonOperator GreaterThanOperator(bool caseInsensitive = false, bool usesAll = false) => new GreaterThanType(caseInsensitive); + public static ComparisonOperator LessThanOperator(bool caseInsensitive = false, bool usesAll = false) => new LessThanType(caseInsensitive); + public static ComparisonOperator GreaterThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new GreaterThanOrEqualType(caseInsensitive); + public static ComparisonOperator LessThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new LessThanOrEqualType(caseInsensitive); + public static ComparisonOperator ContainsOperator(bool caseInsensitive = false, bool usesAll = false) => new ContainsType(caseInsensitive); + public static ComparisonOperator StartsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new StartsWithType(caseInsensitive); + public static ComparisonOperator EndsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new EndsWithType(caseInsensitive); + public static ComparisonOperator NotContainsOperator(bool caseInsensitive = false, bool usesAll = false) => new NotContainsType(caseInsensitive); + public static ComparisonOperator NotStartsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new NotStartsWithType(caseInsensitive); + public static ComparisonOperator NotEndsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new NotEndsWithType(caseInsensitive); + public static ComparisonOperator InOperator(bool caseInsensitive = false, bool usesAll = false) => new InType(caseInsensitive); + public static ComparisonOperator NotInOperator(bool caseInsensitive = false, bool usesAll = false) => new NotInType(caseInsensitive); + public static ComparisonOperator SoundsLikeOperator(bool caseInsensitive = false, bool usesAll = false) => new SoundsLikeType(caseInsensitive); + public static ComparisonOperator DoesNotSoundLikeOperator(bool caseInsensitive = false, bool usesAll = false) => new DoesNotSoundLikeType(caseInsensitive); + public static ComparisonOperator HasCountEqualToOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountEqualToType(caseInsensitive); + public static ComparisonOperator HasCountNotEqualToOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountNotEqualToType(caseInsensitive); + public static ComparisonOperator HasCountGreaterThanOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountGreaterThanType(caseInsensitive); + public static ComparisonOperator HasCountLessThanOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountLessThanType(caseInsensitive); + public static ComparisonOperator HasCountGreaterThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountGreaterThanOrEqualType(caseInsensitive); + public static ComparisonOperator HasCountLessThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountLessThanOrEqualType(caseInsensitive); + public static ComparisonOperator HasOperator(bool caseInsensitive = false, bool usesAll = false) => new HasType(caseInsensitive); + public static ComparisonOperator DoesNotHaveOperator(bool caseInsensitive = false, bool usesAll = false) => new DoesNotHaveType(caseInsensitive); public static ComparisonOperator GetByOperatorString(string op, bool caseInsensitive = false, bool usesAll = false) { @@ -383,10 +383,7 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.AndAlso(nullCheck, containsCall); } - // null doesn't contain anything, so we need: left != null && left.Contains(right) - return Expression.AndAlso( - Expression.NotEqual(left, Expression.Constant(null, typeof(string))), - Expression.Call(left, typeof(string).GetMethod("Contains", new[] { typeof(string) })!, right)); + return Expression.Call(left, typeof(string).GetMethod("Contains", new[] { typeof(string) })!, right); } } @@ -418,10 +415,7 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.AndAlso(nullCheck, startsWithCall); } - // null doesn't start with anything, so we need: left != null && left.StartsWith(right) - return Expression.AndAlso( - Expression.NotEqual(left, Expression.Constant(null, typeof(string))), - Expression.Call(left, typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!, right)); + return Expression.Call(left, typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!, right); } } @@ -453,10 +447,7 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.AndAlso(nullCheck, endsWithCall); } - // null doesn't end with anything, so we need: left != null && left.EndsWith(right) - return Expression.AndAlso( - Expression.NotEqual(left, Expression.Constant(null, typeof(string))), - Expression.Call(left, typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!, right)); + return Expression.Call(left, typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!, right); } } @@ -488,10 +479,7 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.OrElse(nullCheck, notContainsCall); } - // null doesn't contain anything, so it should be included: left == null || !left.Contains(right) - return Expression.OrElse( - Expression.Equal(left, Expression.Constant(null, typeof(string))), - Expression.Not(Expression.Call(left, typeof(string).GetMethod("Contains", new[] { typeof(string) })!, right))); + return Expression.Not(Expression.Call(left, typeof(string).GetMethod("Contains", new[] { typeof(string) })!, right)); } } @@ -523,10 +511,7 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.OrElse(nullCheck, notStartsWithCall); } - // null doesn't start with anything, so it should be included: left == null || !left.StartsWith(right) - return Expression.OrElse( - Expression.Equal(left, Expression.Constant(null, typeof(string))), - Expression.Not(Expression.Call(left, typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!, right))); + return Expression.Not(Expression.Call(left, typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!, right)); } } @@ -558,10 +543,7 @@ public override Expression GetExpression(Expression left, Expression right, T return Expression.OrElse(nullCheck, notEndsWithCall); } - // null doesn't end with anything, so it should be included: left == null || !left.EndsWith(right) - return Expression.OrElse( - Expression.Equal(left, Expression.Constant(null, typeof(string))), - Expression.Not(Expression.Call(left, typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!, right))); + return Expression.Not(Expression.Call(left, typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!, right)); } } @@ -606,7 +588,8 @@ public override Expression GetExpression(Expression left, Expression right, T var listType = typeof(List); var caseList = Activator.CreateInstance(listType); - var originalList = values as IEnumerable; + // A caller can pass the list as a constant, like v1.14.2. + var originalList = (values ?? ((ConstantExpression)right).Value) as IEnumerable; foreach (var value in originalList!) { listType.GetMethod("Add")!.Invoke(caseList, new[] { caseMode == CaseInsensitiveMode.Upper ? value.ToUpper() : value.ToLower() }); @@ -783,7 +766,7 @@ public override Expression GetExpression(Expression left, Expression right, T return GetCollectionExpression(left, right, Expression.Equal, UsesAll); } - throw new QueryKitParsingException("HasType is only supported for collections"); + throw new QueryKitParsingException("DoesNotHaveType is only supported for collections"); } } @@ -851,7 +834,8 @@ public override Expression GetExpression(Expression left, Expression right, T var listType = typeof(List); var caseList = Activator.CreateInstance(listType); - var originalList = values as IEnumerable; + // A caller can pass the list as a constant, like v1.14.2. + var originalList = (values ?? ((ConstantExpression)right).Value) as IEnumerable; foreach (var value in originalList!) { listType.GetMethod("Add")!.Invoke(caseList, new[] { caseMode == CaseInsensitiveMode.Upper ? value.ToUpper() : value.ToLower() }); diff --git a/QueryKit/PropertyResolver.cs b/QueryKit/PropertyResolver.cs index 8db8a0c..2fbe954 100644 --- a/QueryKit/PropertyResolver.cs +++ b/QueryKit/PropertyResolver.cs @@ -36,10 +36,6 @@ private PropertyReference(PropertyReferenceKind kind, string text, string path, /// The configuration of the member, the derived property, or the custom operation, if there is one. public QueryKitPropertyInfo? Mapping { get; } - public bool CanFilter => Mapping?.CanFilter ?? true; - - public bool CanSort => Mapping?.CanSort ?? true; - /// When the reference is not a member, the first path segment that did not resolve to a member. public string? UnknownSegment { get; } @@ -57,11 +53,9 @@ internal static class PropertyResolver { internal static PropertyReference Resolve(Type rootType, string reference, IQueryKitConfiguration? config) { - // A query name resolves to the property path of its mapping first - var path = config?.PropertyMappings?.GetPropertyPathByQueryName(reference) ?? reference; - config?.ValidatePropertyDepth(path); + config?.ValidatePropertyDepth(reference); - var memberPath = ResolveMemberPath(rootType, path, out var unknownSegment); + var memberPath = ResolveMemberPath(rootType, reference, out var unknownSegment); if (memberPath != null) { return PropertyReference.Member(reference, memberPath, config?.PropertyMappings?.GetPropertyInfo(memberPath)); @@ -83,20 +77,35 @@ internal static PropertyReference Resolve(Type rootType, string reference, IQuer } // Matches each segment to a public member, ignoring case. A segment after a collection resolves on the element type. + // After a collection, only properties match: the first segment in the exact case, a later segment in any case. + // A segment after a collection that does not match throws NullReferenceException. private static string? ResolveMemberPath(Type rootType, string path, out string? unknownSegment) { var memberNames = new List(); var currentType = rootType; + var afterCollection = false; foreach (var segment in path.Split('.')) { + var firstAfterCollection = !afterCollection && memberNames.Count > 0 && IsCollection(currentType); while (IsCollection(currentType)) { currentType = currentType.GetGenericArguments()[0]; } - var member = (MemberInfo?)currentType.GetProperty(segment, MemberFlags) + MemberInfo? member; + if (firstAfterCollection || afterCollection) + { + member = (firstAfterCollection ? currentType.GetProperty(segment) : currentType.GetProperty(segment, MemberFlags)) + ?? throw new NullReferenceException(); + afterCollection = true; + } + else + { + member = (MemberInfo?)currentType.GetProperty(segment, MemberFlags) ?? currentType.GetField(segment, MemberFlags); + } + if (member == null) { unknownSegment = segment; diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index d771ca8..9408114 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -14,14 +14,6 @@ public class QueryKitPropertyMappings internal IReadOnlyDictionary DerivedPropertyMappings => _derivedPropertyMappings; internal IReadOnlyDictionary CustomOperationMappings => _customOperationMappings; - // Every query name of a property, a derived property, or a custom operation - internal IEnumerable QueryNames - => _propertyMappings.Values.Concat(_derivedPropertyMappings.Values).Concat(_customOperationMappings.Values) - .Select(info => info.QueryName) - .Where(queryName => !string.IsNullOrEmpty(queryName)) - .Select(queryName => queryName!) - .Distinct(StringComparer.InvariantCultureIgnoreCase); - public QueryKitPropertyMapping Property(Expression>? propertySelector) { var fullPath = GetFullPropertyPath(propertySelector); @@ -420,12 +412,11 @@ private static string GetOperator(ExpressionType nodeType) if (string.IsNullOrEmpty(propertyPath)) return null; - // Check if the property path is, or is under, any configured property that has MaxDepth + // Check if the property path starts with any configured property that has MaxDepth foreach (var mapping in _propertyMappings.Values) { - if (mapping.MaxDepth.HasValue && !string.IsNullOrEmpty(mapping.Name) && - (propertyPath.Equals(mapping.Name, StringComparison.OrdinalIgnoreCase) || - propertyPath.StartsWith(mapping.Name + ".", StringComparison.OrdinalIgnoreCase))) + if (mapping.MaxDepth.HasValue && + propertyPath.StartsWith(mapping.Name ?? "", StringComparison.OrdinalIgnoreCase)) { return mapping.MaxDepth; } diff --git a/QueryKit/SortParser.cs b/QueryKit/SortParser.cs index 26c0280..22a915a 100644 --- a/QueryKit/SortParser.cs +++ b/QueryKit/SortParser.cs @@ -56,8 +56,7 @@ private static SortExpressionInfo CreateSortExpression(string sortClause, } var propertyPath = config?.GetPropertyPathByQueryName(propertyName) ?? propertyName; - var reference = PropertyResolver.Resolve(typeof(T), propertyPath, config); - if (reference.Kind != PropertyReferenceKind.CustomOperation && !reference.CanSort) + if (config != null && config.IsPropertySortable(propertyPath) == false) { return new SortExpressionInfo { diff --git a/README.md b/README.md index 8027b84..2899dc1 100644 --- a/README.md +++ b/README.md @@ -823,7 +823,7 @@ var filterExpression = FilterParser.ParseFilter(input, config); #### Parse Limits -`IQueryKitParseLimits` caps how much a filter string can do before QueryKit parses it, through `MaxInputLength` (default `5000` characters) and `MaxNestingDepth` (default `32` levels of parentheses). `QueryKitConfiguration` implements this interface, so a custom configuration class can implement `IQueryKitParseLimits` directly instead. A filter string that goes over `MaxInputLength` throws a `QueryKitInputLengthExceededException`. A filter string that goes over `MaxNestingDepth` throws a `QueryKitNestingDepthExceededException`. Both exceptions throw before parsing starts. These limits apply only to filter strings. Sort strings have no limit, and the number of items in an in-list has no limit. The nesting-depth check counts every `(` character, including a `(` inside a quoted value. +`IQueryKitParseLimits` caps how much a filter string can do before QueryKit parses it, through `MaxInputLength` (a number of characters) and `MaxNestingDepth` (a number of levels of parentheses). Both limits are off by default. If your app sends user input to QueryKit, turn both limits on. A filter string with a few thousand nested parentheses can overflow the call stack and stop the process. `QueryKitConfiguration` implements this interface, so a custom configuration class can implement `IQueryKitParseLimits` directly instead. A filter string that goes over `MaxInputLength` throws a `QueryKitInputLengthExceededException`. A filter string that goes over `MaxNestingDepth` throws a `QueryKitNestingDepthExceededException`. Both exceptions throw before parsing starts. These limits apply only to filter strings. Sort strings have no limit, and the number of items in an in-list has no limit. The nesting-depth check counts every `(` character, including a `(` inside a quoted value. ```csharp var config = new QueryKitConfiguration(config =>