diff --git a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs index 4b0a83f..b126188 100644 --- a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs +++ b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs @@ -398,6 +398,7 @@ public async Task can_filter_by_string_for_nested_collection() var testingServiceScope = new TestingServiceScope(); var faker = new Faker(); var preparationOne = new FakeIngredientPreparation().Generate(); + preparationOne.Text = Guid.NewGuid().ToString(); var preparationTwo = new FakeIngredientPreparation().Generate(); var fakeIngredientOne = new FakeIngredientBuilder() .WithPreparation(preparationOne) @@ -435,6 +436,7 @@ public async Task can_filter_by_string_for_nested_collection_with_alias() var testingServiceScope = new TestingServiceScope(); var faker = new Faker(); var preparationOne = new FakeIngredientPreparation().Generate(); + preparationOne.Text = Guid.NewGuid().ToString(); var preparationTwo = new FakeIngredientPreparation().Generate(); var fakeIngredientOne = new FakeIngredientBuilder() .WithPreparation(preparationOne) @@ -885,7 +887,7 @@ public async Task can_filter_nested_property_using_ownsone() .WithPhysicalAddress(new Address(faker.Address.StreetAddress() , faker.Address.SecondaryAddress() , faker.Address.City() - , faker.Address.State() + , Guid.NewGuid().ToString() , faker.Address.ZipCode() , faker.Address.Country())) .Build(); diff --git a/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs new file mode 100644 index 0000000..590601b --- /dev/null +++ b/QueryKit.IntegrationTests/Tests/PropertyResolverTests.cs @@ -0,0 +1,412 @@ +namespace QueryKit.IntegrationTests.Tests; + +using Bogus; +using Configuration; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using SharedTestingHelper.Fakes; +using SharedTestingHelper.Fakes.Author; +using SharedTestingHelper.Fakes.Recipes; +using WebApiTestProject.Entities; + +public class PropertyResolverTests : TestBase +{ + [Fact] + public async Task unknown_property_clause_under_or_does_not_return_every_row() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = new Faker().Lorem.Sentence(); + var fakePerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithAge(30) + .Build(); + await testingServiceScope.InsertAsync(fakePerson); + + var input = $"""Title == "{title}" && (Nope == "x" || Age > 100)"""; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().BeEmpty(); + } + + [Fact] + public async Task prevented_property_clause_under_or_does_not_return_every_row() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var title = new Faker().Lorem.Sentence(); + var fakePerson = new FakeTestingPersonBuilder() + .WithTitle(title) + .WithAge(30) + .WithRating(1) + .Build(); + await testingServiceScope.InsertAsync(fakePerson); + + var input = $"""Title == "{title}" && (Rating == 1 || Age > 100)"""; + var config = new QueryKitConfiguration(config => + { + 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.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.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.AllowUnknownProperties = true; + }); + + // Act + var people = await testingServiceScope.DbContext().People + .ApplyQueryKitFilter(input, config) + .ToListAsync(); + + // Assert + people.Should().BeEmpty(); + } +} diff --git a/QueryKit.UnitTests/CustomFilterPropertyTests.cs b/QueryKit.UnitTests/CustomFilterPropertyTests.cs index 917f4df..a41cbdf 100644 --- a/QueryKit.UnitTests/CustomFilterPropertyTests.cs +++ b/QueryKit.UnitTests/CustomFilterPropertyTests.cs @@ -142,7 +142,7 @@ public void can_have_custom_prop_excluded_from_filter() config.Property(x => x.Id).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (True == True))"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "{stringValue}")"""); } [Fact] @@ -159,7 +159,7 @@ public void can_have_custom_prop_excluded_from_filter_with_custom_propname() config.Property(x => x.Id).HasQueryName("identifier").PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => ((x.Title == "{stringValue}") OrElse (True == True))"""); + filterExpression.ToDisplayString().Should().Be($"""x => (x.Title == "{stringValue}")"""); } [Fact] @@ -243,7 +243,7 @@ public void can_have_derived_prop_work_with_collection_filters() } [Fact] - public void filter_prevented_props_always_have_true_equals_true_regardless_of_comparison() + public void filter_prevented_props_are_removed_regardless_of_comparison() { var faker = new Faker(); var filterOperator = faker.PickRandom(ComparisonOperator.List.Where(x => x != ComparisonOperator.EqualsOperator()).ToList()); @@ -255,14 +255,13 @@ public void filter_prevented_props_always_have_true_equals_true_regardless_of_co config.Property(x => x.Id).PreventFilter(); }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be($"""x => (True == True)"""); + filterExpression.ToDisplayString().Should().Be("x => True"); } [Fact] public void can_throw_error_when_property_has_space() { - var faker = new Faker(); - var propertyName = faker.Lorem.Sentence(); + var propertyName = "unknown property name"; var firstWord = propertyName.Split(' ').First(); var input = $"""{propertyName} == 25"""; @@ -278,14 +277,13 @@ public void can_throw_error_when_property_has_space() [Fact] public void can_handle_nonexistent_property() { - var faker = new Faker(); - var input = $"""{faker.Lorem.Word()} == 25"""; + var input = """unknownProperty == 25"""; var config = new QueryKitConfiguration(config => { config.AllowUnknownProperties = true; }); var filterExpression = FilterParser.ParseFilter(input, config); - filterExpression.ToDisplayString().Should().Be("x => (True == True)"); + filterExpression.ToDisplayString().Should().Be("x => True"); } } \ No newline at end of file diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index a2daaa3..87ce7fd 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -519,8 +519,7 @@ public void can_throw_error_when_missing_double_quotes_not_recognized() [Fact] public void can_throw_error_when_property_has_space() { - var faker = new Faker(); - var propertyName = faker.Lorem.Sentence(); + var propertyName = "unknown property name"; var firstWord = propertyName.Split(' ').First(); var input = $"""{propertyName} == 25"""; var act = () => FilterParser.ParseFilter(input); @@ -537,6 +536,15 @@ public void simple_child_collection_for_string_equal() .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => (z == "flour"))""""); } + [Fact] + public void child_collection_member_resolves_in_any_case() + { + 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"))""""); + } + [Fact] public void simple_child_collection_for_string_case_insensitive_equal() { diff --git a/QueryKit.UnitTests/PropertyDepthTests.cs b/QueryKit.UnitTests/PropertyDepthTests.cs index c4021bb..35659d2 100644 --- a/QueryKit.UnitTests/PropertyDepthTests.cs +++ b/QueryKit.UnitTests/PropertyDepthTests.cs @@ -255,4 +255,39 @@ public void filter_root_property_allowed_when_max_depth_is_0() var filterExpression = FilterParser.ParseFilter(input, config); filterExpression.Should().NotBeNull(); } + + [Fact] + public void filter_per_property_max_depth_does_not_apply_to_a_property_that_starts_with_its_name() + { + var input = """AddressBackup.State == "x" """; + var config = new QueryKitConfiguration(settings => + { + settings.MaxPropertyDepth = 0; + settings.Property(x => x.Address).HasMaxDepth(1); + }); + + var act = () => FilterParser.ParseFilter(input, config); + act.Should().Throw() + .WithMessage("*AddressBackup.State*depth of 1*maximum allowed depth of 0*"); + } + + [Fact] + public void sort_per_property_max_depth_does_not_apply_to_a_property_that_starts_with_its_name() + { + var input = "AddressBackup.State"; + var config = new QueryKitConfiguration(settings => + { + settings.MaxPropertyDepth = 0; + settings.Property(x => x.Address).HasMaxDepth(1); + }); + + var act = () => SortParser.ParseSort(input, config); + act.Should().Throw(); + } + + private class Owner + { + public Address Address { get; set; } = null!; + public Address AddressBackup { get; set; } = null!; + } } diff --git a/QueryKit.UnitTests/PropertyResolverTests.cs b/QueryKit.UnitTests/PropertyResolverTests.cs new file mode 100644 index 0000000..90cc61d --- /dev/null +++ b/QueryKit.UnitTests/PropertyResolverTests.cs @@ -0,0 +1,454 @@ +namespace QueryKit.UnitTests; + +using Configuration; +using Exceptions; +using FluentAssertions; +using WebApiTestProject.Entities; +using WebApiTestProject.Entities.Ingredients; +using WebApiTestProject.Entities.Recipes; + +public class PropertyResolverTests +{ + [Fact] + public void unknown_property_clause_is_removed_under_or() + { + var input = """Nope == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void unknown_property_clause_is_removed_under_and() + { + var input = """Age > 100 && Nope == "x" """; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void prevented_property_clause_is_removed_under_or() + { + var input = """Rating == 1 || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Rating).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void removed_clause_in_a_group_is_removed_from_the_group() + { + var input = """Title == "a" && (Nope == "x" || Age > 100)"""; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => ((x.Title == "a") AndAlso (x.Age > 100))"""); + } + + [Fact] + public void property_list_with_only_prevented_properties_is_removed() + { + var input = """(Title, FirstName) == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).PreventFilter(); + config.Property(x => x.FirstName).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void prevented_property_in_arithmetic_removes_the_clause() + { + var input = """(Age + 0) > 10 || Title == "a" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Age).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "a")"""); + } + + [Fact] + public void prevented_property_on_the_right_side_of_arithmetic_removes_the_clause() + { + var input = """(Age + 0) > (Rating * 2)"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Rating).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => True"); + } + + [Fact] + public void arithmetic_property_obeys_max_property_depth() + { + var input = """(Recipe.Rating + 0) > 1"""; + var config = new QueryKitConfiguration(config => + { + config.MaxPropertyDepth = 0; + }); + + var act = () => FilterParser.ParseFilter(input, config); + + act.Should().Throw(); + } + + [Fact] + public void prevented_property_on_the_right_side_removes_the_clause() + { + var input = """FirstName == Title || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + 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.Property(x => x.Title).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void prevented_property_in_a_list_is_skipped_in_any_case() + { + var input = """(title, FirstName) == "x" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "x")"""); + } + + [Fact] + public void prevented_property_removes_the_clause_in_any_case() + { + var input = """title == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + 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_with_a_query_name_removes_the_clause_when_written_by_its_member_name_in_any_case() + { + var input = """title == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("t").PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + 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); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Contain("ToUpper()"); + } + + [Fact] + 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(); + }); + + var sortExpressions = SortParser.ParseSort(input, config); + + sortExpressions.Should().ContainSingle(); + sortExpressions[0].Expression!.ToString().Should().Be("x => Convert(x.Age, Object)"); + } + + [Fact] + public void prevented_sort_property_with_a_query_name_is_skipped_when_written_by_its_member_name() + { + var input = "title desc"; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("t").PreventSort(); + }); + + var sortExpressions = SortParser.ParseSort(input, config); + + sortExpressions.Should().BeEmpty(); + } + + [Fact] + public void prevented_derived_property_removes_the_clause() + { + var input = """full == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void prevented_derived_property_in_a_list_is_skipped() + { + var input = """(full, FirstName) == "x" """; + var config = new QueryKitConfiguration(config => + { + config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "x")"""); + } + + [Fact] + public void prevented_custom_operation_removes_the_clause() + { + var input = """adult == true || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.CustomOperation((x, op, value) => x.Age > 17).HasQueryName("adult").PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void prevented_derived_sort_property_is_skipped() + { + var input = "full desc, Age"; + var config = new QueryKitConfiguration(config => + { + config.DerivedProperty(x => x.FirstName + " " + x.LastName).HasQueryName("full").PreventSort(); + }); + + var sortExpressions = SortParser.ParseSort(input, config); + + sortExpressions.Should().ContainSingle(); + sortExpressions[0].Expression!.ToString().Should().Be("x => Convert(x.Age, Object)"); + } + + [Fact] + public void query_name_in_a_property_list_resolves_to_its_property() + { + var input = """(name, FirstName) == "x" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("name"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => ((x.Title == "x") OrElse (x.FirstName == "x"))"""); + } + + [Fact] + public void query_name_in_arithmetic_resolves_to_its_property() + { + var input = """(stars + 0) > 3"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Rating).HasQueryName("stars"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Contain("x.Rating"); + } + + [Fact] + public void query_name_in_a_value_is_not_replaced() + { + var input = """FirstName == "name == x" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("name"); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.FirstName == "name == x")"""); + } + + [Fact] + public void property_prevented_for_filter_and_sort_is_removed_by_its_query_name() + { + var input = """name == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void property_prevented_for_filter_and_sort_is_removed_by_its_member_name() + { + var input = """Title == "x" || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("name").PreventFilter().PreventSort(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void alias_replacement_does_not_replace_a_query_name_in_a_nested_path() + { + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Title).HasQueryName("name"); + }); + + var input = config.PropertyMappings.ReplaceAliasesWithPropertyPaths("""Author.Name == "x" && name == "y" """); + + input.Should().Be("""Author.Name == "x" && Title == "y" """); + } + + [Fact] + public void property_path_on_the_right_side_is_compared() + { + var input = """Title == Author.Name"""; + + var filterExpression = FilterParser.ParseFilter(input); + + filterExpression.ToDisplayString().Should().Be("x => (x.Title == x.Author.Name)"); + } + + [Fact] + public void property_path_on_the_right_side_obeys_max_property_depth() + { + var input = """Title == Author.Name"""; + var config = new QueryKitConfiguration(config => + { + config.MaxPropertyDepth = 0; + }); + + var act = () => FilterParser.ParseFilter(input, config); + + act.Should().Throw(); + } + + [Fact] + public void prevented_property_path_on_the_right_side_removes_the_clause() + { + var input = """Title == Author.Name || Directions == "x" """; + var config = new QueryKitConfiguration(config => + { + config.Property(x => x.Author.Name).PreventFilter(); + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.Directions == "x")"""); + } + + [Fact] + public void unknown_property_in_arithmetic_removes_the_clause_when_unknown_properties_are_allowed() + { + var input = """(Nope + 1) > 3 || Age > 100"""; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("x => (x.Age > 100)"); + } + + [Fact] + public void unknown_property_on_the_right_side_of_arithmetic_removes_the_clause_when_unknown_properties_are_allowed() + { + var input = """(Age + 0) > Nope || Title == "a" """; + var config = new QueryKitConfiguration(config => + { + config.AllowUnknownProperties = true; + }); + + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.ToDisplayString().Should().Be("""x => (x.Title == "a")"""); + } + + [Fact] + public void unknown_property_in_arithmetic_is_not_recognized() + { + var input = """(Nope + 1) > 3"""; + + var act = () => FilterParser.ParseFilter(input); + + act.Should().Throw() + .WithMessage("The filter property 'Nope' was not recognized."); + } +} diff --git a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs index f196d49..43735b6 100644 --- a/QueryKit/Configuration/QueryKitConfigurationExtensions.cs +++ b/QueryKit/Configuration/QueryKitConfigurationExtensions.cs @@ -37,11 +37,6 @@ internal static string ReplaceLogicalAliases(this IQueryKitConfiguration configu 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/Expressions/RemovedClauseExpression.cs b/QueryKit/Expressions/RemovedClauseExpression.cs new file mode 100644 index 0000000..f0be49c --- /dev/null +++ b/QueryKit/Expressions/RemovedClauseExpression.cs @@ -0,0 +1,19 @@ +namespace QueryKit.Expressions; + +using System.Linq.Expressions; + +/// +/// Marks a filter clause that the parser removed, for example a clause on an unknown property. +/// A logical operator with a removed side becomes its other side, so the clause has no effect on the result. +/// +internal sealed class RemovedClauseExpression : Expression +{ + public static readonly RemovedClauseExpression Instance = new(); + + private RemovedClauseExpression() + { + } + + public override ExpressionType NodeType => ExpressionType.Extension; + public override Type Type => typeof(bool); +} diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 7e74634..c36c616 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -24,13 +24,19 @@ public static Expression> ParseFilter(string input, IQueryKitCo 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; try { expr = ExprParser(parameter, config).End().Parse(input); + + // When the parser removed every clause, no clause limits the result + if (expr is RemovedClauseExpression) + { + expr = Expression.Constant(true); + } + expr = ReplaceDerivedProperties(expr, config, parameter); } catch (InvalidOperationException e) @@ -212,7 +218,7 @@ from closingBracket in Parse.Char(']') .XOr(NumberParser.Select(v => new RightSideValue(v, false))) .XOr((RawStringLiteralParser.Or(DoubleQuoteParser)).Select(v => new RightSideValue(v, true))) .XOr(SquareBracketParser.Select(v => new RightSideValue(v, false))) - .XOr(Identifier.Select(v => new RightSideValue(v, false))); // Keep this last to try property paths only if nothing else matches + .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 private static readonly Parser RightSideValueParser = from atSign in Parse.Char('@').Optional() @@ -592,14 +598,58 @@ 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 leftExpr = temp.leftArithmetic.ToLinqExpression(parameter, typeof(T)); - var rightExpr = temp.rightSide.ToLinqExpression(parameter, typeof(T)); + var leftArithmetic = ResolveArithmeticProperties(temp.leftArithmetic, typeof(T), config); + var rightArithmetic = ResolveArithmeticProperties(temp.rightSide, typeof(T), config); + if (leftArithmetic == null || rightArithmetic == null) + { + return RemovedClauseExpression.Instance; + } + + var leftExpr = leftArithmetic.ToLinqExpression(parameter, typeof(T)); + var rightExpr = rightArithmetic.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 @@ -662,39 +712,37 @@ private static Parser ComparisonExprParser(ParameterExpression pa // Try arithmetic comparison (e.g., (price + tax) > 100) var arithmeticComparison = ArithmeticComparisonExprParser(parameter, config); - var regularComparison = CreateLeftExprParser(parameter, config) - .SelectMany(leftExpr => comparisonOperatorParser, (leftExpr, op) => new { leftExpr, op }) - .SelectMany(temp => rightSideValueParser, (temp, rightValue) => new { temp.leftExpr, temp.op, right = rightValue.Value, rightIsQuotedLiteral = rightValue.IsQuotedLiteral }) + var regularComparison = CreateLeftExprParser(parameter.Type, config) + .SelectMany(reference => comparisonOperatorParser, (reference, op) => new { reference, op }) + .SelectMany(temp => rightSideValueParser, (temp, rightValue) => new { temp.reference, temp.op, right = rightValue.Value, rightIsQuotedLiteral = rightValue.IsQuotedLiteral }) .Select(temp => { - if (temp.leftExpr == null) + if (temp.reference.Kind == PropertyReferenceKind.CustomOperation) { - throw new InvalidOperationException("Left expression cannot be null"); + if (!temp.reference.CanFilter) + { + return RemovedClauseExpression.Instance; + } + + return CreateCustomOperationExpression(parameter, temp.reference.Mapping!, temp.op, temp.right); } - if (temp.leftExpr.NodeType == ExpressionType.Constant && true.Equals(((ConstantExpression)temp.leftExpr).Value)) + if (temp.reference.Kind == PropertyReferenceKind.Unknown) { - return Expression.Equal(Expression.Constant(true), Expression.Constant(true)); + return RemovedClauseExpression.Instance; } - // Check if this is a custom operation placeholder - if (temp.leftExpr.NodeType == ExpressionType.Constant && - ((ConstantExpression)temp.leftExpr).Value is string constantValue && - constantValue.StartsWith("CustomOperation:")) + var leftExpr = CreateLeftExpr(parameter, temp.reference, config); + if (leftExpr is RemovedClauseExpression) { - var operationName = constantValue.Substring("CustomOperation:".Length); - var customOperationInfo = config?.PropertyMappings?.GetCustomOperationInfoByQueryName(operationName); - if (customOperationInfo?.CustomOperation != null) - { - return CreateCustomOperationExpression(parameter, customOperationInfo, temp.op, temp.right); - } + return leftExpr; } - - if (temp.leftExpr.Type == typeof(Guid) || temp.leftExpr.Type == typeof(Guid?)) + + if (leftExpr.Type == typeof(Guid) || leftExpr.Type == typeof(Guid?)) { // Try to determine the property path for HasConversion support string? guidPropertyPath = null; - if (temp.leftExpr is MemberExpression guidMemberExpr) + if (leftExpr is MemberExpression guidMemberExpr) { guidPropertyPath = GetPropertyPath(guidMemberExpr, parameter); } @@ -703,13 +751,13 @@ private static Parser ComparisonExprParser(ParameterExpression pa // For equality/comparison operators, keep as GUID for better EF Core translation if (temp.op.IsStringComparisonOperator()) { - var guidStringExpr = HandleGuidConversion(temp.leftExpr, temp.leftExpr.Type); + var guidStringExpr = HandleGuidConversion(leftExpr, leftExpr.Type); return temp.op.GetExpression(guidStringExpr, CreateRightExpr(guidStringExpr, temp.right, temp.op, config, guidPropertyPath), config?.DbContextType, ResolveCaseMode(guidPropertyPath, config)); } // For non-string operators, use direct GUID comparison - return temp.op.GetExpression(temp.leftExpr, CreateRightExpr(temp.leftExpr, temp.right, temp.op, config, guidPropertyPath), + return temp.op.GetExpression(leftExpr, CreateRightExpr(leftExpr, temp.right, temp.op, config, guidPropertyPath), config?.DbContextType); } @@ -717,17 +765,22 @@ 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 RemovedClauseExpression.Instance; + } + var rightPropertyExpr = CreateRightPropertyExpr(parameter, temp.right, config); if (rightPropertyExpr != null) { // Handle GUID conversion for property-to-property comparisons // Only convert to string for string operators - var leftExpr = temp.leftExpr; + var comparedLeftExpr = leftExpr; if (temp.op.IsStringComparisonOperator()) { - if (leftExpr.Type == typeof(Guid) || leftExpr.Type == typeof(Guid?)) + if (comparedLeftExpr.Type == typeof(Guid) || comparedLeftExpr.Type == typeof(Guid?)) { - leftExpr = HandleGuidConversion(leftExpr, leftExpr.Type); + comparedLeftExpr = HandleGuidConversion(comparedLeftExpr, comparedLeftExpr.Type); } if (rightPropertyExpr.Type == typeof(Guid) || rightPropertyExpr.Type == typeof(Guid?)) { @@ -736,20 +789,20 @@ private static Parser ComparisonExprParser(ParameterExpression pa } // Ensure compatible types for property-to-property comparison - var (leftCompatible, rightCompatible) = EnsureCompatibleTypes(leftExpr, rightPropertyExpr); - var propToProptPath = temp.leftExpr is MemberExpression ptpMemberExpr ? GetPropertyPath(ptpMemberExpr, parameter) : null; + var (leftCompatible, rightCompatible) = EnsureCompatibleTypes(comparedLeftExpr, rightPropertyExpr); + var propToProptPath = leftExpr is MemberExpression ptpMemberExpr ? GetPropertyPath(ptpMemberExpr, parameter) : null; return temp.op.GetExpression(leftCompatible, rightCompatible, config?.DbContextType, ResolveCaseMode(propToProptPath, config)); } } // Try to determine the property path for HasConversion support string? propertyPath = null; - if (temp.leftExpr is MemberExpression memberExpr) + if (leftExpr is MemberExpression memberExpr) { propertyPath = GetPropertyPath(memberExpr, parameter); } - var leftExprForComparison = temp.leftExpr; + var leftExprForComparison = leftExpr; // If the left expression is a conditional with Object type, convert it to the proper type if (leftExprForComparison.Type == typeof(object)) @@ -834,153 +887,58 @@ private static Parser ComparisonExprParser(ParameterExpression pa return propertyListComparison.Or(arithmeticComparison).Or(regularComparison); } - private static Parser? CreateLeftExprParser(ParameterExpression parameter, IQueryKitConfiguration? config) + private static Parser CreateLeftExprParser(Type entityType, IQueryKitConfiguration? config) { var leftIdentifierParser = Identifier.DelimitedBy(Parse.Char('.')).Token(); - return leftIdentifierParser?.Select(left => + return leftIdentifierParser.Select(left => { - var leftList = left.ToList(); - var fullPropPath = string.Join(".", leftList); - - // Validate property depth before processing - config?.ValidatePropertyDepth(fullPropPath); - var propertyExpression = leftList?.Aggregate((Expression)parameter, (expr, propName) => + var reference = PropertyResolver.Resolve(entityType, string.Join(".", left), config); + if (reference.Kind == PropertyReferenceKind.Unknown && config?.AllowUnknownProperties != true) { - if (expr is MemberExpression member) - { - if (IsEnumerable(member.Type)) - { - var genericArgType = member.Type.GetGenericArguments()[0]; - var propertyType = genericArgType.GetProperty(propName)!.PropertyType; - - if (IsEnumerable(propertyType)) - { - propertyType = propertyType.GetGenericArguments()[0]; - - var linqMethod = "SelectMany"; - var selectMethod = typeof(Enumerable).GetMethods() - .First(m => m.Name == linqMethod && m.GetParameters().Length == 2) - .MakeGenericMethod(genericArgType, propertyType); - - var innerParameter = Expression.Parameter(genericArgType, "y"); - var propertyInfoForMethod = GetPropertyInfo(genericArgType, propName); - Expression lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod!.Name); - - // Ensure the lambda body returns IEnumerable for SelectMany - var expectedType = typeof(IEnumerable<>).MakeGenericType(propertyType); - if (lambdaBody.Type != expectedType && !expectedType.IsAssignableFrom(lambdaBody.Type)) - { - // Convert to IEnumerable if needed (e.g., List to IEnumerable) - lambdaBody = Expression.Convert(lambdaBody, expectedType); - } - - // Create lambda with the correct return type - var lambdaType = typeof(Func<,>).MakeGenericType(genericArgType, expectedType); - lambdaBody = Expression.Lambda(lambdaType, lambdaBody, innerParameter); - - return Expression.Call(selectMethod, member, lambdaBody); - } - else - { - var selectMethod = typeof(Enumerable).GetMethods() - .First(m => m.Name == "Select" && m.GetParameters().Length == 2) - .MakeGenericMethod(genericArgType, genericArgType.GetProperty(propName)!.PropertyType); - - var innerParameter = Expression.Parameter(genericArgType, "y"); - var propertyInfoForMethod = GetPropertyInfo(genericArgType, propName); - var lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod!.Name); - var selectLambda = Expression.Lambda(lambdaBody, innerParameter); - var selectResult = Expression.Call(null, selectMethod, member, selectLambda); - - return HandleGuidConversion(selectResult, propertyType, "Select"); - } - } - } - - if (expr is MethodCallExpression call) - { - var innerGenericType = GetInnerGenericType(call.Method.ReturnType); - var propertyInfoForMethod = GetPropertyInfo(innerGenericType!, propName); - - var propertyType = propertyInfoForMethod!.PropertyType; - var linqMethod = IsEnumerable(propertyType) ? "SelectMany" : "Select"; - var resultType = IsEnumerable(propertyType) ? propertyType.GetGenericArguments()[0] : propertyType; - - var selectMethod = typeof(Enumerable).GetMethods() - .First(m => m.Name == linqMethod && m.GetParameters().Length == 2) - .MakeGenericMethod(innerGenericType!, resultType); - - var innerParameter = Expression.Parameter(innerGenericType!, "y"); - var lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod.Name); - var selectLambda = Expression.Lambda(lambdaBody, innerParameter); - - return Expression.Call(selectMethod, expr, selectLambda); - } - - var propertyInfo = GetPropertyInfo(expr.Type, propName); - var actualPropertyName = propertyInfo?.Name ?? propName; - try - { - return Expression.PropertyOrField(expr, actualPropertyName); - } - catch(ArgumentException) - { - // Check for custom operations first - var customOperationInfo = config?.PropertyMappings?.GetCustomOperationInfoByQueryName(fullPropPath); - if (customOperationInfo?.CustomOperation != null) - { - // Custom operations will be handled in the comparison parsing, so return a placeholder - return Expression.Constant($"CustomOperation:{fullPropPath}", typeof(string)); - } + throw new UnknownFilterPropertyException(reference.UnknownSegment!); + } - var derivedPropertyInfo = config?.PropertyMappings?.GetDerivedPropertyInfoByQueryName(fullPropPath); - if (derivedPropertyInfo?.DerivedExpression != null) - { - return derivedPropertyInfo.DerivedExpression; - } - - if(config?.AllowUnknownProperties == true) - { - return Expression.Constant(true, typeof(bool)); - } + return reference; + }); + } - throw new UnknownFilterPropertyException(actualPropertyName); - } - }); + 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 = config?.PropertyMappings?.GetPropertyInfo(fullPropPath); - if (propertyConfig != null && !propertyConfig.CanFilter) - { - return Expression.Constant(true, typeof(bool)); - } + var propertyConfig = reference.Mapping; + if (propertyConfig != null && !propertyConfig.CanFilter) + { + return RemovedClauseExpression.Instance; + } - // Check if this property uses HasConversion - var currentPropertyConfig = config?.PropertyMappings?.GetPropertyInfo(fullPropPath); - if (currentPropertyConfig?.UsesConversion == true) - { - // For HasConversion properties, return the property expression as-is - // EF Core will handle the type conversion automatically when it translates the expression to SQL - // The key is that the right-side value will be converted to match the property's conversion target type - return propertyExpression; - } + // Check if this property uses HasConversion + if (propertyConfig?.UsesConversion == true) + { + // For HasConversion properties, return the property expression as-is + // EF Core will handle the type conversion automatically when it translates the expression to SQL + // The key is that the right-side value will be converted to match the property's conversion target type + return propertyExpression; + } + + // Also check if this is a nested property where the parent has HasConversion configured + if (propertyExpression is MemberExpression nestedMemberExpression && + nestedMemberExpression.Expression is MemberExpression parentExpression) + { + var parentPropertyPath = GetPropertyPath(parentExpression, parameter); + var parentPropertyConfig = config?.PropertyMappings?.GetPropertyInfo(parentPropertyPath); - // Also check if this is a nested property where the parent has HasConversion configured - if (propertyExpression is MemberExpression nestedMemberExpression && - nestedMemberExpression.Expression is MemberExpression parentExpression) + if (parentPropertyConfig?.UsesConversion == true) { - var parentPropertyPath = GetPropertyPath(parentExpression, parameter); - var parentPropertyConfig = config?.PropertyMappings?.GetPropertyInfo(parentPropertyPath); - - if (parentPropertyConfig?.UsesConversion == true) - { - // Use the parent expression instead of the nested property - return parentExpression; - } + // Use the parent expression instead of the nested property + return parentExpression; } + } - return propertyExpression; - }); + return propertyExpression; } private static string GetPropertyPath(MemberExpression memberExpression, ParameterExpression parameter) @@ -1001,107 +959,69 @@ private static string GetPropertyPath(MemberExpression memberExpression, Paramet return string.Join(".", parts); } - private static Expression CreatePropertyExpressionFromPath( - ParameterExpression parameter, - List propertyPath, - IQueryKitConfiguration? config) + // Builds the access expression for a resolved member path. A member of a collection element becomes a Select, or a SelectMany when the member is a collection too. + private static Expression CreateMemberExpression(ParameterExpression parameter, string memberPath) { - var fullPropPath = string.Join(".", propertyPath); - - // Validate property depth before processing - config?.ValidatePropertyDepth(fullPropPath); - - return propertyPath.Aggregate((Expression)parameter, (expr, propName) => + return memberPath.Split('.').Aggregate((Expression)parameter, (expr, memberName) => { - if (expr is MemberExpression member) + if (expr is MemberExpression member && IsEnumerable(member.Type)) { - if (IsEnumerable(member.Type)) - { - var genericArgType = member.Type.GetGenericArguments()[0]; - var propertyType = genericArgType.GetProperty(propName)!.PropertyType; - - if (IsEnumerable(propertyType)) - { - propertyType = propertyType.GetGenericArguments()[0]; + var genericArgType = member.Type.GetGenericArguments()[0]; + var innerParameter = Expression.Parameter(genericArgType, "y"); + Expression lambdaBody = Expression.PropertyOrField(innerParameter, memberName); + var propertyType = lambdaBody.Type; - var linqMethod = "SelectMany"; - var selectMethod = typeof(Enumerable).GetMethods() - .First(m => m.Name == linqMethod && m.GetParameters().Length == 2) - .MakeGenericMethod(genericArgType, propertyType); + if (IsEnumerable(propertyType)) + { + propertyType = propertyType.GetGenericArguments()[0]; - var innerParameter = Expression.Parameter(genericArgType, "y"); - var propertyInfoForMethod = GetPropertyInfo(genericArgType, propName); - Expression lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod!.Name); + var selectManyMethod = typeof(Enumerable).GetMethods() + .First(m => m.Name == "SelectMany" && m.GetParameters().Length == 2) + .MakeGenericMethod(genericArgType, propertyType); - var expectedType = typeof(IEnumerable<>).MakeGenericType(propertyType); - if (lambdaBody.Type != expectedType && !expectedType.IsAssignableFrom(lambdaBody.Type)) - { - lambdaBody = Expression.Convert(lambdaBody, expectedType); - } + // Ensure the lambda body returns IEnumerable for SelectMany + var expectedType = typeof(IEnumerable<>).MakeGenericType(propertyType); + if (lambdaBody.Type != expectedType && !expectedType.IsAssignableFrom(lambdaBody.Type)) + { + // Convert to IEnumerable if needed (e.g., List to IEnumerable) + lambdaBody = Expression.Convert(lambdaBody, expectedType); + } - var lambdaType = typeof(Func<,>).MakeGenericType(genericArgType, expectedType); - lambdaBody = Expression.Lambda(lambdaType, lambdaBody, innerParameter); + // Create lambda with the correct return type + var lambdaType = typeof(Func<,>).MakeGenericType(genericArgType, expectedType); + return Expression.Call(selectManyMethod, member, Expression.Lambda(lambdaType, lambdaBody, innerParameter)); + } - return Expression.Call(selectMethod, member, lambdaBody); - } - else - { - var selectMethod = typeof(Enumerable).GetMethods() - .First(m => m.Name == "Select" && m.GetParameters().Length == 2) - .MakeGenericMethod(genericArgType, genericArgType.GetProperty(propName)!.PropertyType); + var selectMethod = typeof(Enumerable).GetMethods() + .First(m => m.Name == "Select" && m.GetParameters().Length == 2) + .MakeGenericMethod(genericArgType, propertyType); - var innerParameter = Expression.Parameter(genericArgType, "y"); - var propertyInfoForMethod = GetPropertyInfo(genericArgType, propName); - var lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod!.Name); - var selectLambda = Expression.Lambda(lambdaBody, innerParameter); - var selectResult = Expression.Call(null, selectMethod, member, selectLambda); + var selectLambda = Expression.Lambda(lambdaBody, innerParameter); + var selectResult = Expression.Call(null, selectMethod, member, selectLambda); - return HandleGuidConversion(selectResult, propertyType, "Select"); - } - } + return HandleGuidConversion(selectResult, propertyType, "Select"); } if (expr is MethodCallExpression call) { - var innerGenericType = GetInnerGenericType(call.Method.ReturnType); - var propertyInfoForMethod = GetPropertyInfo(innerGenericType!, propName); + var innerGenericType = GetInnerGenericType(call.Method.ReturnType)!; + var innerParameter = Expression.Parameter(innerGenericType, "y"); + var lambdaBody = Expression.PropertyOrField(innerParameter, memberName); - var propertyType = propertyInfoForMethod!.PropertyType; + var propertyType = lambdaBody.Type; var linqMethod = IsEnumerable(propertyType) ? "SelectMany" : "Select"; var resultType = IsEnumerable(propertyType) ? propertyType.GetGenericArguments()[0] : propertyType; var selectMethod = typeof(Enumerable).GetMethods() .First(m => m.Name == linqMethod && m.GetParameters().Length == 2) - .MakeGenericMethod(innerGenericType!, resultType); + .MakeGenericMethod(innerGenericType, resultType); - var innerParameter = Expression.Parameter(innerGenericType!, "y"); - var lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod.Name); var selectLambda = Expression.Lambda(lambdaBody, innerParameter); return Expression.Call(selectMethod, expr, selectLambda); } - var propertyInfo = GetPropertyInfo(expr.Type, propName); - var actualPropertyName = propertyInfo?.Name ?? propName; - try - { - return Expression.PropertyOrField(expr, actualPropertyName); - } - catch(ArgumentException) - { - var derivedPropertyInfo = config?.PropertyMappings?.GetDerivedPropertyInfoByQueryName(fullPropPath); - if (derivedPropertyInfo?.DerivedExpression != null) - { - return derivedPropertyInfo.DerivedExpression; - } - - if(config?.AllowUnknownProperties == true) - { - return Expression.Constant(true, typeof(bool)); - } - - throw new UnknownFilterPropertyException(actualPropertyName); - } + return Expression.PropertyOrField(expr, memberName); }); } @@ -1135,43 +1055,27 @@ private static Parser PropertyListComparisonExprParser( var propertyPathList = propertyPath.ToList(); var fullPropPath = string.Join(".", propertyPathList); - // Check if property can be filtered - var propertyConfig = config?.PropertyMappings?.GetPropertyInfo(fullPropPath); - if (propertyConfig != null && !propertyConfig.CanFilter) + // Build expression for each property. A property list does not support custom operations. + var reference = PropertyResolver.Resolve(parameter.Type, fullPropPath, config); + if (!reference.CanFilter) { continue; } - // Build expression for each property - var leftExpr = CreatePropertyExpressionFromPath( - parameter, propertyPathList, config); - - // Skip if it's a placeholder for unknown properties - if (leftExpr.NodeType == ExpressionType.Constant && - ((ConstantExpression)leftExpr).Value!.Equals(true)) + if (reference.Kind is PropertyReferenceKind.Unknown or PropertyReferenceKind.CustomOperation) { - continue; - } - - // Handle custom operations - if (leftExpr.NodeType == ExpressionType.Constant && - ((ConstantExpression)leftExpr).Value is string constantValue && - constantValue.StartsWith("CustomOperation:")) - { - var operationName = constantValue.Substring("CustomOperation:".Length); - var customOperationInfo = config?.PropertyMappings?.GetCustomOperationInfoByQueryName(operationName); - if (customOperationInfo?.CustomOperation != null) + if (config?.AllowUnknownProperties == true) { - var customComparison = CreateCustomOperationExpression(parameter, customOperationInfo, temp.op, temp.right); - result = result == null - ? customComparison - : isNegativeOperator - ? Expression.AndAlso(result, customComparison) - : Expression.OrElse(result, customComparison); continue; } + + throw new UnknownFilterPropertyException(reference.UnknownSegment!); } + var leftExpr = reference.Kind == PropertyReferenceKind.DerivedProperty + ? 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) @@ -1185,7 +1089,7 @@ private static Parser PropertyListComparisonExprParser( } var rightExpr = CreateRightExpr(leftExpr, temp.right, temp.op, config, resolvedPropPath); - var comparison = temp.op.GetExpression(leftExpr, rightExpr, config?.DbContextType, ResolveCaseMode(fullPropPath, config)); + var comparison = temp.op.GetExpression(leftExpr, rightExpr, config?.DbContextType, ResolveCaseMode(reference.Path, config)); // Combine with AND for negative operators, OR for positive operators result = result == null @@ -1195,8 +1099,8 @@ private static Parser PropertyListComparisonExprParser( : Expression.OrElse(result, comparison); } - // If all properties were filtered out, return true - return result ?? Expression.Constant(true, typeof(bool)); + // If all properties were filtered out, remove the clause + return result ?? RemovedClauseExpression.Instance; }); } @@ -1222,15 +1126,31 @@ private static Parser AndExprParser(ParameterExpression parameter => Parse.ChainOperator( LogicalOperatorParser.Where(x => x.Name == LogicalOperator.AndOperator.Operator()), AtomicExprParser(parameter, config), - (op, left, right) => op.GetExpression(left, right) + CombineClauses ); private static Parser OrExprParser(ParameterExpression parameter, IQueryKitConfiguration? config = null) => Parse.ChainOperator( LogicalOperatorParser.Where(x => x.Name == LogicalOperator.OrOperator.Operator()), AndExprParser(parameter, config), - (op, left, right) => op.GetExpression(left, right) + CombineClauses ); + + // A removed clause has no effect, so the operator keeps only the other side + private static Expression CombineClauses(LogicalOperator op, Expression left, Expression right) + { + if (left is RemovedClauseExpression) + { + return right; + } + + if (right is RemovedClauseExpression) + { + return left; + } + + return op.GetExpression(left, right); + } private static Expression GetGuidToStringExpression(Expression leftExpr) { diff --git a/QueryKit/PropertyResolver.cs b/QueryKit/PropertyResolver.cs new file mode 100644 index 0000000..8db8a0c --- /dev/null +++ b/QueryKit/PropertyResolver.cs @@ -0,0 +1,120 @@ +namespace QueryKit; + +using System.Reflection; +using Configuration; + +internal enum PropertyReferenceKind +{ + Member, + DerivedProperty, + CustomOperation, + Unknown +} + +/// +/// A property reference from a filter or sort string, resolved against the entity type and the configuration. +/// +internal sealed class PropertyReference +{ + private PropertyReference(PropertyReferenceKind kind, string text, string path, QueryKitPropertyInfo? mapping, string? unknownSegment) + { + Kind = kind; + Text = text; + Path = path; + Mapping = mapping; + UnknownSegment = unknownSegment; + } + + public PropertyReferenceKind Kind { get; } + + /// The reference as the caller wrote it. + public string Text { get; } + + /// For a member, the real member names joined with '.'. For other kinds, the reference text. + public string Path { get; } + + /// 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; } + + internal static PropertyReference Member(string text, string path, QueryKitPropertyInfo? mapping) + => new(PropertyReferenceKind.Member, text, path, mapping, null); + + internal static PropertyReference NotMember(PropertyReferenceKind kind, string text, QueryKitPropertyInfo? mapping, string unknownSegment) + => new(kind, text, text, mapping, unknownSegment); +} + +/// +/// Resolves every property reference in a filter or sort string the same way. +/// +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); + + var memberPath = ResolveMemberPath(rootType, path, out var unknownSegment); + if (memberPath != null) + { + return PropertyReference.Member(reference, memberPath, config?.PropertyMappings?.GetPropertyInfo(memberPath)); + } + + var customOperationInfo = config?.PropertyMappings?.GetCustomOperationInfoByQueryName(reference); + if (customOperationInfo?.CustomOperation != null) + { + return PropertyReference.NotMember(PropertyReferenceKind.CustomOperation, reference, customOperationInfo, unknownSegment!); + } + + var derivedPropertyInfo = config?.PropertyMappings?.GetDerivedPropertyInfoByQueryName(reference); + if (derivedPropertyInfo?.DerivedExpression != null) + { + return PropertyReference.NotMember(PropertyReferenceKind.DerivedProperty, reference, derivedPropertyInfo, unknownSegment!); + } + + return PropertyReference.NotMember(PropertyReferenceKind.Unknown, reference, null, unknownSegment!); + } + + // Matches each segment to a public member, ignoring case. A segment after a collection resolves on the element type. + private static string? ResolveMemberPath(Type rootType, string path, out string? unknownSegment) + { + var memberNames = new List(); + var currentType = rootType; + + foreach (var segment in path.Split('.')) + { + while (IsCollection(currentType)) + { + currentType = currentType.GetGenericArguments()[0]; + } + + var member = (MemberInfo?)currentType.GetProperty(segment, MemberFlags) + ?? currentType.GetField(segment, MemberFlags); + if (member == null) + { + unknownSegment = segment; + return null; + } + + memberNames.Add(member.Name); + currentType = member is PropertyInfo property ? property.PropertyType : ((FieldInfo)member).FieldType; + } + + unknownSegment = null; + return string.Join(".", memberNames); + } + + private const BindingFlags MemberFlags = BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance; + + private static bool IsCollection(Type type) + => type != typeof(string) && type.IsGenericType && + (type.GetGenericTypeDefinition() == typeof(IEnumerable<>) || + type.GetInterfaces().Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEnumerable<>))); +} diff --git a/QueryKit/QueryKitExtensions.cs b/QueryKit/QueryKitExtensions.cs index eaa7d1a..9c4b5e7 100644 --- a/QueryKit/QueryKitExtensions.cs +++ b/QueryKit/QueryKitExtensions.cs @@ -37,27 +37,22 @@ public static IOrderedQueryable ApplyQueryKitSort(this IQueryable query { var sortLambdas = SortParser.ParseSort(sortExpression, config); - if (sortLambdas.Count == 0) + if (sortLambdas.Count == 0 || sortLambdas[0].Expression is null) return queryable.OrderBy(_ => 0); var firstSortInfo = sortLambdas[0]; - if (firstSortInfo.Expression != null) + var orderedQueryable = firstSortInfo.IsAscending ? queryable.OrderBy(firstSortInfo.Expression!) : queryable.OrderByDescending(firstSortInfo.Expression!); + + for (var i = 1; i < sortLambdas.Count; i++) { - var orderedQueryable = firstSortInfo.IsAscending ? queryable.OrderBy(firstSortInfo.Expression) : queryable.OrderByDescending(firstSortInfo.Expression); - - for (var i = 1; i < sortLambdas.Count; i++) - { - var sortInfo = sortLambdas[i]; - if (sortInfo.Expression != null) - orderedQueryable = sortInfo.IsAscending - ? orderedQueryable.ThenBy(sortInfo.Expression) - : orderedQueryable.ThenByDescending(sortInfo.Expression); - } - - return orderedQueryable; + var sortInfo = sortLambdas[i]; + if (sortInfo.Expression != null) + orderedQueryable = sortInfo.IsAscending + ? orderedQueryable.ThenBy(sortInfo.Expression) + : orderedQueryable.ThenByDescending(sortInfo.Expression); } - - return queryable.OrderBy(x => x); + + return orderedQueryable; } public static IEnumerable ApplyQueryKit( diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index 9408114..216231e 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -136,8 +136,9 @@ public string ReplaceAliasesWithPropertyPaths(string input) { // Use regular expression to isolate left side of the expression. Query names and // operators are matched literally, so escape any regex metacharacters they contain - // (e.g. the `^` in `^^` would otherwise be read as a start-of-line anchor). - var regex = AliasRegexCache.Get($@"\b{Regex.Escape(queryKitPropertyInfo.QueryName!)}\b(?=\s*{Regex.Escape(op)})"); + // (e.g. the `^` in `^^` would otherwise be read as a start-of-line anchor). A query name + // after a dot is a segment of a nested path, so it is not replaced. + var regex = AliasRegexCache.Get($@"(? CreateSortExpression(string sortClause, } var propertyPath = config?.GetPropertyPathByQueryName(propertyName) ?? propertyName; - if (config != null && config.IsPropertySortable(propertyPath) == false) + var reference = PropertyResolver.Resolve(typeof(T), propertyPath, config); + if (reference.Kind != PropertyReferenceKind.CustomOperation && !reference.CanSort) { return new SortExpressionInfo {