diff --git a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs index 31f865c..ce2c908 100644 --- a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs +++ b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs @@ -1,20 +1,22 @@ namespace QueryKit.IntegrationTests.Tests; -using System.Linq.Expressions; using Bogus; using Configuration; using FluentAssertions; using Microsoft.EntityFrameworkCore; using SharedTestingHelper.Fakes; using SharedTestingHelper.Fakes.Author; +using SharedTestingHelper.Fakes.IngredientPreparations; using SharedTestingHelper.Fakes.Ingredients; using SharedTestingHelper.Fakes.Recipes; using WebApiTestProject.Database; using WebApiTestProject.Entities; +using WebApiTestProject.Entities.Ingredients; +using WebApiTestProject.Entities.Ingredients.Models; using WebApiTestProject.Entities.Recipes; -using WebApiTestProject.Features; +using Xunit.Abstractions; -public class DatabaseFilteringTests : TestBase +public class DatabaseFilteringTests(ITestOutputHelper testOutputHelper) : TestBase { [Fact] public async Task can_filter_by_string() @@ -40,6 +42,179 @@ public async Task can_filter_by_string() people[0].Id.Should().Be(fakePersonOne.Id); } + [Fact] + public async Task can_filter_by_boolean() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var faker = new Faker(); + var fakePersonOne = new FakeTestingPersonBuilder() + .WithTitle(faker.Lorem.Sentence()) + .WithFavorite(true) + .Build(); + var fakePersonTwo = new FakeTestingPersonBuilder() + .WithTitle(faker.Lorem.Sentence()) + .WithFavorite(false) + .Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + var input = $"""{nameof(TestingPerson.Title)} == "{fakePersonOne.Title}" && {nameof(TestingPerson.Favorite)} == true"""; + + // Act + var queryablePeople = testingServiceScope.DbContext().People; + var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input); + var people = await appliedQueryable.ToListAsync(); + + // Assert + people.Count.Should().Be(1); + people[0].Id.Should().Be(fakePersonOne.Id); + } + + [Fact] + public async Task can_filter_by_combo_multi_value_pass() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var fakePersonOne = new FakeTestingPersonBuilder().Build(); + var fakePersonTwo = new FakeTestingPersonBuilder() + .WithFirstName(fakePersonOne.FirstName) + .Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + var input = $"""fullname @=* "{fakePersonOne.FirstName} {fakePersonOne.LastName}" """; + var config = new QueryKitConfiguration(config => + { + config.DerivedProperty(tp => tp.FirstName + " " + tp.LastName).HasQueryName("fullname"); + }); + + // 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(fakePersonOne.Id); + } + + [Fact] + public async Task can_filter_by_combo_complex() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var fakePersonOne = new FakeTestingPersonBuilder() + .WithAge(8888) + .Build(); + var fakePersonTwo = new FakeTestingPersonBuilder() + .WithFirstName(fakePersonOne.FirstName) + .Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + var input = $"""(fullname @=* "{fakePersonOne.FirstName} {fakePersonOne.LastName}") && age >= {fakePersonOne.Age}"""; + var config = new QueryKitConfiguration(config => + { + config.DerivedProperty(tp => tp.FirstName + " " + tp.LastName).HasQueryName("fullname"); + }); + + // 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(fakePersonOne.Id); + } + + [Theory] + [InlineData(88448)] + [InlineData(-83388)] + public async Task can_filter_by_int(int age) + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var fakePersonOne = new FakeTestingPersonBuilder() + .WithAge(age) + .Build(); + var fakePersonTwo = new FakeTestingPersonBuilder() + .WithFirstName(fakePersonOne.FirstName) + .Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + var input = $"""age == {fakePersonOne.Age}"""; + var config = new QueryKitConfiguration(_ => + { + }); + + // 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(fakePersonOne.Id); + } + + [Fact] + public async Task can_filter_by_and_also_bool() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var fakePersonOne = new FakeTestingPersonBuilder() + .WithFirstName("John") + .WithAge(18) + .Build(); + var fakePersonTwo = new FakeTestingPersonBuilder().Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + var input = $"""adult_johns == true"""; + var config = new QueryKitConfiguration(config => + { + config.DerivedProperty(tp => tp.Age >= 18 && tp.FirstName == "John").HasQueryName("adult_johns"); + }); + + // 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(fakePersonOne.Id); + } + + [Fact] + public async Task can_filter_by_combo() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var fakePersonOne = new FakeTestingPersonBuilder() + .WithFirstName(Guid.NewGuid().ToString()) + .Build(); + var fakePersonTwo = new FakeTestingPersonBuilder().Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + var input = $"""fullname @=* "{fakePersonOne.FirstName}" """; + var config = new QueryKitConfiguration(config => + { + config.DerivedProperty(tp => tp.FirstName + " " + tp.LastName).HasQueryName("fullname"); + }); + + // Act + var queryablePeople = testingServiceScope.DbContext().People; + var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input, config); + var people = await appliedQueryable.ToListAsync(); + // var people = testingServiceScope.DbContext().People + // // .Where(p => (p.FirstName + " " + p.LastName).ToLower().Contains(fakePersonOne.FirstName.ToLower())) + // // .Where(x => ((x.FirstName + " ") + x.LastName).ToLower().Contains("ito".ToLower())) + // .ToList(); + + // Assert + people.Count.Should().Be(1); + people[0].Id.Should().Be(fakePersonOne.Id); + } + [Fact] public async Task can_filter_by_string_for_collection() { @@ -164,6 +339,71 @@ public async Task can_filter_by_string_for_collection_contains() recipes[0].Id.Should().Be(fakeRecipeOne.Id); } + [Fact] + public async Task can_filter_within_collection_long() + { + var testingServiceScope = new TestingServiceScope(); + var qualityLevel = 2L; + var fakeRecipeOne = new FakeRecipeBuilder().Build(); + var ingredient = new FakeIngredientBuilder() + .WithQualityLevel(qualityLevel) + .Build(); + fakeRecipeOne.AddIngredient(ingredient); + + await testingServiceScope.InsertAsync(fakeRecipeOne); + + var input = $"Ingredients.QualityLevel == {qualityLevel}"; + var config = new QueryKitConfiguration(settings => + { + settings.Property(x => x.Ingredients.Select(y => y.QualityLevel)).PreventSort(); + }); + + var queryableRecipes = testingServiceScope.DbContext().Recipes; + var appliedQueryable = queryableRecipes.ApplyQueryKitFilter(input); + var recipes = await appliedQueryable.ToListAsync(); + + recipes.Count.Should().Be(1); + recipes[0].Id.Should().Be(fakeRecipeOne.Id); + } + + [Fact(Skip = "Can not handle nested collections yet.")] + public async Task can_filter_by_string_for_nested_collection() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var faker = new Faker(); + var preparationOne = new FakeIngredientPreparation().Generate(); + var preparationTwo = new FakeIngredientPreparation().Generate(); + var fakeIngredientOne = new FakeIngredientBuilder() + .WithPreparation(preparationOne) + .Build(); + var fakeRecipeOne = new FakeRecipeBuilder().Build(); + fakeRecipeOne.AddIngredient(fakeIngredientOne); + + var fakeIngredientTwo = new FakeIngredientBuilder() + .WithName(faker.Lorem.Sentence()) + .WithPreparation(preparationTwo) + .Build(); + var fakeRecipeTwo = new FakeRecipeBuilder().Build(); + fakeRecipeTwo.AddIngredient(fakeIngredientTwo); + await testingServiceScope.InsertAsync(fakeRecipeOne, fakeRecipeTwo); + + var input = $"""Ingredients.Preparations.Text == "{preparationOne.Text}" """; + var config = new QueryKitConfiguration(settings => + { + settings.Property(x => x.Ingredients.SelectMany(y => y.Preparations).Select(y => y.Text)); + }); + + // Act + var queryableRecipes = testingServiceScope.DbContext().Recipes; + var appliedQueryable = queryableRecipes.ApplyQueryKitFilter(input, config); + var recipes = await appliedQueryable.ToListAsync(); + + // Assert + recipes.Count.Should().Be(1); + recipes[0].Id.Should().Be(fakeRecipeOne.Id); + } + [Fact] public async Task can_filter_by_string_for_collection_does_not_contain() { @@ -577,7 +817,7 @@ public async Task can_filter_nested_property_using_ownsone_with_alias() .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(); @@ -621,6 +861,31 @@ public async Task can_filter_by_decimal() people.Count(x => x.Id == fakePersonOne.Id).Should().Be(1); } + [Fact] + public async Task can_filter_by_negative_decimal() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var faker = new Faker(); + var fakePersonOne = new FakeTestingPersonBuilder() + .WithRating(-3.533M) + .Build(); + var fakePersonTwo = new FakeTestingPersonBuilder() + .WithRating(2M) + .Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + + var input = $"""{nameof(TestingPerson.Rating)} == -3.533"""; + + // Act + var queryablePeople = testingServiceScope.DbContext().People; + var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input); + var people = await appliedQueryable.ToListAsync(); + + // Assert + people.Count(x => x.Id == fakePersonOne.Id).Should().Be(1); + } + [Fact] public async Task can_filter_complex_expression() { @@ -687,14 +952,17 @@ public async Task can_handle_in_for_int() // Arrange var testingServiceScope = new TestingServiceScope(); var fakePersonOne = new FakeTestingPersonBuilder() - .WithAge(22) + .WithAge(-22) .Build(); var fakePersonTwo = new FakeTestingPersonBuilder() + .WithAge(40) + .Build(); + var fakePersonThree = new FakeTestingPersonBuilder() .WithAge(60) .Build(); - await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo, fakePersonThree); - var input = """Age ^^ [22, 30, 40]"""; + var input = """Age ^^ [-22, 60]"""; // Act var queryablePeople = testingServiceScope.DbContext().People; @@ -702,9 +970,40 @@ public async Task can_handle_in_for_int() var people = await appliedQueryable.ToListAsync(); // Assert - people.Count.Should().BeGreaterOrEqualTo(1); + people.Count.Should().BeGreaterOrEqualTo(2); + people.FirstOrDefault(x => x.Id == fakePersonOne.Id).Should().NotBeNull(); + people.FirstOrDefault(x => x.Id == fakePersonTwo.Id).Should().BeNull(); + people.FirstOrDefault(x => x.Id == fakePersonThree.Id).Should().NotBeNull(); + } + + [Fact] + public async Task can_handle_in_for_decimal() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var fakePersonOne = new FakeTestingPersonBuilder() + .WithRating(-22.44M) + .Build(); + var fakePersonTwo = new FakeTestingPersonBuilder() + .WithRating(40.55M) + .Build(); + var fakePersonThree = new FakeTestingPersonBuilder() + .WithRating(60.99M) + .Build(); + await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo, fakePersonThree); + + var input = """Rating ^^ [-22.44, 60.99]"""; + + // Act + var queryablePeople = testingServiceScope.DbContext().People; + var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input); + var people = await appliedQueryable.ToListAsync(); + + // Assert + people.Count.Should().BeGreaterOrEqualTo(2); people.FirstOrDefault(x => x.Id == fakePersonOne.Id).Should().NotBeNull(); people.FirstOrDefault(x => x.Id == fakePersonTwo.Id).Should().BeNull(); + people.FirstOrDefault(x => x.Id == fakePersonThree.Id).Should().NotBeNull(); } [Fact] diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 4c7adb8..13d3851 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -488,7 +488,7 @@ public void can_throw_error_when_comparison_operator_not_recognized() var input = $"""Age ^#$%^%@ 25"""; var act = () => FilterParser.ParseFilter(input); act.Should().Throw() - .WithMessage("There was a parsing failure, likely due to an invalid comparison or logical operator. You may also be missing double quotes surrounding a string or guid."); + .WithMessage("There was a parsing failure, likely due to an invalid comparison or logical operator. You may also be missing double quotes surrounding a string or guid.*"); } [Fact] @@ -497,7 +497,7 @@ public void can_throw_error_when_logical_operator_not_recognized() var input = $"""Title == "temp" %$@#^ Age == 25"""; var act = () => FilterParser.ParseFilter(input); act.Should().Throw() - .WithMessage("There was a parsing failure, likely due to an invalid comparison or logical operator. You may also be missing double quotes surrounding a string or guid."); + .WithMessage("There was a parsing failure, likely due to an invalid comparison or logical operator. You may also be missing double quotes surrounding a string or guid.*"); } [Fact] @@ -506,7 +506,7 @@ public void can_throw_error_when_missing_double_quotes_not_recognized() var input = $"""Title == temp string here"""; var act = () => FilterParser.ParseFilter(input); act.Should().Throw() - .WithMessage("There was a parsing failure, likely due to an invalid comparison or logical operator. You may also be missing double quotes surrounding a string or guid."); + .WithMessage("There was a parsing failure, likely due to an invalid comparison or logical operator. You may also be missing double quotes surrounding a string or guid.*"); } [Fact] @@ -518,43 +518,7 @@ public void can_throw_error_when_property_has_space() var input = $"""{propertyName} == 25"""; var act = () => FilterParser.ParseFilter(input); act.Should().Throw() - .WithMessage($"The filter property '{firstWord}' was not recognized."); - } - - [Fact] - public void can_filter_within_collection() - { - var faker = new Faker(); - var ingredientName = faker.Lorem.Sentence(); - var fakeRecipeOne = new FakeRecipeBuilder().Build(); - fakeRecipeOne.AddIngredient(Ingredient.Create(new IngredientForCreation(){Name = ingredientName})); - var input = $"Ingredients.Name == \"{ingredientName}\""; - var config = new QueryKitConfiguration(settings => - { - settings.Property(x => x.Ingredients.Select(y => y.Name)).PreventSort(); - }); - var filterExpression = FilterParser.ParseFilter(input, config); - - filterExpression.Compile().Invoke(fakeRecipeOne).Should().BeTrue(); - } - - [Fact] - public void can_filter_within_nested_collection() - { - var faker = new Faker(); - var ingredientName = faker.Lorem.Sentence(); - var prepText = faker.Lorem.Sentence(); - var fakeRecipeOne = new FakeRecipeBuilder().Build(); - fakeRecipeOne.AddIngredient(Ingredient.Create(new IngredientForCreation(){Name = ingredientName})); - fakeRecipeOne.Ingredients.First().Preparations.Add(new IngredientPreparation() { Text = prepText }); - var input = $"Ingredients.Preparations.Text == \"{prepText}\""; - var config = new QueryKitConfiguration(settings => - { - settings.Property(x => x.Ingredients.SelectMany(y => y.Preparations).Select(y=> y.Text)).PreventSort(); - }); - var filterExpression = FilterParser.ParseFilter(input, config); - - filterExpression.Compile().Invoke(fakeRecipeOne).Should().BeTrue(); + .WithMessage($"The filter property '{firstWord}' was not recognized.*"); } [Fact] @@ -788,7 +752,7 @@ public void can_throw_exception_when_invalid_enum_value() var input = $"""BirthMonth == invalid"""; var act = () => FilterParser.ParseFilter(input); act.Should().Throw() - .WithMessage("There was a parsing failure, likely due to an invalid comparison or logical operator. You may also be missing double quotes surrounding a string or guid."); + .WithMessage("There was a parsing failure, likely due to an invalid comparison or logical operator. You may also be missing double quotes surrounding a string or guid.*"); } } \ No newline at end of file diff --git a/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs b/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs index 194e6f5..96bd88e 100644 --- a/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs +++ b/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs @@ -16,6 +16,7 @@ public class Ingredient : BaseEntity public string Name { get; private set; } public string Quantity { get; private set; } + public long? QualityLevel { get; set; } public DateTime? ExpiresOn { get; private set; } @@ -39,6 +40,7 @@ public static Ingredient Create(IngredientForCreation ingredientForCreation) newIngredient.ExpiresOn = ingredientForCreation.ExpiresOn; newIngredient.Measure = ingredientForCreation.Measure; newIngredient.RecipeId = ingredientForCreation.RecipeId; + newIngredient.QualityLevel = ingredientForCreation.QualityLevel; return newIngredient; } @@ -49,6 +51,7 @@ public Ingredient Update(IngredientForUpdate ingredientForUpdate) ExpiresOn = ingredientForUpdate.ExpiresOn; Measure = ingredientForUpdate.Measure; RecipeId = ingredientForUpdate.RecipeId; + QualityLevel = ingredientForUpdate.QualityLevel; return this; } diff --git a/QueryKit.WebApiTestProject/Entities/Ingredients/Models/IngredientForCreation.cs b/QueryKit.WebApiTestProject/Entities/Ingredients/Models/IngredientForCreation.cs index fb576f4..b5eec87 100644 --- a/QueryKit.WebApiTestProject/Entities/Ingredients/Models/IngredientForCreation.cs +++ b/QueryKit.WebApiTestProject/Entities/Ingredients/Models/IngredientForCreation.cs @@ -6,5 +6,6 @@ public sealed class IngredientForCreation public string Quantity { get; set; } public DateTime? ExpiresOn { get; set; } public string Measure { get; set; } + public long? QualityLevel { get; set; } public Guid RecipeId { get; set; } } diff --git a/QueryKit.WebApiTestProject/Entities/Ingredients/Models/IngredientForUpdate.cs b/QueryKit.WebApiTestProject/Entities/Ingredients/Models/IngredientForUpdate.cs index 1ea94ba..c1aa08d 100644 --- a/QueryKit.WebApiTestProject/Entities/Ingredients/Models/IngredientForUpdate.cs +++ b/QueryKit.WebApiTestProject/Entities/Ingredients/Models/IngredientForUpdate.cs @@ -6,5 +6,6 @@ public sealed class IngredientForUpdate public string Quantity { get; set; } public DateTime? ExpiresOn { get; set; } public string Measure { get; set; } + public long? QualityLevel { get; set; } public Guid RecipeId { get; set; } } diff --git a/QueryKit.WebApiTestProject/Entities/TestingPerson.cs b/QueryKit.WebApiTestProject/Entities/TestingPerson.cs index 1dc8e89..664108e 100644 --- a/QueryKit.WebApiTestProject/Entities/TestingPerson.cs +++ b/QueryKit.WebApiTestProject/Entities/TestingPerson.cs @@ -2,7 +2,9 @@ namespace QueryKit.WebApiTestProject.Entities; public class TestingPerson { - public string? Title { get; set; } + public string? Title { get; set; } = default!; + public string? FirstName { get; set; } = default!; + public string? LastName { get; set; } = default!; public int? Age { get; set; } public BirthMonthEnum? BirthMonth { get; set; } public decimal? Rating { get; set; } diff --git a/QueryKit.WebApiTestProject/Migrations/20240416001404_BaseTestingMigration.Designer.cs b/QueryKit.WebApiTestProject/Migrations/20240622235558_TestingSetup.Designer.cs similarity index 96% rename from QueryKit.WebApiTestProject/Migrations/20240416001404_BaseTestingMigration.Designer.cs rename to QueryKit.WebApiTestProject/Migrations/20240622235558_TestingSetup.Designer.cs index fca4555..f36b7f0 100644 --- a/QueryKit.WebApiTestProject/Migrations/20240416001404_BaseTestingMigration.Designer.cs +++ b/QueryKit.WebApiTestProject/Migrations/20240622235558_TestingSetup.Designer.cs @@ -13,8 +13,8 @@ namespace QueryKit.WebApiTestProject.Migrations { [DbContext(typeof(TestingDbContext))] - [Migration("20240416001404_BaseTestingMigration")] - partial class BaseTestingMigration + [Migration("20240622235558_TestingSetup")] + partial class TestingSetup { /// protected override void BuildTargetModel(ModelBuilder modelBuilder) @@ -78,6 +78,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("name"); + b.Property("QualityLevel") + .HasColumnType("bigint") + .HasColumnName("quality_level"); + b.Property("Quantity") .IsRequired() .HasColumnType("text") @@ -206,6 +210,14 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasColumnName("favorite"); + b.Property("FirstName") + .HasColumnType("text") + .HasColumnName("first_name"); + + b.Property("LastName") + .HasColumnType("text") + .HasColumnName("last_name"); + b.Property("Rating") .HasColumnType("numeric") .HasColumnName("rating"); diff --git a/QueryKit.WebApiTestProject/Migrations/20240416001404_BaseTestingMigration.cs b/QueryKit.WebApiTestProject/Migrations/20240622235558_TestingSetup.cs similarity index 96% rename from QueryKit.WebApiTestProject/Migrations/20240416001404_BaseTestingMigration.cs rename to QueryKit.WebApiTestProject/Migrations/20240622235558_TestingSetup.cs index c2db2dd..8453b6c 100644 --- a/QueryKit.WebApiTestProject/Migrations/20240416001404_BaseTestingMigration.cs +++ b/QueryKit.WebApiTestProject/Migrations/20240622235558_TestingSetup.cs @@ -7,7 +7,7 @@ namespace QueryKit.WebApiTestProject.Migrations { /// - public partial class BaseTestingMigration : Migration + public partial class TestingSetup : Migration { /// protected override void Up(MigrationBuilder migrationBuilder) @@ -21,6 +21,8 @@ protected override void Up(MigrationBuilder migrationBuilder) { id = table.Column(type: "uuid", nullable: false), title = table.Column(type: "text", nullable: true), + first_name = table.Column(type: "text", nullable: true), + last_name = table.Column(type: "text", nullable: true), age = table.Column(type: "integer", nullable: true), birth_month = table.Column(type: "integer", nullable: true), rating = table.Column(type: "numeric", nullable: true), @@ -88,6 +90,7 @@ protected override void Up(MigrationBuilder migrationBuilder) id = table.Column(type: "uuid", nullable: false), name = table.Column(type: "text", nullable: false), quantity = table.Column(type: "text", nullable: false), + quality_level = table.Column(type: "bigint", nullable: true), expires_on = table.Column(type: "timestamp with time zone", nullable: true), measure = table.Column(type: "text", nullable: false), minimum_quality = table.Column(type: "integer", nullable: false), diff --git a/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs b/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs index 52d67e1..bc37216 100644 --- a/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs +++ b/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs @@ -75,6 +75,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("name"); + b.Property("QualityLevel") + .HasColumnType("bigint") + .HasColumnName("quality_level"); + b.Property("Quantity") .IsRequired() .HasColumnType("text") @@ -203,6 +207,14 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("boolean") .HasColumnName("favorite"); + b.Property("FirstName") + .HasColumnType("text") + .HasColumnName("first_name"); + + b.Property("LastName") + .HasColumnType("text") + .HasColumnName("last_name"); + b.Property("Rating") .HasColumnType("numeric") .HasColumnName("rating"); diff --git a/QueryKit/Configuration/QueryKitSettings.cs b/QueryKit/Configuration/QueryKitSettings.cs index 6d17221..f4dbad7 100644 --- a/QueryKit/Configuration/QueryKitSettings.cs +++ b/QueryKit/Configuration/QueryKitSettings.cs @@ -40,4 +40,9 @@ public QueryKitPropertyMapping Property(Expression DerivedProperty(Expression>? propertySelector) + { + return PropertyMappings.DerivedProperty(propertySelector); + } } \ No newline at end of file diff --git a/QueryKit/Exceptions/ParsingException.cs b/QueryKit/Exceptions/ParsingException.cs index d6a77fc..cef6675 100644 --- a/QueryKit/Exceptions/ParsingException.cs +++ b/QueryKit/Exceptions/ParsingException.cs @@ -3,7 +3,9 @@ namespace QueryKit.Exceptions; public sealed class ParsingException : Exception { public ParsingException(Exception exception) - : base($"There was a parsing failure, likely due to an invalid comparison or logical operator. You may also be missing double quotes surrounding a string or guid.", exception) + : base(@$"There was a parsing failure, likely due to an invalid comparison or logical operator. You may also be missing double quotes surrounding a string or guid. + +{exception.Message}", exception) { } } diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 2b0de4a..d71e2df 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -1,7 +1,5 @@ - -namespace QueryKit; +namespace QueryKit; -using System.Collections; using System.Globalization; using System.Linq.Expressions; using System.Reflection; @@ -26,20 +24,34 @@ public static Expression> ParseFilter(string input, IQueryKitCo input = config?.PropertyMappings?.ReplaceAliasesWithPropertyPaths(input) ?? input; var parameter = Expression.Parameter(typeof(T), "x"); - Expression expr; + Expression expr; try { expr = ExprParser(parameter, config).End().Parse(input); + expr = ReplaceDerivedProperties(expr, config, parameter); } - catch (InvalidOperationException e) { + catch (InvalidOperationException e) + { throw new ParsingException(e); } catch (ParseException e) { throw new ParsingException(e); } + return Expression.Lambda>(expr, parameter); } + + private static Expression ReplaceDerivedProperties(Expression expr, IQueryKitConfiguration? config, ParameterExpression parameter) + { + if (config?.PropertyMappings == null) + { + return expr; + } + + return new ParameterReplacer(parameter).Visit(expr); + } + private static readonly Parser Identifier = from first in Parse.Letter.Once() @@ -102,6 +114,10 @@ from timeZone in Parse.Regex(@"Z|[+-]\d{2}(:\d{2})?").Text().Optional().Select(x from micros in Parse.Regex(@"\.\d{1,6}").Text().Optional().Select(x => x.GetOrElse("")) select dateFormat + timeFormat + micros + timeZone; + private static Parser NumberParser => + from sign in Parse.Char('-').Optional().Select(x => x.IsDefined ? "-" : "") + from number in Parse.Decimal + select sign + number; private static Parser GuidFormatParser => Parse.Regex(@"[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}").Text(); @@ -120,8 +136,7 @@ from value in Parse.String("null").Text() .XOr(Identifier) .XOr(DateTimeFormatParser) .XOr(TimeFormatParser) - .XOr(Parse.Decimal) - .XOr(Parse.Number) + .XOr(NumberParser) .XOr(RawStringLiteralParser.Or(DoubleQuoteParser)) .XOr(SquareBracketParser) from trailingSpaces in Parse.WhiteSpace.Many() @@ -129,11 +144,13 @@ from trailingSpaces in Parse.WhiteSpace.Many() private static Parser SquareBracketParser => from openingBracket in Parse.Char('[') - from content in DoubleQuoteParser + from content in Parse.String("null").Text() .Or(GuidFormatParser) - .Or(Parse.Decimal) - .Or(Parse.Number) .Or(Identifier) + .Or(DateTimeFormatParser) + .Or(TimeFormatParser) + .Or(NumberParser) + .Or(RawStringLiteralParser.Or(DoubleQuoteParser)) .DelimitedBy(Parse.Char(',').Token()) from closingBracket in Parse.Char(']') select "[" + string.Join(",", content) + "]"; @@ -162,25 +179,24 @@ from closingBracket in Parse.Char(']') { typeof(sbyte), value => sbyte.Parse(value, CultureInfo.InvariantCulture) }, }; - private static Expression CreateRightExpr(Expression leftExpr, string right) + private static Expression CreateRightExpr(Expression leftExpr, string right, ComparisonOperator op) { var targetType = leftExpr.Type; - return CreateRightExprFromType(targetType, right); + return CreateRightExprFromType(targetType, right, op); } - private static Expression CreateRightExprFromType(Type leftExprType, string right) + private static Expression CreateRightExprFromType(Type leftExprType, string right, ComparisonOperator op) { var isEnumerable = IsEnumerable(leftExprType); var targetType = leftExprType; if (isEnumerable) { - if (int.TryParse(right, out var intVal) && targetType.GetGenericArguments().All(arg => arg != typeof(string))) + if (op.IsCountOperator() && int.TryParse(right, out var intVal)) { - // supports collection count return Expression.Constant(intVal, typeof(int)); } targetType = targetType.GetGenericArguments()[0]; - return CreateRightExprFromType(targetType, right); + return CreateRightExprFromType(targetType, right, op); } var rawType = targetType; @@ -358,6 +374,20 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ var nullableCtor = rawType.GetConstructor(new[] {enumType}); return Expression.New(nullableCtor, constant); } + + // for some complex derived expressions + if (targetType == typeof(object)) + { + if (right == "null") + { + return Expression.Constant(null, typeof(object)); + } + + if (bool.TryParse(right, out var boolVal)) + { + return Expression.Constant(boolVal, typeof(bool)); + } + } throw new InvalidOperationException($"Unsupported value '{right}' for type '{targetType.Name}'"); } @@ -413,11 +443,11 @@ private static Parser ComparisonExprParser(ParameterExpression pa ) : Expression.Call(temp.leftExpr, toStringMethod!); - return temp.op.GetExpression(leftExpr, CreateRightExpr(temp.leftExpr, temp.right), config?.DbContextType); + return temp.op.GetExpression(leftExpr, CreateRightExpr(temp.leftExpr, temp.right, temp.op), config?.DbContextType); } - var rightExpr = CreateRightExpr(temp.leftExpr, temp.right); + var rightExpr = CreateRightExpr(temp.leftExpr, temp.right, temp.op); return temp.op.GetExpression(temp.leftExpr, rightExpr, config?.DbContextType); }); } @@ -499,10 +529,17 @@ private static Parser ComparisonExprParser(ParameterExpression pa } 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); } }); @@ -516,7 +553,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa return propertyExpression; }); } - + private static Type? GetInnerGenericType(Type type) { if (!IsEnumerable(type)) @@ -549,3 +586,5 @@ private static Parser OrExprParser(ParameterExpression parameter, (op, left, right) => op.GetExpression(left, right) ); } + + diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index ff62d4d..ed182d0 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -58,7 +58,6 @@ public abstract class ComparisonOperator : SmartEnum 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) { @@ -175,6 +174,7 @@ public static ComparisonOperator GetByOperatorString(string op, bool caseInsensi public const char CaseSensitiveAppendix = '*'; public const char AllPrefix = '%'; public abstract string Operator(); + public abstract bool IsCountOperator(); public bool CaseInsensitive { get; protected set; } public bool UsesAll { get; protected set; } public abstract Expression GetExpression(Expression left, Expression right, Type? dbContextType); @@ -191,6 +191,7 @@ public EqualsType(bool caseInsensitive = false, bool usesAll = false) : base("== } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) @@ -205,6 +206,12 @@ public override Expression GetExpression(Expression left, Expression right, T Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)) ); } + + // for some complex derived expressions + if (left.NodeType == ExpressionType.Convert) + { + left = Expression.Convert(left, typeof(bool)); + } return Expression.Equal(left, right); } @@ -217,6 +224,7 @@ public NotEqualsType(bool caseInsensitive = false, bool usesAll = false) : base( } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) @@ -231,6 +239,12 @@ public override Expression GetExpression(Expression left, Expression right, T Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)) ); } + + // for some complex derived expressions + if (left.NodeType == ExpressionType.Convert) + { + left = Expression.Convert(left, typeof(bool)); + } return Expression.NotEqual(left, right); } @@ -243,6 +257,7 @@ public GreaterThanType(bool caseInsensitive = false, bool usesAll = false) : bas } public override string Operator() => Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) @@ -260,6 +275,7 @@ public LessThanType(bool caseInsensitive = false, bool usesAll = false) : base(" } public override string Operator() => Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) @@ -273,6 +289,7 @@ public override Expression GetExpression(Expression left, Expression right, T private class GreaterThanOrEqualType : ComparisonOperator { public override string Operator() => Name; + public override bool IsCountOperator() => false; public GreaterThanOrEqualType(bool caseInsensitive = false, bool usesAll = false) : base(">=", 4, caseInsensitive, usesAll) { } @@ -292,6 +309,7 @@ public LessThanOrEqualType(bool caseInsensitive = false, bool usesAll = false) : { } public override string Operator() => Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) @@ -309,6 +327,7 @@ public ContainsType(bool caseInsensitive = false, bool usesAll = false) : base(" } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) @@ -336,6 +355,7 @@ public StartsWithType(bool caseInsensitive = false, bool usesAll = false) : base } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) @@ -363,6 +383,7 @@ public EndsWithType(bool caseInsensitive = false, bool usesAll = false) : base(" } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) @@ -390,6 +411,7 @@ public NotContainsType(bool caseInsensitive = false, bool usesAll = false) : bas } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) @@ -418,6 +440,7 @@ public NotStartsWithType(bool caseInsensitive = false, bool usesAll = false) : b } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) @@ -445,6 +468,7 @@ public NotEndsWithType(bool caseInsensitive = false, bool usesAll = false) : bas } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) @@ -472,6 +496,7 @@ public InType(bool caseInsensitive = false, bool usesAll = false) : base("^^", 1 } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { var leftType = left.Type == typeof(Guid) || left.Type == typeof(Guid?) @@ -521,6 +546,7 @@ public SoundsLikeType(bool caseInsensitive = false, bool usesAll = false) : base } public override string Operator() => Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { @@ -549,6 +575,7 @@ public DoesNotSoundLikeType(bool caseInsensitive = false, bool usesAll = false) } public override string Operator() => Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { @@ -577,6 +604,7 @@ public HasCountEqualToType(bool caseInsensitive = false, bool usesAll = false) : } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => true; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { return GetCountExpression(left, right, nameof(Expression.Equal)); @@ -590,6 +618,7 @@ public HasCountNotEqualToType(bool caseInsensitive = false, bool usesAll = false } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => true; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { return GetCountExpression(left, right, nameof(Expression.NotEqual)); @@ -603,6 +632,7 @@ public HasCountGreaterThanType(bool caseInsensitive = false, bool usesAll = fals } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => true; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { return GetCountExpression(left, right, nameof(Expression.GreaterThan)); @@ -616,6 +646,7 @@ public HasCountLessThanType(bool caseInsensitive = false, bool usesAll = false) } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => true; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { return GetCountExpression(left, right, nameof(Expression.LessThan)); @@ -629,6 +660,7 @@ public HasCountGreaterThanOrEqualType(bool caseInsensitive = false, bool usesAll } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => true; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { return GetCountExpression(left, right, nameof(Expression.GreaterThanOrEqual)); @@ -642,6 +674,7 @@ public HasCountLessThanOrEqualType(bool caseInsensitive = false, bool usesAll = } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => true; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { return GetCountExpression(left, right, nameof(Expression.LessThanOrEqual)); @@ -655,6 +688,7 @@ public HasType(bool caseInsensitive = false, bool usesAll = false) : base("^$", } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { if (left.Type.IsGenericType && @@ -677,6 +711,7 @@ public DoesNotHaveType(bool caseInsensitive = false, bool usesAll = false) : bas } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { if (left.Type.IsGenericType && @@ -699,6 +734,7 @@ public NotInType(bool caseInsensitive = false, bool usesAll = false) : base("!^^ } public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override bool IsCountOperator() => false; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { var leftType = left.Type; diff --git a/QueryKit/ParameterReplacer.cs b/QueryKit/ParameterReplacer.cs new file mode 100644 index 0000000..7000870 --- /dev/null +++ b/QueryKit/ParameterReplacer.cs @@ -0,0 +1,19 @@ +namespace QueryKit; + +using System.Linq.Expressions; + +internal class ParameterReplacer : ExpressionVisitor +{ + private readonly ParameterExpression _newParameter; + + public ParameterReplacer(ParameterExpression newParameter) + { + _newParameter = newParameter; + } + + protected override Expression VisitParameter(ParameterExpression node) + { + // Replace all parameters with the new parameter + return _newParameter; + } +} \ No newline at end of file diff --git a/QueryKit/QueryKit.csproj b/QueryKit/QueryKit.csproj index d8d26bf..582a605 100644 --- a/QueryKit/QueryKit.csproj +++ b/QueryKit/QueryKit.csproj @@ -6,7 +6,7 @@ enable QueryKit QueryKit;Fitler;Filtering;Sort;Sorting - 1.3.3 + 1.4.0 Paul DeVito QueryKit is a .NET library that makes it easier to query your data by providing a fluent and intuitive syntax for filtering and sorting. QueryKit is a .NET library that makes it easier to query your data by providing a fluent and intuitive syntax for filtering and sorting. diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index 373a3e2..e7cf43f 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -8,7 +8,9 @@ namespace QueryKit; public class QueryKitPropertyMappings { private readonly Dictionary _propertyMappings = new(); + private readonly Dictionary _derivedPropertyMappings = new(); internal IReadOnlyDictionary PropertyMappings => _propertyMappings; + internal IReadOnlyDictionary DerivedPropertyMappings => _derivedPropertyMappings; public QueryKitPropertyMapping Property(Expression>? propertySelector) { @@ -25,7 +27,32 @@ public QueryKitPropertyMapping Property(Expression(propertyInfo); } - + + public QueryKitPropertyMapping DerivedProperty(Expression>? propertySelector) + { + var fullPath = GetFullPropertyPath(propertySelector); + + if (propertySelector == null) + throw new ArgumentNullException(nameof(propertySelector)); + if(propertySelector.NodeType != ExpressionType.Lambda) + throw new ArgumentException("Property selector must be a lambda expression", nameof(propertySelector)); + + var body = propertySelector.Body; + + var propertyInfo = new QueryKitPropertyInfo + { + Name = fullPath, + CanFilter = true, + CanSort = true, + QueryName = fullPath, + DerivedExpression = body + }; + + _derivedPropertyMappings[fullPath] = propertyInfo; + + return new QueryKitPropertyMapping(propertyInfo); + } + public string ReplaceAliasesWithPropertyPaths(string input) { var operators = ComparisonOperator.List.Select(x => x.Operator()).ToList(); @@ -42,56 +69,116 @@ public string ReplaceAliasesWithPropertyPaths(string input) } } } - + + // foreach (var alias in _derivedPropertyMappings.Values) + // { + // foreach (var op in operators) + // { + // // Use regular expression to isolate left side of the expression + // var regex = new Regex($@"\b{alias.QueryName}\b(?=\s*{op})", RegexOptions.IgnoreCase); + // input = regex.Replace(input, $"~||~||~{alias.QueryName}"); + // } + // } return input; } private static string GetFullPropertyPath(Expression? expression) { - if (expression!.NodeType == ExpressionType.Call) - { - var call = (MethodCallExpression)expression; - if (call.Method.DeclaringType == typeof(Enumerable) && call.Method.Name == "Select" || - call.Method.DeclaringType == typeof(Queryable) && call.Method.Name == "Select" || - call.Method.DeclaringType == typeof(Enumerable) && call.Method.Name == "SelectMany" || - call.Method.DeclaringType == typeof(Queryable) && call.Method.Name == "SelectMany") - { - var propertyPath = GetFullPropertyPath(call.Arguments[1]); - var prevPath = GetFullPropertyPath(call.Arguments[0]); - return $"{prevPath}.{propertyPath}"; - } - } + if (expression == null) + throw new ArgumentNullException(nameof(expression)); - if (expression!.NodeType == ExpressionType.Lambda) - { - var lambda = (LambdaExpression)expression; - return GetFullPropertyPath(lambda.Body); - } - if (expression.NodeType == ExpressionType.Convert) - { - var unary = (UnaryExpression)expression; - return GetFullPropertyPath(unary.Operand); - } - if (expression.NodeType == ExpressionType.MemberAccess) + switch (expression.NodeType) { - var memberExpression = (MemberExpression)expression; - return memberExpression?.Expression?.NodeType == ExpressionType.Parameter - ? memberExpression.Member.Name - : $"{GetFullPropertyPath(memberExpression?.Expression)}.{memberExpression?.Member?.Name}"; + case ExpressionType.Call: + var call = (MethodCallExpression)expression; + if (call.Method.DeclaringType == typeof(Enumerable) && call.Method.Name == "Select" || + call.Method.DeclaringType == typeof(Queryable) && call.Method.Name == "Select" || + call.Method.DeclaringType == typeof(Enumerable) && call.Method.Name == "SelectMany" || + call.Method.DeclaringType == typeof(Queryable) && call.Method.Name == "SelectMany") + { + var propertyPath = GetFullPropertyPath(call.Arguments[1]); + var prevPath = GetFullPropertyPath(call.Arguments[0]); + return $"{prevPath}.{propertyPath}"; + } + break; + case ExpressionType.Lambda: + var lambda = (LambdaExpression)expression; + return GetFullPropertyPath(lambda.Body); + case ExpressionType.Convert: + var unary = (UnaryExpression)expression; + return GetFullPropertyPath(unary.Operand); + case ExpressionType.MemberAccess: + var memberExpression = (MemberExpression)expression; + return memberExpression?.Expression?.NodeType == ExpressionType.Parameter + ? memberExpression.Member.Name + : $"{GetFullPropertyPath(memberExpression?.Expression)}.{memberExpression?.Member?.Name}"; + case ExpressionType.Add: + case ExpressionType.Subtract: + case ExpressionType.Multiply: + case ExpressionType.Divide: + case ExpressionType.Modulo: + case ExpressionType.And: + case ExpressionType.Or: + case ExpressionType.AndAlso: + case ExpressionType.OrElse: + case ExpressionType.GreaterThan: + case ExpressionType.LessThan: + case ExpressionType.GreaterThanOrEqual: + case ExpressionType.LessThanOrEqual: + case ExpressionType.Equal: + var binary = (BinaryExpression)expression; + var left = GetFullPropertyPath(binary.Left); + var right = GetFullPropertyPath(binary.Right); + var op = GetOperator(binary.NodeType); + return $"{left} {op} {right}"; + case ExpressionType.Constant: + var constant = (ConstantExpression)expression; + return constant.Value?.ToString() ?? ""; + default: + throw new NotSupportedException($"Expression type '{expression.NodeType}' is not supported."); } + throw new NotSupportedException($"Expression type '{expression.NodeType}' is not supported."); } + private static string GetOperator(ExpressionType nodeType) + { + return nodeType switch + { + ExpressionType.Add => "+", + ExpressionType.Subtract => "-", + ExpressionType.Multiply => "*", + ExpressionType.Divide => "/", + ExpressionType.Modulo => "%", + ExpressionType.And => "&", + ExpressionType.Or => "|", + ExpressionType.AndAlso => "&&", + ExpressionType.OrElse => "||", + ExpressionType.GreaterThan => ">", + ExpressionType.LessThan => "<", + ExpressionType.GreaterThanOrEqual => ">=", + ExpressionType.LessThanOrEqual => "<=", + ExpressionType.Equal => "==", + _ => throw new NotSupportedException($"Operator for expression type '{nodeType}' is not supported.") + }; + } + + + public QueryKitPropertyInfo? GetPropertyInfo(string? propertyName) => _propertyMappings.TryGetValue(propertyName, out var info) ? info : null; public QueryKitPropertyInfo? GetPropertyInfoByQueryName(string? queryName) => _propertyMappings.Values.FirstOrDefault(info => info.QueryName != null && info.QueryName.Equals(queryName, StringComparison.InvariantCultureIgnoreCase)); + public QueryKitPropertyInfo? GetDerivedPropertyInfoByQueryName(string? queryName) + => _derivedPropertyMappings.Values.FirstOrDefault(info => info.QueryName != null && info.QueryName.Equals(queryName, StringComparison.InvariantCultureIgnoreCase)); + public string? GetPropertyPathByQueryName(string? queryName) => GetPropertyInfoByQueryName(queryName)?.Name ?? null; } + public class QueryKitPropertyMapping { private readonly QueryKitPropertyInfo _propertyInfo; @@ -126,4 +213,5 @@ public class QueryKitPropertyInfo public bool CanFilter { get; set; } public bool CanSort { get; set; } public string? QueryName { get; set; } + internal Expression DerivedExpression { get; set; } } \ No newline at end of file diff --git a/README.md b/README.md index b9acb58..a4c61ee 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,22 @@ var config = new QueryKitConfiguration(config => }); ``` +#### Derived Properties + +You can also expose custom derived properties for consumption. Just be sure that Linq can handle them in a db query if you're using it that way. + +```csharp +var config = new QueryKitConfiguration(config => +{ + config.DerivedProperty(p => p.FirstName + " " + p.LastName).HasQueryName("fullname"); + config.DerivedProperty(p => p.Age >= 18 && p.FirstName == "John").HasQueryName("adult_johns"); +}); + +var input = $"""(fullname @=* "John Doe") && age >= 18"""; +// or +var input = $"""adult_johns == true"""; +``` + #### Custom Operators You can also add custom comparison operators to your config if you'd like: diff --git a/SharedTestingHelper/Fakes/FakeTestingPersonBuilder.cs b/SharedTestingHelper/Fakes/FakeTestingPersonBuilder.cs index 62de7f6..66657dd 100644 --- a/SharedTestingHelper/Fakes/FakeTestingPersonBuilder.cs +++ b/SharedTestingHelper/Fakes/FakeTestingPersonBuilder.cs @@ -6,6 +6,8 @@ namespace SharedTestingHelper.Fakes; public class FakeTestingPersonBuilder { private readonly TestingPerson _baseTestingPerson = new AutoFaker() + .RuleFor(x => x.FirstName, faker => faker.Name.FirstName()) + .RuleFor(x => x.LastName, faker => faker.Name.LastName()) .RuleFor(x => x.Title, faker => faker.Lorem.Sentence()) .Generate(); @@ -21,6 +23,12 @@ public FakeTestingPersonBuilder WithAge(int age) return this; } + public FakeTestingPersonBuilder WithFavorite(bool favorite) + { + _baseTestingPerson.Favorite = favorite; + return this; + } + public FakeTestingPersonBuilder WithEmail(string email) { _baseTestingPerson.Email = new EmailAddress(email); @@ -75,5 +83,17 @@ public FakeTestingPersonBuilder WithTime(TimeOnly? time) return this; } + public FakeTestingPersonBuilder WithFirstName(string firstName) + { + _baseTestingPerson.FirstName = firstName; + return this; + } + + public FakeTestingPersonBuilder WithLastName(string lastName) + { + _baseTestingPerson.LastName = lastName; + return this; + } + public TestingPerson Build() => _baseTestingPerson; } \ No newline at end of file diff --git a/SharedTestingHelper/Fakes/IngredientPreparations/FakeIngredientPreparation.cs b/SharedTestingHelper/Fakes/IngredientPreparations/FakeIngredientPreparation.cs new file mode 100644 index 0000000..a201701 --- /dev/null +++ b/SharedTestingHelper/Fakes/IngredientPreparations/FakeIngredientPreparation.cs @@ -0,0 +1,12 @@ +namespace SharedTestingHelper.Fakes.IngredientPreparations; + +using AutoBogus; +using QueryKit.WebApiTestProject.Entities.Ingredients; +using QueryKit.WebApiTestProject.Entities.Ingredients.Models; + +public sealed class FakeIngredientPreparation : AutoFaker +{ + public FakeIngredientPreparation() + { + } +} \ No newline at end of file diff --git a/SharedTestingHelper/Fakes/Ingredients/FakeIngredientBuilder.cs b/SharedTestingHelper/Fakes/Ingredients/FakeIngredientBuilder.cs index 68dd1b1..c425387 100644 --- a/SharedTestingHelper/Fakes/Ingredients/FakeIngredientBuilder.cs +++ b/SharedTestingHelper/Fakes/Ingredients/FakeIngredientBuilder.cs @@ -6,6 +6,7 @@ namespace SharedTestingHelper.Fakes.Ingredients; public class FakeIngredientBuilder { private IngredientForCreation _creationData = new FakeIngredientForCreation().Generate(); + private List _preparations = new(); public FakeIngredientBuilder WithModel(IngredientForCreation model) { @@ -19,9 +20,22 @@ public FakeIngredientBuilder WithName(string name) return this; } + public FakeIngredientBuilder WithPreparation(IngredientPreparation preparation) + { + _preparations.Add(preparation); + return this; + } + + public FakeIngredientBuilder WithQualityLevel(long qualityLevel) + { + _creationData.QualityLevel = qualityLevel; + return this; + } + public Ingredient Build() { var result = Ingredient.Create(_creationData); + result.Preparations = _preparations; return result; } } \ No newline at end of file