From 81b8382bf63e511187f6c255c08f4fcec67a6116 Mon Sep 17 00:00:00 2001 From: Roy Cockram <97388890+royston-c-oa@users.noreply.github.com> Date: Mon, 17 Jul 2023 16:29:53 +0100 Subject: [PATCH 01/21] feat - add collection support for collections. ONLY Equals operator --- QueryKit.UnitTests/FilterParserTests.cs | 45 ++++++- .../Entities/Ingredients/Ingredient.cs | 8 +- .../Entities/Recipes/Recipe.cs | 4 + QueryKit/FilterParser.cs | 113 +++++++++++++++++- QueryKit/Operators/ComparisonOperator.cs | 21 ++++ QueryKit/QueryKitPropertyMappings.cs | 12 ++ 6 files changed, 197 insertions(+), 6 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index a9dc563..060290b 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -1,8 +1,14 @@ +using QueryKit.Configuration; +using QueryKit.WebApiTestProject.Entities.Ingredients; +using QueryKit.WebApiTestProject.Entities.Ingredients.Models; +using QueryKit.WebApiTestProject.Entities.Recipes; + namespace QueryKit.UnitTests; using Bogus; using Exceptions; using FluentAssertions; +using SharedTestingHelper.Fakes.Recipes; using WebApiTestProject.Entities; public class FilterParserTests @@ -532,4 +538,41 @@ public void can_throw_error_when_property_has_space() act.Should().Throw() .WithMessage($"The filter property '{firstWord}' was not recognized."); } -} \ No newline at end of file + + [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.Select(y => y.Name)).PreventSort(); + }); + var filterExpression = FilterParser.ParseFilter(input, config); + + filterExpression.Compile().Invoke(fakeRecipeOne).Should().BeTrue(); + } +} + \ No newline at end of file diff --git a/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs b/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs index cacd174..c06c2b7 100644 --- a/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs +++ b/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs @@ -6,6 +6,11 @@ namespace QueryKit.WebApiTestProject.Entities.Ingredients; using Models; using Recipes; +public class IngredientPreparation +{ + public string Text { get; set; } +} + public class Ingredient : BaseEntity { public string Name { get; private set; } @@ -16,12 +21,13 @@ public class Ingredient : BaseEntity public string Measure { get; private set; } + public List Preparations { get; set; } = new(); + [JsonIgnore, IgnoreDataMember] [ForeignKey("Recipe")] public Guid RecipeId { get; private set; } public Recipe Recipe { get; private set; } - public static Ingredient Create(IngredientForCreation ingredientForCreation) { var newIngredient = new Ingredient(); diff --git a/QueryKit.WebApiTestProject/Entities/Recipes/Recipe.cs b/QueryKit.WebApiTestProject/Entities/Recipes/Recipe.cs index 67e6014..768b6f6 100644 --- a/QueryKit.WebApiTestProject/Entities/Recipes/Recipe.cs +++ b/QueryKit.WebApiTestProject/Entities/Recipes/Recipe.cs @@ -38,6 +38,10 @@ private set [JsonIgnore, IgnoreDataMember] public IReadOnlyCollection Ingredients => _ingredients.AsReadOnly(); + public void AddIngredient(Ingredient ingredient) + { + _ingredients.Add(ingredient); + } public static Recipe Create(RecipeForCreation recipeForCreation) { diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index ed30900..1b9ce33 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -151,6 +151,19 @@ from closingBracket in Parse.Char(']') private static Expression CreateRightExpr(Expression leftExpr, string right) { var targetType = leftExpr.Type; + + if (IsEnumerable(targetType)) + { + targetType = targetType.GetGenericArguments()[0]; + return CreateRightExprFromType(targetType, right); + } + + return CreateRightExprFromType(leftExpr.Type, right); + } + + private static Expression CreateRightExprFromType(Type leftExprType, string right) + { + var targetType = leftExprType; var rawType = targetType; targetType = TransformTargetTypeIfNullable(targetType); @@ -159,7 +172,7 @@ private static Expression CreateRightExpr(Expression leftExpr, string right) { if (right == "null") { - return Expression.Constant(null, leftExpr.Type); + return Expression.Constant(null, leftExprType); } if (right.StartsWith("[") && right.EndsWith("]")) @@ -273,7 +286,7 @@ private static Expression CreateRightExpr(Expression leftExpr, string right) } var convertedValue = conversionFunction(right); - return Expression.Constant(convertedValue, leftExpr.Type); + return Expression.Constant(convertedValue, leftExprType); } throw new InvalidOperationException($"Unsupported value '{right}' for type '{targetType.Name}'"); @@ -281,14 +294,28 @@ private static Expression CreateRightExpr(Expression leftExpr, string right) private static Type TransformTargetTypeIfNullable(Type targetType) { - if (targetType.IsGenericType && targetType.GetGenericTypeDefinition() == typeof(Nullable<>)) + if (targetType.IsGenericType) { - targetType = Nullable.GetUnderlyingType(targetType); + if (targetType.GetGenericTypeDefinition() == typeof(Nullable<>)) + { + targetType = Nullable.GetUnderlyingType(targetType); + } } return targetType; } + private static bool IsEnumerable(Type targetType) + { + if (targetType == typeof(string)) + { + return false; + } + return targetType.IsGenericType && targetType.GetGenericTypeDefinition() == typeof(IEnumerable<>) || + targetType.GetInterfaces() + .Any(x => x.IsGenericType && x.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + } + private static Parser ComparisonExprParser(ParameterExpression parameter, IQueryKitConfiguration? config) { var comparisonOperatorParser = ComparisonOperatorParser.Token(); @@ -320,6 +347,69 @@ private static Parser ComparisonExprParser(ParameterExpression pa var fullPropPath = leftList?.First(); var propertyExpression = leftList?.Aggregate((Expression)parameter, (expr, propName) => { + if (expr is MemberExpression member) + { + // check if member is IEnumerable + 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, "x_"+ propName); + var propertyInfoForMethod = GetPropertyInfo(genericArgType, propName); + Expression lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod.Name); + + var t = typeof(IEnumerable<>).MakeGenericType(propertyType); + lambdaBody = Expression.Lambda(Expression.Convert(lambdaBody, t), 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, "x_"+ propName); + var propertyInfoForMethod = GetPropertyInfo(genericArgType, propName); + var lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod.Name); + var selectLambda = Expression.Lambda(lambdaBody, innerParameter); + + return Expression.Call(null, selectMethod, member, selectLambda); + } + } + } + + if (expr is MethodCallExpression call) + { + // Find the inner generic type of the last argument + var innerGenericType = GetInnerGenericType(call.Method.ReturnType); + + // Find the PropertyInfo on the innerGenericType for the property named propName + var propertyInfoForMethod = GetPropertyInfo(innerGenericType, propName); + + // Create a Select expression for this property + var linqMethod = IsEnumerable(innerGenericType) ? "SelectMany" : "Select"; + var selectMethod = typeof(Enumerable).GetMethods() + .First(m => m.Name == linqMethod && m.GetParameters().Length == 2) + .MakeGenericMethod(innerGenericType, propertyInfoForMethod.PropertyType); + + var innerParameter = Expression.Parameter(innerGenericType, "x_" + propName); + 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 @@ -345,6 +435,21 @@ private static Parser ComparisonExprParser(ParameterExpression pa return propertyExpression; }); } + + private static Type? GetInnerGenericType(Type type) + { + // If the type is not an IEnumerable, return the original type + if (!IsEnumerable(type)) + { + return type; + } + + // If the type is an IEnumerable, get the inner generic type + var innerGenericType = type.GetGenericArguments()[0]; + + // Recursively check if the inner type is also an IEnumerable + return GetInnerGenericType(innerGenericType); + } private static Parser AtomicExprParser(ParameterExpression parameter, IQueryKitConfiguration? config = null) => ComparisonExprParser(parameter, config) diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index e510324..7e4fa6c 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -142,6 +142,27 @@ public override Expression GetExpression(Expression left, Expression right, T ); } + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + // create the x parameter + var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "x"); + + // create the x => x == right lambda + var anyLambda = Expression.Lambda( + Expression.Equal(xParameter, right), + xParameter + ); + + // get the .Any method + var anyMethod = typeof(Enumerable) + .GetMethods() + .Single(m => m.Name == "Any" && m.GetParameters().Length == 2) + .MakeGenericMethod(left.Type.GetGenericArguments()[0]); + + // append an .Any(x => x == right) expression + return Expression.Call(anyMethod, left, anyLambda); + } + return Expression.Equal(left, right); } } diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index b86df2c..a4b2c39 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -48,6 +48,18 @@ public string ReplaceAliasesWithPropertyPaths(string 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") + { + var propertyPath = GetFullPropertyPath(call.Arguments[1]); + var prevPath = GetFullPropertyPath(call.Arguments[0]); + return $"{prevPath}.{propertyPath}"; + } + } + if (expression!.NodeType == ExpressionType.Lambda) { var lambda = (LambdaExpression)expression; From d6a8002de1624e8c80088e7501f3c42446fb1061 Mon Sep 17 00:00:00 2001 From: Roy Cockram <97388890+royston-c-oa@users.noreply.github.com> Date: Mon, 17 Jul 2023 16:59:14 +0100 Subject: [PATCH 02/21] fix - add support for nested Array properties in the mappings --- QueryKit.UnitTests/FilterParserTests.cs | 2 +- QueryKit/QueryKitPropertyMappings.cs | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 060290b..7854535 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -568,7 +568,7 @@ public void can_filter_within_nested_collection() var input = $"Ingredients.Preparations.Text == \"{prepText}\""; var config = new QueryKitConfiguration(settings => { - settings.Property(x => x.Ingredients.Select(y => y.Name)).PreventSort(); + settings.Property(x => x.Ingredients.SelectMany(y => y.Preparations).Select(y=> y.Text)).PreventSort(); }); var filterExpression = FilterParser.ParseFilter(input, config); diff --git a/QueryKit/QueryKitPropertyMappings.cs b/QueryKit/QueryKitPropertyMappings.cs index a4b2c39..373a3e2 100644 --- a/QueryKit/QueryKitPropertyMappings.cs +++ b/QueryKit/QueryKitPropertyMappings.cs @@ -52,7 +52,9 @@ private static string GetFullPropertyPath(Expression? expression) { 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(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]); From a8f710d417ddc4e14e0cac25dba1b13addbda557 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sat, 22 Jul 2023 09:15:24 -0400 Subject: [PATCH 03/21] update: tweak expression build --- QueryKit/FilterParser.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 1b9ce33..e660b71 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -364,7 +364,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa .First(m => m.Name == linqMethod && m.GetParameters().Length == 2) .MakeGenericMethod(genericArgType, propertyType); - var innerParameter = Expression.Parameter(genericArgType, "x_"+ propName); + var innerParameter = Expression.Parameter(genericArgType, "y"); var propertyInfoForMethod = GetPropertyInfo(genericArgType, propName); Expression lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod.Name); @@ -379,7 +379,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa .First(m => m.Name == "Select" && m.GetParameters().Length == 2) .MakeGenericMethod(genericArgType, genericArgType.GetProperty(propName).PropertyType); - var innerParameter = Expression.Parameter(genericArgType, "x_"+ propName); + var innerParameter = Expression.Parameter(genericArgType, "y"); var propertyInfoForMethod = GetPropertyInfo(genericArgType, propName); var lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod.Name); var selectLambda = Expression.Lambda(lambdaBody, innerParameter); @@ -403,7 +403,7 @@ private static Parser ComparisonExprParser(ParameterExpression pa .First(m => m.Name == linqMethod && m.GetParameters().Length == 2) .MakeGenericMethod(innerGenericType, propertyInfoForMethod.PropertyType); - var innerParameter = Expression.Parameter(innerGenericType, "x_" + propName); + var innerParameter = Expression.Parameter(innerGenericType, "y"); var lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod.Name); var selectLambda = Expression.Lambda(lambdaBody, innerParameter); From c3437856ccb353195d8468265c98cfb073d1b4b5 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sat, 22 Jul 2023 09:15:37 -0400 Subject: [PATCH 04/21] update: a couple more tests --- QueryKit.UnitTests/FilterParserTests.cs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 7854535..4129e9d 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -574,5 +574,23 @@ public void can_filter_within_nested_collection() filterExpression.Compile().Invoke(fakeRecipeOne).Should().BeTrue(); } + + [Fact] + public void simple_child_collection_for_string_not_equal() + { + var input = """Ingredients.Name != "flour" """; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => (x.Ingredients.Select(y => y.Name) != "flour")""""); + } + + [Fact] + public void simple_child_collection_for_string_equal() + { + var input = """Ingredients.Name == "flour" """; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => (x == "flour"))""""); + } } \ No newline at end of file From 7ca84e9676fea7eb2511d21848f7b257553c1538 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sat, 22 Jul 2023 09:50:52 -0400 Subject: [PATCH 05/21] feat: can handle case insensitive lambdas --- QueryKit.UnitTests/FilterParserTests.cs | 9 ++++++ QueryKit/Operators/ComparisonOperator.cs | 37 ++++++++++++++---------- 2 files changed, 30 insertions(+), 16 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 4129e9d..6d92d1d 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -592,5 +592,14 @@ public void simple_child_collection_for_string_equal() filterExpression.ToString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => (x == "flour"))""""); } + + [Fact] + public void simple_child_collection_for_string_case_insensitive_equal() + { + var input = """Ingredients.Name ==* "flour" """; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => (x.ToLower() == "flour".ToLower()))""""); + } } \ No newline at end of file diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 7e4fa6c..f422007 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -134,34 +134,39 @@ public EqualsType(bool caseInsensitive = false) : base("==", 0, caseInsensitive) public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { - if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) - { - return Expression.Equal( - Expression.Call(left, typeof(string).GetMethod("ToLower", Type.EmptyTypes)), - Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)) - ); - } - if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - // create the x parameter var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "x"); + Expression body; - // create the x => x == right lambda - var anyLambda = Expression.Lambda( - Expression.Equal(xParameter, right), - xParameter - ); + if (CaseInsensitive && xParameter.Type == typeof(string) && right.Type == typeof(string)) + { + var toLowerLeft = Expression.Call(xParameter, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); + var toLowerRight = Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); + + body = Expression.Equal(toLowerLeft, toLowerRight); + } + else + { + body = Expression.Equal(xParameter, right); + } - // get the .Any method + var anyLambda = Expression.Lambda(body, xParameter); var anyMethod = typeof(Enumerable) .GetMethods() .Single(m => m.Name == "Any" && m.GetParameters().Length == 2) .MakeGenericMethod(left.Type.GetGenericArguments()[0]); - // append an .Any(x => x == right) expression return Expression.Call(anyMethod, left, anyLambda); } + + if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) + { + return Expression.Equal( + Expression.Call(left, typeof(string).GetMethod("ToLower", Type.EmptyTypes)), + Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)) + ); + } return Expression.Equal(left, right); } From 70bdd4868465225191bf432e781863590820aeb8 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sat, 22 Jul 2023 10:13:03 -0400 Subject: [PATCH 06/21] feat: can handle not equal collection --- QueryKit.UnitTests/FilterParserTests.cs | 27 ++++++++++++++++-------- QueryKit/Operators/ComparisonOperator.cs | 26 ++++++++++++++++++++++- 2 files changed, 43 insertions(+), 10 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 6d92d1d..73be6cc 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -575,15 +575,6 @@ public void can_filter_within_nested_collection() filterExpression.Compile().Invoke(fakeRecipeOne).Should().BeTrue(); } - [Fact] - public void simple_child_collection_for_string_not_equal() - { - var input = """Ingredients.Name != "flour" """; - var filterExpression = FilterParser.ParseFilter(input); - filterExpression.ToString().Should() - .Be(""""x => (x.Ingredients.Select(y => y.Name) != "flour")""""); - } - [Fact] public void simple_child_collection_for_string_equal() { @@ -601,5 +592,23 @@ public void simple_child_collection_for_string_case_insensitive_equal() filterExpression.ToString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => (x.ToLower() == "flour".ToLower()))""""); } + + [Fact] + public void simple_child_collection_for_string_not_equal() + { + var input = """Ingredients.Name != "flour" """; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => (x != "flour"))""""); + } + + [Fact] + public void simple_child_collection_for_string_case_insensitive_not_equal() + { + var input = """Ingredients.Name !=* "flour" """; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => (x.ToLower() != "flour".ToLower()))""""); + } } \ No newline at end of file diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index f422007..00d210a 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -181,7 +181,32 @@ public NotEqualsType(bool caseInsensitive = false) : base("!=", 1, caseInsensiti public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "x"); + Expression body; + + if (CaseInsensitive && xParameter.Type == typeof(string) && right.Type == typeof(string)) + { + var toLowerLeft = Expression.Call(xParameter, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); + var toLowerRight = Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); + + body = Expression.NotEqual(toLowerLeft, toLowerRight); + } + else + { + body = Expression.NotEqual(xParameter, right); + } + + var anyLambda = Expression.Lambda(body, xParameter); + var anyMethod = typeof(Enumerable) + .GetMethods() + .Single(m => m.Name == "Any" && m.GetParameters().Length == 2) + .MakeGenericMethod(left.Type.GetGenericArguments()[0]); + return Expression.Call(anyMethod, left, anyLambda); + } + if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) { return Expression.NotEqual( @@ -194,7 +219,6 @@ public override Expression GetExpression(Expression left, Expression right, T } } - private class GreaterThanType : ComparisonOperator { public GreaterThanType(bool caseInsensitive = false) : base(">", 2, caseInsensitive) From a5c4f7479ebb5607d41d3a779c81cf3a2b51c598 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sat, 22 Jul 2023 10:19:13 -0400 Subject: [PATCH 07/21] feat: collections with greaters and lesses --- QueryKit.UnitTests/FilterParserTests.cs | 36 ++++++++ .../Entities/Ingredients/Ingredient.cs | 2 + QueryKit/Operators/ComparisonOperator.cs | 88 +++++++++---------- 3 files changed, 82 insertions(+), 44 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 73be6cc..4d394f8 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -610,5 +610,41 @@ public void simple_child_collection_for_string_case_insensitive_not_equal() filterExpression.ToString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => (x.ToLower() != "flour".ToLower()))""""); } + + [Fact] + public void simple_child_collection_for_string_greater_than() + { + var input = """Ingredients.MinimumQuality > 5"""; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(x => (x > 5))""""); + } + + [Fact] + public void simple_child_collection_for_string_less_than() + { + var input = """Ingredients.MinimumQuality < 5"""; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(x => (x < 5))""""); + } + + [Fact] + public void simple_child_collection_for_string_greater_than_or_equal() + { + var input = """Ingredients.MinimumQuality >= 5"""; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(x => (x >= 5))""""); + } + + [Fact] + public void simple_child_collection_for_string_less_than_or_equal() + { + var input = """Ingredients.MinimumQuality <= 5"""; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(x => (x <= 5))""""); + } } \ No newline at end of file diff --git a/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs b/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs index c06c2b7..c76b21f 100644 --- a/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs +++ b/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs @@ -21,6 +21,8 @@ public class Ingredient : BaseEntity public string Measure { get; private set; } + public int MinimumQuality { get; private set; } + public List Preparations { get; set; } = new(); [JsonIgnore, IgnoreDataMember] diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 00d210a..8c0b243 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -136,28 +136,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "x"); - Expression body; - - if (CaseInsensitive && xParameter.Type == typeof(string) && right.Type == typeof(string)) - { - var toLowerLeft = Expression.Call(xParameter, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); - var toLowerRight = Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); - - body = Expression.Equal(toLowerLeft, toLowerRight); - } - else - { - body = Expression.Equal(xParameter, right); - } - - var anyLambda = Expression.Lambda(body, xParameter); - var anyMethod = typeof(Enumerable) - .GetMethods() - .Single(m => m.Name == "Any" && m.GetParameters().Length == 2) - .MakeGenericMethod(left.Type.GetGenericArguments()[0]); - - return Expression.Call(anyMethod, left, anyLambda); + return GetCollectionExpression(left, right, Expression.Equal); } if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) @@ -183,28 +162,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "x"); - Expression body; - - if (CaseInsensitive && xParameter.Type == typeof(string) && right.Type == typeof(string)) - { - var toLowerLeft = Expression.Call(xParameter, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); - var toLowerRight = Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); - - body = Expression.NotEqual(toLowerLeft, toLowerRight); - } - else - { - body = Expression.NotEqual(xParameter, right); - } - - var anyLambda = Expression.Lambda(body, xParameter); - var anyMethod = typeof(Enumerable) - .GetMethods() - .Single(m => m.Name == "Any" && m.GetParameters().Length == 2) - .MakeGenericMethod(left.Type.GetGenericArguments()[0]); - - return Expression.Call(anyMethod, left, anyLambda); + return GetCollectionExpression(left, right, Expression.NotEqual); } if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) @@ -228,6 +186,10 @@ public GreaterThanType(bool caseInsensitive = false) : base(">", 2, caseInsensit public override string Operator() => Name; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + return GetCollectionExpression(left, right, Expression.GreaterThan); + } return Expression.GreaterThan(left, right); } } @@ -241,6 +203,10 @@ public LessThanType(bool caseInsensitive = false) : base("<", 3, caseInsensitive public override string Operator() => Name; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + return GetCollectionExpression(left, right, Expression.LessThan); + } return Expression.LessThan(left, right); } } @@ -253,6 +219,10 @@ public GreaterThanOrEqualType(bool caseInsensitive = false) : base(">=", 4, case } public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + return GetCollectionExpression(left, right, Expression.GreaterThanOrEqual); + } return Expression.GreaterThanOrEqual(left, right); } } @@ -265,6 +235,10 @@ public LessThanOrEqualType(bool caseInsensitive = false) : base("<=", 5, caseIns public override string Operator() => Name; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + return GetCollectionExpression(left, right, Expression.LessThanOrEqual); + } return Expression.LessThanOrEqual(left, right); } } @@ -584,4 +558,30 @@ internal static List GetAliasMatches(IQueryKitConfiguratio return matches; } + + private Expression GetCollectionExpression(Expression left, Expression right, Func comparisonFunction) + { + var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "x"); + Expression body; + + if (CaseInsensitive && xParameter.Type == typeof(string) && right.Type == typeof(string)) + { + var toLowerLeft = Expression.Call(xParameter, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); + var toLowerRight = Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); + + body = comparisonFunction(toLowerLeft, toLowerRight); + } + else + { + body = comparisonFunction(xParameter, right); + } + + var anyLambda = Expression.Lambda(body, xParameter); + var anyMethod = typeof(Enumerable) + .GetMethods() + .Single(m => m.Name == "Any" && m.GetParameters().Length == 2) + .MakeGenericMethod(left.Type.GetGenericArguments()[0]); + + return Expression.Call(anyMethod, left, anyLambda); + } } From 336a4ab4e8553eb73c7282f86d9fcbfad4713909 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sat, 22 Jul 2023 10:38:07 -0400 Subject: [PATCH 08/21] feat: collections contains and starts with --- QueryKit.UnitTests/FilterParserTests.cs | 27 ++++++++++++ QueryKit/Operators/ComparisonOperator.cs | 56 ++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 4d394f8..0b1fdf6 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -646,5 +646,32 @@ public void simple_child_collection_for_string_less_than_or_equal() filterExpression.ToString().Should() .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(x => (x <= 5))""""); } + + [Fact] + public void collection_contains_case_insensitive() + { + var input = """"Ingredients.Name @=* "waffle" """"; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => x.ToLower().Contains("waffle".ToLower()))""""); + } + + [Fact] + public void collection_contains() + { + var input = """"Ingredients.Name @= "waffle" """"; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => x.Contains("waffle"))""""); + } + + [Fact] + public void collection_starts_with() + { + var input = """"Ingredients.Name _= "waffle" """"; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => x.StartsWith("waffle"))""""); + } } \ No newline at end of file diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 8c0b243..4eada38 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -252,7 +252,32 @@ public ContainsType(bool caseInsensitive = false) : base("@=", 6, caseInsensitiv public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { - if (CaseInsensitive) + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "x"); + Expression body; + + if (CaseInsensitive && xParameter.Type == typeof(string) && right.Type == typeof(string)) + { + var toLowerLeft = Expression.Call(xParameter, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); + var toLowerRight = Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); + body = Expression.Call(toLowerLeft, typeof(string).GetMethod("Contains", new[] { typeof(string) }), toLowerRight); + } + else + { + body = Expression.Call(xParameter, typeof(string).GetMethod("Contains", new[] { typeof(string) }), right); + } + + var anyLambda = Expression.Lambda(body, xParameter); + var anyMethod = typeof(Enumerable) + .GetMethods() + .Single(m => m.Name == "Any" && m.GetParameters().Length == 2) + .MakeGenericMethod(left.Type.GetGenericArguments()[0]); + + return Expression.Call(anyMethod, left, anyLambda); + } + + if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) { return Expression.Call( Expression.Call(left, "ToLower", null), @@ -274,7 +299,32 @@ public StartsWithType(bool caseInsensitive = false) : base("_=", 7, caseInsensit public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { - if (CaseInsensitive) + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "x"); + Expression body; + + if (CaseInsensitive && xParameter.Type == typeof(string) && right.Type == typeof(string)) + { + var toLowerLeft = Expression.Call(xParameter, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); + var toLowerRight = Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); + body = Expression.Call(toLowerLeft, typeof(string).GetMethod("StartsWith", new[] { typeof(string) }), toLowerRight); + } + else + { + body = Expression.Call(xParameter, typeof(string).GetMethod("StartsWith", new[] { typeof(string) }), right); + } + + var anyLambda = Expression.Lambda(body, xParameter); + var anyMethod = typeof(Enumerable) + .GetMethods() + .Single(m => m.Name == "Any" && m.GetParameters().Length == 2) + .MakeGenericMethod(left.Type.GetGenericArguments()[0]); + + return Expression.Call(anyMethod, left, anyLambda); + } + + if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) { return Expression.Call( Expression.Call(left, "ToLower", null), @@ -282,7 +332,7 @@ public override Expression GetExpression(Expression left, Expression right, T Expression.Call(right, "ToLower", null) ); } - + return Expression.Call(left, typeof(string).GetMethod("StartsWith", new[] { typeof(string) }), right); } } From d49327f5e55fd92531c6e8e274a9ad9aa92e5b1f Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sat, 22 Jul 2023 10:39:22 -0400 Subject: [PATCH 09/21] feat: ends with collections --- QueryKit.UnitTests/FilterParserTests.cs | 9 +++ QueryKit/Operators/ComparisonOperator.cs | 76 +++++++++++------------- 2 files changed, 43 insertions(+), 42 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 0b1fdf6..5733902 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -673,5 +673,14 @@ public void collection_starts_with() filterExpression.ToString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => x.StartsWith("waffle"))""""); } + + [Fact] + public void collection_ends_with() + { + var input = """"Ingredients.Name _-= "waffle" """"; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => x.EndsWith("waffle"))""""); + } } \ No newline at end of file diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 4eada38..51c6a2a 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -254,27 +254,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "x"); - Expression body; - - if (CaseInsensitive && xParameter.Type == typeof(string) && right.Type == typeof(string)) - { - var toLowerLeft = Expression.Call(xParameter, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); - var toLowerRight = Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); - body = Expression.Call(toLowerLeft, typeof(string).GetMethod("Contains", new[] { typeof(string) }), toLowerRight); - } - else - { - body = Expression.Call(xParameter, typeof(string).GetMethod("Contains", new[] { typeof(string) }), right); - } - - var anyLambda = Expression.Lambda(body, xParameter); - var anyMethod = typeof(Enumerable) - .GetMethods() - .Single(m => m.Name == "Any" && m.GetParameters().Length == 2) - .MakeGenericMethod(left.Type.GetGenericArguments()[0]); - - return Expression.Call(anyMethod, left, anyLambda); + return GetCollectionExpression(left, right, "Contains"); } if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) @@ -301,27 +281,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "x"); - Expression body; - - if (CaseInsensitive && xParameter.Type == typeof(string) && right.Type == typeof(string)) - { - var toLowerLeft = Expression.Call(xParameter, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); - var toLowerRight = Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); - body = Expression.Call(toLowerLeft, typeof(string).GetMethod("StartsWith", new[] { typeof(string) }), toLowerRight); - } - else - { - body = Expression.Call(xParameter, typeof(string).GetMethod("StartsWith", new[] { typeof(string) }), right); - } - - var anyLambda = Expression.Lambda(body, xParameter); - var anyMethod = typeof(Enumerable) - .GetMethods() - .Single(m => m.Name == "Any" && m.GetParameters().Length == 2) - .MakeGenericMethod(left.Type.GetGenericArguments()[0]); - - return Expression.Call(anyMethod, left, anyLambda); + return GetCollectionExpression(left, right, "StartsWith"); } if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) @@ -346,6 +306,11 @@ public EndsWithType(bool caseInsensitive = false) : base("_-=", 8, caseInsensiti public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + return GetCollectionExpression(left, right, "EndsWith"); + } + if (CaseInsensitive) { return Expression.Call( @@ -634,4 +599,31 @@ private Expression GetCollectionExpression(Expression left, Expression right, Fu return Expression.Call(anyMethod, left, anyLambda); } + + protected Expression GetCollectionExpression(Expression left, Expression right, string methodName) + { + var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "x"); + Expression body; + + if (CaseInsensitive && xParameter.Type == typeof(string) && right.Type == typeof(string)) + { + var toLowerLeft = Expression.Call(xParameter, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); + var toLowerRight = Expression.Call(right, typeof(string).GetMethod("ToLower", Type.EmptyTypes)); + + body = Expression.Call(toLowerLeft, typeof(string).GetMethod(methodName, new[] { typeof(string) }), toLowerRight); + } + else + { + body = Expression.Call(xParameter, typeof(string).GetMethod(methodName, new[] { typeof(string) }), right); + } + + var anyLambda = Expression.Lambda(body, xParameter); + var anyMethod = typeof(Enumerable) + .GetMethods() + .Single(m => m.Name == "Any" && m.GetParameters().Length == 2) + .MakeGenericMethod(left.Type.GetGenericArguments()[0]); + + return Expression.Call(anyMethod, left, anyLambda); + } + } From baf1ea2e0f7f64469185be162d54d0754a6b67bb Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sat, 22 Jul 2023 15:44:31 -0400 Subject: [PATCH 10/21] feat: collections not contain/start/end --- .../Tests/DatabaseFilteringTests.cs | 94 +++++++++++++++++++ QueryKit.UnitTests/FilterParserTests.cs | 27 ++++++ .../Database/TestingDbContext.cs | 3 + .../Entities/Ingredients/Ingredient.cs | 2 +- ...22192729_BaseTestingMigration.Designer.cs} | 52 +++++++++- ...=> 20230722192729_BaseTestingMigration.cs} | 39 ++++++-- .../TestingDbContextModelSnapshot.cs | 50 +++++++++- QueryKit/Operators/ComparisonOperator.cs | 28 ++++-- 8 files changed, 273 insertions(+), 22 deletions(-) rename QueryKit.WebApiTestProject/Migrations/{20230718122822_BaseTestingMigration.Designer.cs => 20230722192729_BaseTestingMigration.Designer.cs} (83%) rename QueryKit.WebApiTestProject/Migrations/{20230718122822_BaseTestingMigration.cs => 20230722192729_BaseTestingMigration.cs} (78%) diff --git a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs index 72223ac..e406ac8 100644 --- a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs +++ b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs @@ -7,6 +7,7 @@ namespace QueryKit.IntegrationTests.Tests; using Microsoft.EntityFrameworkCore; using SharedTestingHelper.Fakes; using SharedTestingHelper.Fakes.Author; +using SharedTestingHelper.Fakes.Ingredients; using SharedTestingHelper.Fakes.Recipes; using WebApiTestProject.Database; using WebApiTestProject.Entities; @@ -39,6 +40,99 @@ public async Task can_filter_by_string() people[0].Id.Should().Be(fakePersonOne.Id); } + [Fact] + public async Task can_filter_by_string_for_collection() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var faker = new Faker(); + var fakeIngredientOne = new FakeIngredientBuilder() + .WithName(faker.Lorem.Sentence()) + .Build(); + var fakeRecipeOne = new FakeRecipeBuilder().Build(); + fakeRecipeOne.AddIngredient(fakeIngredientOne); + + var fakeIngredientTwo = new FakeIngredientBuilder() + .WithName(faker.Lorem.Sentence()) + .Build(); + var fakeRecipeTwo = new FakeRecipeBuilder().Build(); + fakeRecipeTwo.AddIngredient(fakeIngredientTwo); + await testingServiceScope.InsertAsync(fakeRecipeOne, fakeRecipeTwo); + + var input = $"""Ingredients.Name == "{fakeIngredientOne.Name}" """; + + // Act + var queryablePeople = testingServiceScope.DbContext().Recipes; + var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input); + 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_contains() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var faker = new Faker(); + var fakeIngredientOne = new FakeIngredientBuilder() + .WithName($"{faker.Lorem.Sentence()}partial") + .Build(); + var fakeRecipeOne = new FakeRecipeBuilder().Build(); + fakeRecipeOne.AddIngredient(fakeIngredientOne); + + var fakeIngredientTwo = new FakeIngredientBuilder() + .WithName(faker.Lorem.Sentence()) + .Build(); + var fakeRecipeTwo = new FakeRecipeBuilder().Build(); + fakeRecipeTwo.AddIngredient(fakeIngredientTwo); + await testingServiceScope.InsertAsync(fakeRecipeOne, fakeRecipeTwo); + + var input = $"""Ingredients.Name @= "partial" """; + + // Act + var queryablePeople = testingServiceScope.DbContext().Recipes; + var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input); + 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() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var faker = new Faker(); + var fakeIngredientOne = new FakeIngredientBuilder() + .WithName($"{faker.Lorem.Sentence()}partial") + .Build(); + var fakeRecipeOne = new FakeRecipeBuilder().Build(); + fakeRecipeOne.AddIngredient(fakeIngredientOne); + + var fakeIngredientTwo = new FakeIngredientBuilder() + .WithName(faker.Lorem.Sentence()) + .Build(); + var fakeRecipeTwo = new FakeRecipeBuilder().Build(); + fakeRecipeTwo.AddIngredient(fakeIngredientTwo); + await testingServiceScope.InsertAsync(fakeRecipeOne, fakeRecipeTwo); + + var input = $"""Ingredients.Name !@= "partial" """; + + // Act + var queryablePeople = testingServiceScope.DbContext().Recipes; + var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input); + var recipes = await appliedQueryable.ToListAsync(); + + // Assert + recipes.FirstOrDefault(x => x.Id == fakeRecipeOne.Id).Should().BeNull(); + recipes.FirstOrDefault(x => x.Id == fakeRecipeTwo.Id).Should().NotBeNull(); + } + [Fact] public async Task can_use_soundex_equals() { diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 5733902..1bfb374 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -682,5 +682,32 @@ public void collection_ends_with() filterExpression.ToString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => x.EndsWith("waffle"))""""); } + + [Fact] + public void collection_does_not_contains() + { + var input = """"Ingredients.Name !@= "waffle" """"; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => Not(x.Ingredients.Select(y => y.Name).Any(x => x.Contains("waffle")))""""); + } + + [Fact] + public void collection_does_not_starts_with() + { + var input = """"Ingredients.Name !_= "waffle" """"; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => Not(x.Ingredients.Select(y => y.Name).Any(x => x.StartsWith("waffle")))""""); + } + + [Fact] + public void collection_does_not_ends_with() + { + var input = """"Ingredients.Name !_-= "waffle" """"; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => Not(x.Ingredients.Select(y => y.Name).Any(x => x.EndsWith("waffle")))""""); + } } \ No newline at end of file diff --git a/QueryKit.WebApiTestProject/Database/TestingDbContext.cs b/QueryKit.WebApiTestProject/Database/TestingDbContext.cs index 4e0b447..1afb7ea 100644 --- a/QueryKit.WebApiTestProject/Database/TestingDbContext.cs +++ b/QueryKit.WebApiTestProject/Database/TestingDbContext.cs @@ -1,5 +1,6 @@ namespace QueryKit.WebApiTestProject.Database; +using Entities.Ingredients; using Entities.Recipes; using Microsoft.EntityFrameworkCore; using QueryKit.WebApiTestProject.Entities; @@ -16,6 +17,8 @@ public TestingDbContext(DbContextOptions options) public DbSet People { get; set; } public DbSet Recipes { get; set; } + public DbSet IngredientPreparations { get; set; } + public DbSet Ingredients { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { diff --git a/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs b/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs index c76b21f..194e6f5 100644 --- a/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs +++ b/QueryKit.WebApiTestProject/Entities/Ingredients/Ingredient.cs @@ -6,7 +6,7 @@ namespace QueryKit.WebApiTestProject.Entities.Ingredients; using Models; using Recipes; -public class IngredientPreparation +public class IngredientPreparation : BaseEntity { public string Text { get; set; } } diff --git a/QueryKit.WebApiTestProject/Migrations/20230718122822_BaseTestingMigration.Designer.cs b/QueryKit.WebApiTestProject/Migrations/20230722192729_BaseTestingMigration.Designer.cs similarity index 83% rename from QueryKit.WebApiTestProject/Migrations/20230718122822_BaseTestingMigration.Designer.cs rename to QueryKit.WebApiTestProject/Migrations/20230722192729_BaseTestingMigration.Designer.cs index d98a6f6..74d0258 100644 --- a/QueryKit.WebApiTestProject/Migrations/20230718122822_BaseTestingMigration.Designer.cs +++ b/QueryKit.WebApiTestProject/Migrations/20230722192729_BaseTestingMigration.Designer.cs @@ -12,7 +12,7 @@ namespace QueryKit.WebApiTestProject.Migrations { [DbContext(typeof(TestingDbContext))] - [Migration("20230718122822_BaseTestingMigration")] + [Migration("20230722192729_BaseTestingMigration")] partial class BaseTestingMigration { /// @@ -68,6 +68,10 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("measure"); + b.Property("MinimumQuality") + .HasColumnType("integer") + .HasColumnName("minimum_quality"); + b.Property("Name") .IsRequired() .HasColumnType("text") @@ -83,12 +87,37 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnName("recipe_id"); b.HasKey("Id") - .HasName("pk_ingredient"); + .HasName("pk_ingredients"); b.HasIndex("RecipeId") - .HasDatabaseName("ix_ingredient_recipe_id"); + .HasDatabaseName("ix_ingredients_recipe_id"); - b.ToTable("ingredient", (string)null); + b.ToTable("ingredients", (string)null); + }); + + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Ingredients.IngredientPreparation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IngredientId") + .HasColumnType("uuid") + .HasColumnName("ingredient_id"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_ingredient_preparations"); + + b.HasIndex("IngredientId") + .HasDatabaseName("ix_ingredient_preparations_ingredient_id"); + + b.ToTable("ingredient_preparations", (string)null); }); modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Recipes.Recipe", b => @@ -203,11 +232,19 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasForeignKey("RecipeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_ingredient_recipes_recipe_id"); + .HasConstraintName("fk_ingredients_recipes_recipe_id"); b.Navigation("Recipe"); }); + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Ingredients.IngredientPreparation", b => + { + b.HasOne("QueryKit.WebApiTestProject.Entities.Ingredients.Ingredient", null) + .WithMany("Preparations") + .HasForeignKey("IngredientId") + .HasConstraintName("fk_ingredient_preparations_ingredients_ingredient_id"); + }); + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.TestingPerson", b => { b.OwnsOne("QueryKit.WebApiTestProject.Entities.Address", "PhysicalAddress", b1 => @@ -259,6 +296,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Ingredients.Ingredient", b => + { + b.Navigation("Preparations"); + }); + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Recipes.Recipe", b => { b.Navigation("Author") diff --git a/QueryKit.WebApiTestProject/Migrations/20230718122822_BaseTestingMigration.cs b/QueryKit.WebApiTestProject/Migrations/20230722192729_BaseTestingMigration.cs similarity index 78% rename from QueryKit.WebApiTestProject/Migrations/20230718122822_BaseTestingMigration.cs rename to QueryKit.WebApiTestProject/Migrations/20230722192729_BaseTestingMigration.cs index 80049cf..d85e270 100644 --- a/QueryKit.WebApiTestProject/Migrations/20230718122822_BaseTestingMigration.cs +++ b/QueryKit.WebApiTestProject/Migrations/20230722192729_BaseTestingMigration.cs @@ -78,7 +78,7 @@ protected override void Up(MigrationBuilder migrationBuilder) }); migrationBuilder.CreateTable( - name: "ingredient", + name: "ingredients", columns: table => new { id = table.Column(type: "uuid", nullable: false), @@ -86,19 +86,38 @@ protected override void Up(MigrationBuilder migrationBuilder) quantity = table.Column(type: "text", nullable: false), 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), recipe_id = table.Column(type: "uuid", nullable: false) }, constraints: table => { - table.PrimaryKey("pk_ingredient", x => x.id); + table.PrimaryKey("pk_ingredients", x => x.id); table.ForeignKey( - name: "fk_ingredient_recipes_recipe_id", + name: "fk_ingredients_recipes_recipe_id", column: x => x.recipe_id, principalTable: "recipes", principalColumn: "id", onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "ingredient_preparations", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + text = table.Column(type: "text", nullable: false), + ingredient_id = table.Column(type: "uuid", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("pk_ingredient_preparations", x => x.id); + table.ForeignKey( + name: "fk_ingredient_preparations_ingredients_ingredient_id", + column: x => x.ingredient_id, + principalTable: "ingredients", + principalColumn: "id"); + }); + migrationBuilder.CreateIndex( name: "ix_author_recipe_id", table: "author", @@ -106,8 +125,13 @@ protected override void Up(MigrationBuilder migrationBuilder) unique: true); migrationBuilder.CreateIndex( - name: "ix_ingredient_recipe_id", - table: "ingredient", + name: "ix_ingredient_preparations_ingredient_id", + table: "ingredient_preparations", + column: "ingredient_id"); + + migrationBuilder.CreateIndex( + name: "ix_ingredients_recipe_id", + table: "ingredients", column: "recipe_id"); } @@ -118,11 +142,14 @@ protected override void Down(MigrationBuilder migrationBuilder) name: "author"); migrationBuilder.DropTable( - name: "ingredient"); + name: "ingredient_preparations"); migrationBuilder.DropTable( name: "people"); + migrationBuilder.DropTable( + name: "ingredients"); + migrationBuilder.DropTable( name: "recipes"); } diff --git a/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs b/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs index 4af0260..322f7b9 100644 --- a/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs +++ b/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs @@ -65,6 +65,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("text") .HasColumnName("measure"); + b.Property("MinimumQuality") + .HasColumnType("integer") + .HasColumnName("minimum_quality"); + b.Property("Name") .IsRequired() .HasColumnType("text") @@ -80,12 +84,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnName("recipe_id"); b.HasKey("Id") - .HasName("pk_ingredient"); + .HasName("pk_ingredients"); b.HasIndex("RecipeId") - .HasDatabaseName("ix_ingredient_recipe_id"); + .HasDatabaseName("ix_ingredients_recipe_id"); - b.ToTable("ingredient", (string)null); + b.ToTable("ingredients", (string)null); + }); + + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Ingredients.IngredientPreparation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("IngredientId") + .HasColumnType("uuid") + .HasColumnName("ingredient_id"); + + b.Property("Text") + .IsRequired() + .HasColumnType("text") + .HasColumnName("text"); + + b.HasKey("Id") + .HasName("pk_ingredient_preparations"); + + b.HasIndex("IngredientId") + .HasDatabaseName("ix_ingredient_preparations_ingredient_id"); + + b.ToTable("ingredient_preparations", (string)null); }); modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Recipes.Recipe", b => @@ -200,11 +229,19 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("RecipeId") .OnDelete(DeleteBehavior.Cascade) .IsRequired() - .HasConstraintName("fk_ingredient_recipes_recipe_id"); + .HasConstraintName("fk_ingredients_recipes_recipe_id"); b.Navigation("Recipe"); }); + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Ingredients.IngredientPreparation", b => + { + b.HasOne("QueryKit.WebApiTestProject.Entities.Ingredients.Ingredient", null) + .WithMany("Preparations") + .HasForeignKey("IngredientId") + .HasConstraintName("fk_ingredient_preparations_ingredients_ingredient_id"); + }); + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.TestingPerson", b => { b.OwnsOne("QueryKit.WebApiTestProject.Entities.Address", "PhysicalAddress", b1 => @@ -256,6 +293,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired(); }); + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Ingredients.Ingredient", b => + { + b.Navigation("Preparations"); + }); + modelBuilder.Entity("QueryKit.WebApiTestProject.Entities.Recipes.Recipe", b => { b.Navigation("Author") diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 51c6a2a..7cff8dd 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -254,7 +254,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return GetCollectionExpression(left, right, "Contains"); + return GetCollectionExpression(left, right, "Contains", false); } if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) @@ -281,7 +281,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return GetCollectionExpression(left, right, "StartsWith"); + return GetCollectionExpression(left, right, "StartsWith", false); } if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) @@ -308,7 +308,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return GetCollectionExpression(left, right, "EndsWith"); + return GetCollectionExpression(left, right, "EndsWith", false); } if (CaseInsensitive) @@ -333,6 +333,11 @@ public NotContainsType(bool caseInsensitive = false) : base("!@=", 9, caseInsens public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + return GetCollectionExpression(left, right, "Contains", true); + } + if(CaseInsensitive) { return Expression.Not(Expression.Call( @@ -356,6 +361,11 @@ public NotStartsWithType(bool caseInsensitive = false) : base("!_=", 10, caseIns public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + return GetCollectionExpression(left, right, "StartsWith", true); + } + if (CaseInsensitive) { return Expression.Not(Expression.Call( @@ -378,6 +388,11 @@ public NotEndsWithType(bool caseInsensitive = false) : base("!_-=", 11, caseInse public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { + if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) + { + return GetCollectionExpression(left, right, "EndsWith", true); + } + if (CaseInsensitive) { return Expression.Not(Expression.Call( @@ -600,7 +615,7 @@ private Expression GetCollectionExpression(Expression left, Expression right, Fu return Expression.Call(anyMethod, left, anyLambda); } - protected Expression GetCollectionExpression(Expression left, Expression right, string methodName) + protected Expression GetCollectionExpression(Expression left, Expression right, string methodName, bool negate) { var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "x"); Expression body; @@ -623,7 +638,8 @@ protected Expression GetCollectionExpression(Expression left, Expression right, .Single(m => m.Name == "Any" && m.GetParameters().Length == 2) .MakeGenericMethod(left.Type.GetGenericArguments()[0]); - return Expression.Call(anyMethod, left, anyLambda); + return negate + ? Expression.Not(Expression.Call(anyMethod, left, anyLambda)) + : Expression.Call(anyMethod, left, anyLambda); } - } From e1a23e3117f3ab7ee653a0d835c4e9da6341a6af Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sat, 22 Jul 2023 20:32:31 -0400 Subject: [PATCH 11/21] update: use z modifier --- QueryKit.UnitTests/FilterParserTests.cs | 30 ++++++++++++------------ QueryKit/Operators/ComparisonOperator.cs | 4 ++-- 2 files changed, 17 insertions(+), 17 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 1bfb374..5c9c5f7 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -581,7 +581,7 @@ public void simple_child_collection_for_string_equal() var input = """Ingredients.Name == "flour" """; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => (x == "flour"))""""); + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => (z == "flour"))""""); } [Fact] @@ -590,7 +590,7 @@ public void simple_child_collection_for_string_case_insensitive_equal() var input = """Ingredients.Name ==* "flour" """; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => (x.ToLower() == "flour".ToLower()))""""); + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => (z.ToLower() == "flour".ToLower()))""""); } [Fact] @@ -599,7 +599,7 @@ public void simple_child_collection_for_string_not_equal() var input = """Ingredients.Name != "flour" """; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => (x != "flour"))""""); + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => (z != "flour"))""""); } [Fact] @@ -608,7 +608,7 @@ public void simple_child_collection_for_string_case_insensitive_not_equal() var input = """Ingredients.Name !=* "flour" """; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => (x.ToLower() != "flour".ToLower()))""""); + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => (z.ToLower() != "flour".ToLower()))""""); } [Fact] @@ -617,7 +617,7 @@ public void simple_child_collection_for_string_greater_than() var input = """Ingredients.MinimumQuality > 5"""; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(x => (x > 5))""""); + .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(z => (z > 5))""""); } [Fact] @@ -626,7 +626,7 @@ public void simple_child_collection_for_string_less_than() var input = """Ingredients.MinimumQuality < 5"""; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(x => (x < 5))""""); + .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(z => (z < 5))""""); } [Fact] @@ -635,7 +635,7 @@ public void simple_child_collection_for_string_greater_than_or_equal() var input = """Ingredients.MinimumQuality >= 5"""; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(x => (x >= 5))""""); + .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(z => (z >= 5))""""); } [Fact] @@ -644,7 +644,7 @@ public void simple_child_collection_for_string_less_than_or_equal() var input = """Ingredients.MinimumQuality <= 5"""; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(x => (x <= 5))""""); + .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(z => (z <= 5))""""); } [Fact] @@ -653,7 +653,7 @@ public void collection_contains_case_insensitive() var input = """"Ingredients.Name @=* "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => x.ToLower().Contains("waffle".ToLower()))""""); + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => z.ToLower().Contains("waffle".ToLower()))""""); } [Fact] @@ -662,7 +662,7 @@ public void collection_contains() var input = """"Ingredients.Name @= "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => x.Contains("waffle"))""""); + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => z.Contains("waffle"))""""); } [Fact] @@ -671,7 +671,7 @@ public void collection_starts_with() var input = """"Ingredients.Name _= "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => x.StartsWith("waffle"))""""); + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => z.StartsWith("waffle"))""""); } [Fact] @@ -680,7 +680,7 @@ public void collection_ends_with() var input = """"Ingredients.Name _-= "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => x.Ingredients.Select(y => y.Name).Any(x => x.EndsWith("waffle"))""""); + .Be(""""x => x.Ingredients.Select(y => y.Name).Any(z => z.EndsWith("waffle"))""""); } [Fact] @@ -689,7 +689,7 @@ public void collection_does_not_contains() var input = """"Ingredients.Name !@= "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => Not(x.Ingredients.Select(y => y.Name).Any(x => x.Contains("waffle")))""""); + .Be(""""x => Not(x.Ingredients.Select(y => y.Name).Any(z => z.Contains("waffle")))""""); } [Fact] @@ -698,7 +698,7 @@ public void collection_does_not_starts_with() var input = """"Ingredients.Name !_= "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => Not(x.Ingredients.Select(y => y.Name).Any(x => x.StartsWith("waffle")))""""); + .Be(""""x => Not(x.Ingredients.Select(y => y.Name).Any(z => z.StartsWith("waffle")))""""); } [Fact] @@ -707,7 +707,7 @@ public void collection_does_not_ends_with() var input = """"Ingredients.Name !_-= "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() - .Be(""""x => Not(x.Ingredients.Select(y => y.Name).Any(x => x.EndsWith("waffle")))""""); + .Be(""""x => Not(x.Ingredients.Select(y => y.Name).Any(z => z.EndsWith("waffle")))""""); } } \ No newline at end of file diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 7cff8dd..1b2fb1f 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -591,7 +591,7 @@ internal static List GetAliasMatches(IQueryKitConfiguratio private Expression GetCollectionExpression(Expression left, Expression right, Func comparisonFunction) { - var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "x"); + var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "z"); Expression body; if (CaseInsensitive && xParameter.Type == typeof(string) && right.Type == typeof(string)) @@ -617,7 +617,7 @@ private Expression GetCollectionExpression(Expression left, Expression right, Fu protected Expression GetCollectionExpression(Expression left, Expression right, string methodName, bool negate) { - var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "x"); + var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "z"); Expression body; if (CaseInsensitive && xParameter.Type == typeof(string) && right.Type == typeof(string)) From 2bf1ab359a98cd5b9117fc83c963c3a19c173051 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sat, 22 Jul 2023 20:33:23 -0400 Subject: [PATCH 12/21] fix: protection level --- QueryKit/Operators/ComparisonOperator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 1b2fb1f..c601491 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -615,7 +615,7 @@ private Expression GetCollectionExpression(Expression left, Expression right, Fu return Expression.Call(anyMethod, left, anyLambda); } - protected Expression GetCollectionExpression(Expression left, Expression right, string methodName, bool negate) + private Expression GetCollectionExpression(Expression left, Expression right, string methodName, bool negate) { var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "z"); Expression body; From a3206dbecf587ea4ab653015cac4abf351d8358e Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sat, 22 Jul 2023 20:38:30 -0400 Subject: [PATCH 13/21] refactor: minor cleanup --- QueryKit/FilterParser.cs | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index e660b71..0542c83 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -349,7 +349,6 @@ private static Parser ComparisonExprParser(ParameterExpression pa { if (expr is MemberExpression member) { - // check if member is IEnumerable if (IsEnumerable(member.Type)) { var genericArgType = member.Type.GetGenericArguments()[0]; @@ -368,8 +367,8 @@ private static Parser ComparisonExprParser(ParameterExpression pa var propertyInfoForMethod = GetPropertyInfo(genericArgType, propName); Expression lambdaBody = Expression.PropertyOrField(innerParameter, propertyInfoForMethod.Name); - var t = typeof(IEnumerable<>).MakeGenericType(propertyType); - lambdaBody = Expression.Lambda(Expression.Convert(lambdaBody, t), innerParameter); + var type = typeof(IEnumerable<>).MakeGenericType(propertyType); + lambdaBody = Expression.Lambda(Expression.Convert(lambdaBody, type), innerParameter); return Expression.Call(selectMethod, member, lambdaBody); } @@ -391,13 +390,9 @@ private static Parser ComparisonExprParser(ParameterExpression pa if (expr is MethodCallExpression call) { - // Find the inner generic type of the last argument var innerGenericType = GetInnerGenericType(call.Method.ReturnType); - - // Find the PropertyInfo on the innerGenericType for the property named propName var propertyInfoForMethod = GetPropertyInfo(innerGenericType, propName); - // Create a Select expression for this property var linqMethod = IsEnumerable(innerGenericType) ? "SelectMany" : "Select"; var selectMethod = typeof(Enumerable).GetMethods() .First(m => m.Name == linqMethod && m.GetParameters().Length == 2) @@ -438,16 +433,12 @@ private static Parser ComparisonExprParser(ParameterExpression pa private static Type? GetInnerGenericType(Type type) { - // If the type is not an IEnumerable, return the original type if (!IsEnumerable(type)) { return type; } - // If the type is an IEnumerable, get the inner generic type var innerGenericType = type.GetGenericArguments()[0]; - - // Recursively check if the inner type is also an IEnumerable return GetInnerGenericType(innerGenericType); } From 7ba7495429b2c49956de35d35b789ede8c584769 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sat, 22 Jul 2023 21:52:27 -0400 Subject: [PATCH 14/21] feat: can handle `All` --- QueryKit.UnitTests/FilterParserTests.cs | 9 ++ QueryKit/FilterParser.cs | 36 ++++--- QueryKit/Operators/ComparisonOperator.cs | 129 ++++++++++++----------- 3 files changed, 94 insertions(+), 80 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 5c9c5f7..6a854be 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -709,5 +709,14 @@ public void collection_does_not_ends_with() filterExpression.ToString().Should() .Be(""""x => Not(x.Ingredients.Select(y => y.Name).Any(z => z.EndsWith("waffle")))""""); } + + [Fact] + public void collection_equals_with_all() + { + var input = """"Ingredients.Name #== "waffle" """"; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Ingredients.Select(y => y.Name).All(z => (z == "waffle"))""""); + } } \ No newline at end of file diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 0542c83..88dda90 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -43,23 +43,25 @@ from rest in Parse.LetterOrDigit.XOr(Parse.Char('_')).Many() select new string(first.Concat(rest).ToArray()); private static Parser ComparisonOperatorParser => - Parse.String(ComparisonOperator.EqualsOperator().Operator()).Text() - .Or(Parse.String(ComparisonOperator.NotEqualsOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.GreaterThanOrEqualOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.LessThanOrEqualOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.GreaterThanOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.LessThanOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.ContainsOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.StartsWithOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.EndsWithOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.NotContainsOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.NotStartsWithOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.NotEndsWithOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.InOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.SoundsLikeOperator().Operator()).Text()) - .Or(Parse.String(ComparisonOperator.DoesNotSoundLikeOperator().Operator()).Text()) - .SelectMany(op => Parse.Char(ComparisonOperator.CaseSensitiveAppendix).Optional(), (op, caseInsensitive) => new { op, caseInsensitive }) - .Select(x => ComparisonOperator.GetByOperatorString(x.op, x.caseInsensitive.IsDefined)); + Parse.Char(ComparisonOperator.AllPrefix).Optional().Select(opt => opt.IsDefined) + .Then(hasHash => + Parse.String(ComparisonOperator.EqualsOperator().Operator()).Text() + .Or(Parse.String(ComparisonOperator.NotEqualsOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.GreaterThanOrEqualOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.LessThanOrEqualOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.GreaterThanOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.LessThanOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.ContainsOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.StartsWithOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.EndsWithOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.NotContainsOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.NotStartsWithOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.NotEndsWithOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.InOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.SoundsLikeOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.DoesNotSoundLikeOperator().Operator()).Text()) + .SelectMany(op => Parse.Char(ComparisonOperator.CaseSensitiveAppendix).Optional(), (op, caseInsensitive) => new { op, caseInsensitive, hasHash }) + .Select(x => ComparisonOperator.GetByOperatorString(x.op, x.caseInsensitive.IsDefined, x.hasHash))); private static PropertyInfo? GetPropertyInfo(Type type, string propertyName) => type.GetProperty(propertyName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance); diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index c601491..668c510 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -23,24 +23,24 @@ public abstract class ComparisonOperator : SmartEnum public static ComparisonOperator CaseSensitiveSoundsLikeOperator = new SoundsLikeType(); public static ComparisonOperator CaseSensitiveDoesNotSoundLikeOperator = new DoesNotSoundLikeType(); - public static ComparisonOperator EqualsOperator(bool caseInsensitive = false) => new EqualsType(caseInsensitive); - public static ComparisonOperator NotEqualsOperator(bool caseInsensitive = false) => new NotEqualsType(caseInsensitive); - public static ComparisonOperator GreaterThanOperator(bool caseInsensitive = false) => new GreaterThanType(caseInsensitive); - public static ComparisonOperator LessThanOperator(bool caseInsensitive = false) => new LessThanType(caseInsensitive); - public static ComparisonOperator GreaterThanOrEqualOperator(bool caseInsensitive = false) => new GreaterThanOrEqualType(caseInsensitive); - public static ComparisonOperator LessThanOrEqualOperator(bool caseInsensitive = false) => new LessThanOrEqualType(caseInsensitive); - public static ComparisonOperator ContainsOperator(bool caseInsensitive = false) => new ContainsType(caseInsensitive); - public static ComparisonOperator StartsWithOperator(bool caseInsensitive = false) => new StartsWithType(caseInsensitive); - public static ComparisonOperator EndsWithOperator(bool caseInsensitive = false) => new EndsWithType(caseInsensitive); - public static ComparisonOperator NotContainsOperator(bool caseInsensitive = false) => new NotContainsType(caseInsensitive); - public static ComparisonOperator NotStartsWithOperator(bool caseInsensitive = false) => new NotStartsWithType(caseInsensitive); - public static ComparisonOperator NotEndsWithOperator(bool caseInsensitive = false) => new NotEndsWithType(caseInsensitive); - public static ComparisonOperator InOperator(bool caseInsensitive = false) => new InType(caseInsensitive); - public static ComparisonOperator SoundsLikeOperator(bool caseInsensitive = false) => new SoundsLikeType(caseInsensitive); - public static ComparisonOperator DoesNotSoundLikeOperator(bool caseInsensitive = false) => new DoesNotSoundLikeType(caseInsensitive); + public static ComparisonOperator EqualsOperator(bool caseInsensitive = false, bool usesAll = false) => new EqualsType(caseInsensitive); + public static ComparisonOperator NotEqualsOperator(bool caseInsensitive = false, bool usesAll = false) => new NotEqualsType(caseInsensitive); + public static ComparisonOperator GreaterThanOperator(bool caseInsensitive = false, bool usesAll = false) => new GreaterThanType(caseInsensitive); + public static ComparisonOperator LessThanOperator(bool caseInsensitive = false, bool usesAll = false) => new LessThanType(caseInsensitive); + public static ComparisonOperator GreaterThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new GreaterThanOrEqualType(caseInsensitive); + public static ComparisonOperator LessThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new LessThanOrEqualType(caseInsensitive); + public static ComparisonOperator ContainsOperator(bool caseInsensitive = false, bool usesAll = false) => new ContainsType(caseInsensitive); + public static ComparisonOperator StartsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new StartsWithType(caseInsensitive); + public static ComparisonOperator EndsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new EndsWithType(caseInsensitive); + public static ComparisonOperator NotContainsOperator(bool caseInsensitive = false, bool usesAll = false) => new NotContainsType(caseInsensitive); + public static ComparisonOperator NotStartsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new NotStartsWithType(caseInsensitive); + public static ComparisonOperator NotEndsWithOperator(bool caseInsensitive = false, bool usesAll = false) => new NotEndsWithType(caseInsensitive); + public static ComparisonOperator InOperator(bool caseInsensitive = false, bool usesAll = false) => new InType(caseInsensitive); + public static ComparisonOperator SoundsLikeOperator(bool caseInsensitive = false, bool usesAll = false) => new SoundsLikeType(caseInsensitive); + public static ComparisonOperator DoesNotSoundLikeOperator(bool caseInsensitive = false, bool usesAll = false) => new DoesNotSoundLikeType(caseInsensitive); - public static ComparisonOperator GetByOperatorString(string op, bool caseInsensitive = false) + public static ComparisonOperator GetByOperatorString(string op, bool caseInsensitive = false, bool usesAll = false) { var comparisonOperator = List.FirstOrDefault(x => x.Operator() == op); if (comparisonOperator == null) @@ -52,63 +52,63 @@ public static ComparisonOperator GetByOperatorString(string op, bool caseInsensi if (comparisonOperator is EqualsType) { - newOperator = new EqualsType(caseInsensitive); + newOperator = new EqualsType(caseInsensitive, usesAll); } if (comparisonOperator is NotEqualsType) { - newOperator = new NotEqualsType(caseInsensitive); + newOperator = new NotEqualsType(caseInsensitive, usesAll); } if (comparisonOperator is GreaterThanType) { - newOperator = new GreaterThanType(caseInsensitive); + newOperator = new GreaterThanType(caseInsensitive, usesAll); } if (comparisonOperator is LessThanType) { - newOperator = new LessThanType(caseInsensitive); + newOperator = new LessThanType(caseInsensitive, usesAll); } if (comparisonOperator is GreaterThanOrEqualType) { - newOperator = new GreaterThanOrEqualType(caseInsensitive); + newOperator = new GreaterThanOrEqualType(caseInsensitive, usesAll); } if (comparisonOperator is LessThanOrEqualType) { - newOperator = new LessThanOrEqualType(caseInsensitive); + newOperator = new LessThanOrEqualType(caseInsensitive, usesAll); } if (comparisonOperator is ContainsType) { - newOperator = new ContainsType(caseInsensitive); + newOperator = new ContainsType(caseInsensitive, usesAll); } if (comparisonOperator is StartsWithType) { - newOperator = new StartsWithType(caseInsensitive); + newOperator = new StartsWithType(caseInsensitive, usesAll); } if (comparisonOperator is EndsWithType) { - newOperator = new EndsWithType(caseInsensitive); + newOperator = new EndsWithType(caseInsensitive, usesAll); } if (comparisonOperator is NotContainsType) { - newOperator = new NotContainsType(caseInsensitive); + newOperator = new NotContainsType(caseInsensitive, usesAll); } if (comparisonOperator is NotStartsWithType) { - newOperator = new NotStartsWithType(caseInsensitive); + newOperator = new NotStartsWithType(caseInsensitive, usesAll); } if (comparisonOperator is NotEndsWithType) { - newOperator = new NotEndsWithType(caseInsensitive); + newOperator = new NotEndsWithType(caseInsensitive, usesAll); } if (comparisonOperator is InType) { - newOperator = new InType(caseInsensitive); + newOperator = new InType(caseInsensitive, usesAll); } if (comparisonOperator is SoundsLikeType) { - newOperator = new SoundsLikeType(caseInsensitive); + newOperator = new SoundsLikeType(caseInsensitive, usesAll); } if (comparisonOperator is DoesNotSoundLikeType) { - newOperator = new DoesNotSoundLikeType(caseInsensitive); + newOperator = new DoesNotSoundLikeType(caseInsensitive, usesAll); } return newOperator == null @@ -117,17 +117,20 @@ public static ComparisonOperator GetByOperatorString(string op, bool caseInsensi } public const char CaseSensitiveAppendix = '*'; + public const char AllPrefix = '#'; public abstract string Operator(); public bool CaseInsensitive { get; protected set; } + public bool UsesAll { get; protected set; } public abstract Expression GetExpression(Expression left, Expression right, Type? dbContextType); - protected ComparisonOperator(string name, int value, bool caseInsensitive = false) : base(name, value) + protected ComparisonOperator(string name, int value, bool caseInsensitive = false, bool usesAll = false) : base(name, value) { CaseInsensitive = caseInsensitive; + UsesAll = usesAll; } private class EqualsType : ComparisonOperator { - public EqualsType(bool caseInsensitive = false) : base("==", 0, caseInsensitive) + public EqualsType(bool caseInsensitive = false, bool usesAll = false) : base("==", 0, caseInsensitive, usesAll) { } @@ -136,7 +139,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return GetCollectionExpression(left, right, Expression.Equal); + return GetCollectionExpression(left, right, Expression.Equal, UsesAll); } if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) @@ -153,7 +156,7 @@ public override Expression GetExpression(Expression left, Expression right, T private class NotEqualsType : ComparisonOperator { - public NotEqualsType(bool caseInsensitive = false) : base("!=", 1, caseInsensitive) + public NotEqualsType(bool caseInsensitive = false, bool usesAll = false) : base("!=", 1, caseInsensitive, usesAll) { } @@ -162,7 +165,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return GetCollectionExpression(left, right, Expression.NotEqual); + return GetCollectionExpression(left, right, Expression.NotEqual, UsesAll); } if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) @@ -179,7 +182,7 @@ public override Expression GetExpression(Expression left, Expression right, T private class GreaterThanType : ComparisonOperator { - public GreaterThanType(bool caseInsensitive = false) : base(">", 2, caseInsensitive) + public GreaterThanType(bool caseInsensitive = false, bool usesAll = false) : base(">", 2, caseInsensitive, usesAll) { } @@ -188,7 +191,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return GetCollectionExpression(left, right, Expression.GreaterThan); + return GetCollectionExpression(left, right, Expression.GreaterThan, UsesAll); } return Expression.GreaterThan(left, right); } @@ -196,7 +199,7 @@ public override Expression GetExpression(Expression left, Expression right, T private class LessThanType : ComparisonOperator { - public LessThanType(bool caseInsensitive = false) : base("<", 3, caseInsensitive) + public LessThanType(bool caseInsensitive = false, bool usesAll = false) : base("<", 3, caseInsensitive, usesAll) { } @@ -205,7 +208,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return GetCollectionExpression(left, right, Expression.LessThan); + return GetCollectionExpression(left, right, Expression.LessThan, UsesAll); } return Expression.LessThan(left, right); } @@ -214,14 +217,14 @@ public override Expression GetExpression(Expression left, Expression right, T private class GreaterThanOrEqualType : ComparisonOperator { public override string Operator() => Name; - public GreaterThanOrEqualType(bool caseInsensitive = false) : base(">=", 4, caseInsensitive) + public GreaterThanOrEqualType(bool caseInsensitive = false, bool usesAll = false) : base(">=", 4, caseInsensitive, usesAll) { } public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return GetCollectionExpression(left, right, Expression.GreaterThanOrEqual); + return GetCollectionExpression(left, right, Expression.GreaterThanOrEqual, UsesAll); } return Expression.GreaterThanOrEqual(left, right); } @@ -229,7 +232,7 @@ public override Expression GetExpression(Expression left, Expression right, T private class LessThanOrEqualType : ComparisonOperator { - public LessThanOrEqualType(bool caseInsensitive = false) : base("<=", 5, caseInsensitive) + public LessThanOrEqualType(bool caseInsensitive = false, bool usesAll = false) : base("<=", 5, caseInsensitive, usesAll) { } public override string Operator() => Name; @@ -237,7 +240,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return GetCollectionExpression(left, right, Expression.LessThanOrEqual); + return GetCollectionExpression(left, right, Expression.LessThanOrEqual, UsesAll); } return Expression.LessThanOrEqual(left, right); } @@ -245,7 +248,7 @@ public override Expression GetExpression(Expression left, Expression right, T private class ContainsType : ComparisonOperator { - public ContainsType(bool caseInsensitive = false) : base("@=", 6, caseInsensitive) + public ContainsType(bool caseInsensitive = false, bool usesAll = false) : base("@=", 6, caseInsensitive, usesAll) { } @@ -254,7 +257,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return GetCollectionExpression(left, right, "Contains", false); + return GetCollectionExpression(left, right, "Contains", false, UsesAll); } if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) @@ -272,7 +275,7 @@ public override Expression GetExpression(Expression left, Expression right, T private class StartsWithType : ComparisonOperator { - public StartsWithType(bool caseInsensitive = false) : base("_=", 7, caseInsensitive) + public StartsWithType(bool caseInsensitive = false, bool usesAll = false) : base("_=", 7, caseInsensitive, usesAll) { } @@ -281,7 +284,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return GetCollectionExpression(left, right, "StartsWith", false); + return GetCollectionExpression(left, right, "StartsWith", false, UsesAll); } if (CaseInsensitive && left.Type == typeof(string) && right.Type == typeof(string)) @@ -299,7 +302,7 @@ public override Expression GetExpression(Expression left, Expression right, T private class EndsWithType : ComparisonOperator { - public EndsWithType(bool caseInsensitive = false) : base("_-=", 8, caseInsensitive) + public EndsWithType(bool caseInsensitive = false, bool usesAll = false) : base("_-=", 8, caseInsensitive, usesAll) { } @@ -308,7 +311,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return GetCollectionExpression(left, right, "EndsWith", false); + return GetCollectionExpression(left, right, "EndsWith", false, UsesAll); } if (CaseInsensitive) @@ -326,7 +329,7 @@ public override Expression GetExpression(Expression left, Expression right, T private class NotContainsType : ComparisonOperator { - public NotContainsType(bool caseInsensitive = false) : base("!@=", 9, caseInsensitive) + public NotContainsType(bool caseInsensitive = false, bool usesAll = false) : base("!@=", 9, caseInsensitive, usesAll) { } @@ -335,7 +338,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return GetCollectionExpression(left, right, "Contains", true); + return GetCollectionExpression(left, right, "Contains", true, UsesAll); } if(CaseInsensitive) @@ -354,7 +357,7 @@ public override Expression GetExpression(Expression left, Expression right, T private class NotStartsWithType : ComparisonOperator { - public NotStartsWithType(bool caseInsensitive = false) : base("!_=", 10, caseInsensitive) + public NotStartsWithType(bool caseInsensitive = false, bool usesAll = false) : base("!_=", 10, caseInsensitive, usesAll) { } @@ -363,7 +366,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return GetCollectionExpression(left, right, "StartsWith", true); + return GetCollectionExpression(left, right, "StartsWith", true, UsesAll); } if (CaseInsensitive) @@ -381,7 +384,7 @@ public override Expression GetExpression(Expression left, Expression right, T private class NotEndsWithType : ComparisonOperator { - public NotEndsWithType(bool caseInsensitive = false) : base("!_-=", 11, caseInsensitive) + public NotEndsWithType(bool caseInsensitive = false, bool usesAll = false) : base("!_-=", 11, caseInsensitive, usesAll) { } @@ -390,7 +393,7 @@ public override Expression GetExpression(Expression left, Expression right, T { if (left.Type.IsGenericType && left.Type.GetGenericTypeDefinition() == typeof(IEnumerable<>)) { - return GetCollectionExpression(left, right, "EndsWith", true); + return GetCollectionExpression(left, right, "EndsWith", true, UsesAll); } if (CaseInsensitive) @@ -408,7 +411,7 @@ public override Expression GetExpression(Expression left, Expression right, T private class InType : ComparisonOperator { - public InType(bool caseInsensitive = false) : base("^^", 12, caseInsensitive) + public InType(bool caseInsensitive = false, bool usesAll = false) : base("^^", 12, caseInsensitive, usesAll) { } @@ -455,7 +458,7 @@ public override Expression GetExpression(Expression left, Expression right, T private class SoundsLikeType : ComparisonOperator { - public SoundsLikeType(bool caseInsensitive = false) : base("~~", 13, caseInsensitive) + public SoundsLikeType(bool caseInsensitive = false, bool usesAll = false) : base("~~", 13, caseInsensitive, usesAll) { } @@ -483,7 +486,7 @@ public override Expression GetExpression(Expression left, Expression right, T private class DoesNotSoundLikeType : ComparisonOperator { - public DoesNotSoundLikeType(bool caseInsensitive = false) : base("!~", 14, caseInsensitive) + public DoesNotSoundLikeType(bool caseInsensitive = false, bool usesAll = false) : base("!~", 14, caseInsensitive, usesAll) { } @@ -589,7 +592,7 @@ internal static List GetAliasMatches(IQueryKitConfiguratio return matches; } - private Expression GetCollectionExpression(Expression left, Expression right, Func comparisonFunction) + private Expression GetCollectionExpression(Expression left, Expression right, Func comparisonFunction, bool usesAll) { var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "z"); Expression body; @@ -609,13 +612,13 @@ private Expression GetCollectionExpression(Expression left, Expression right, Fu var anyLambda = Expression.Lambda(body, xParameter); var anyMethod = typeof(Enumerable) .GetMethods() - .Single(m => m.Name == "Any" && m.GetParameters().Length == 2) + .Single(m => m.Name == (usesAll ? "All" : "Any") && m.GetParameters().Length == 2) .MakeGenericMethod(left.Type.GetGenericArguments()[0]); return Expression.Call(anyMethod, left, anyLambda); } - private Expression GetCollectionExpression(Expression left, Expression right, string methodName, bool negate) + private Expression GetCollectionExpression(Expression left, Expression right, string methodName, bool negate, bool usesAll) { var xParameter = Expression.Parameter(left.Type.GetGenericArguments()[0], "z"); Expression body; @@ -635,7 +638,7 @@ private Expression GetCollectionExpression(Expression left, Expression right, st var anyLambda = Expression.Lambda(body, xParameter); var anyMethod = typeof(Enumerable) .GetMethods() - .Single(m => m.Name == "Any" && m.GetParameters().Length == 2) + .Single(m => m.Name == (usesAll ? "All" : "Any") && m.GetParameters().Length == 2) .MakeGenericMethod(left.Type.GetGenericArguments()[0]); return negate From 6c485e197383bbb505adfa63924ae39943257acd Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sat, 22 Jul 2023 23:05:42 -0400 Subject: [PATCH 15/21] update: `all` operator prefix --- QueryKit.UnitTests/FilterParserTests.cs | 2 +- QueryKit/Operators/ComparisonOperator.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 6a854be..78993b3 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -713,7 +713,7 @@ public void collection_does_not_ends_with() [Fact] public void collection_equals_with_all() { - var input = """"Ingredients.Name #== "waffle" """"; + var input = """"Ingredients.Name %== "waffle" """"; var filterExpression = FilterParser.ParseFilter(input); filterExpression.ToString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).All(z => (z == "waffle"))""""); diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 668c510..fb068b3 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -117,7 +117,7 @@ public static ComparisonOperator GetByOperatorString(string op, bool caseInsensi } public const char CaseSensitiveAppendix = '*'; - public const char AllPrefix = '#'; + public const char AllPrefix = '%'; public abstract string Operator(); public bool CaseInsensitive { get; protected set; } public bool UsesAll { get; protected set; } From e8977b37750dfa9aa4d1ec18fdf3b83da59e6bd5 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 23 Jul 2023 15:20:36 -0400 Subject: [PATCH 16/21] feat: can support counts --- .../Tests/DatabaseFilteringTests.cs | 14 +- QueryKit.UnitTests/FilterParserTests.cs | 53 ++++- .../Configuration/QueryKitConfiguration.cs | 19 ++ QueryKit/Configuration/QueryKitSettings.cs | 6 + QueryKit/FilterParser.cs | 30 ++- QueryKit/Operators/ComparisonOperator.cs | 182 ++++++++++++++++++ 6 files changed, 284 insertions(+), 20 deletions(-) diff --git a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs index e406ac8..743e549 100644 --- a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs +++ b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs @@ -29,7 +29,7 @@ public async Task can_filter_by_string() await testingServiceScope.InsertAsync(fakePersonOne, fakePersonTwo); var input = $"""{nameof(TestingPerson.Title)} == "{fakePersonOne.Title}" """; - + // Act var queryablePeople = testingServiceScope.DbContext().People; var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input); @@ -62,8 +62,8 @@ public async Task can_filter_by_string_for_collection() var input = $"""Ingredients.Name == "{fakeIngredientOne.Name}" """; // Act - var queryablePeople = testingServiceScope.DbContext().Recipes; - var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input); + var queryableRecipes = testingServiceScope.DbContext().Recipes; + var appliedQueryable = queryableRecipes.ApplyQueryKitFilter(input); var recipes = await appliedQueryable.ToListAsync(); // Assert @@ -93,8 +93,8 @@ public async Task can_filter_by_string_for_collection_contains() var input = $"""Ingredients.Name @= "partial" """; // Act - var queryablePeople = testingServiceScope.DbContext().Recipes; - var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input); + var queryableRecipes = testingServiceScope.DbContext().Recipes; + var appliedQueryable = queryableRecipes.ApplyQueryKitFilter(input); var recipes = await appliedQueryable.ToListAsync(); // Assert @@ -124,8 +124,8 @@ public async Task can_filter_by_string_for_collection_does_not_contain() var input = $"""Ingredients.Name !@= "partial" """; // Act - var queryablePeople = testingServiceScope.DbContext().Recipes; - var appliedQueryable = queryablePeople.ApplyQueryKitFilter(input); + var queryableRecipes = testingServiceScope.DbContext().Recipes; + var appliedQueryable = queryableRecipes.ApplyQueryKitFilter(input); var recipes = await appliedQueryable.ToListAsync(); // Assert diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 78993b3..908a0a2 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -612,7 +612,16 @@ public void simple_child_collection_for_string_case_insensitive_not_equal() } [Fact] - public void simple_child_collection_for_string_greater_than() + public void simple_child_collection_for_int_equals() + { + var input = """Ingredients.MinimumQuality == 5"""; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Ingredients.Select(y => y.MinimumQuality).Any(z => (z == 5))""""); + } + + [Fact] + public void simple_child_collection_for_int_greater_than() { var input = """Ingredients.MinimumQuality > 5"""; var filterExpression = FilterParser.ParseFilter(input); @@ -621,7 +630,7 @@ public void simple_child_collection_for_string_greater_than() } [Fact] - public void simple_child_collection_for_string_less_than() + public void simple_child_collection_for_int_less_than() { var input = """Ingredients.MinimumQuality < 5"""; var filterExpression = FilterParser.ParseFilter(input); @@ -630,7 +639,7 @@ public void simple_child_collection_for_string_less_than() } [Fact] - public void simple_child_collection_for_string_greater_than_or_equal() + public void simple_child_collection_for_int_greater_than_or_equal() { var input = """Ingredients.MinimumQuality >= 5"""; var filterExpression = FilterParser.ParseFilter(input); @@ -639,7 +648,7 @@ public void simple_child_collection_for_string_greater_than_or_equal() } [Fact] - public void simple_child_collection_for_string_less_than_or_equal() + public void simple_child_collection_for_int_less_than_or_equal() { var input = """Ingredients.MinimumQuality <= 5"""; var filterExpression = FilterParser.ParseFilter(input); @@ -718,5 +727,41 @@ public void collection_equals_with_all() filterExpression.ToString().Should() .Be(""""x => x.Ingredients.Select(y => y.Name).All(z => (z == "waffle"))""""); } + + [Fact] + public void collection_has_operator_greater_than() + { + var input = """"Ingredients #> 0""""; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => (x.Ingredients.Count() > 0)""""); + } + + [Fact] + public void collection_has_operator_equal() + { + var input = """"Ingredients #== 0""""; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => (x.Ingredients.Count() == 0)""""); + } + + [Fact] + public void collection_has_operator_not_equal() + { + var input = """"Ingredients #!= 3""""; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => (x.Ingredients.Count() != 3)""""); + } + + [Fact] + public void collection_has_operator_greater_than_equal() + { + var input = """"Ingredients #>= 0""""; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => (x.Ingredients.Count() >= 0)""""); + } } \ No newline at end of file diff --git a/QueryKit/Configuration/QueryKitConfiguration.cs b/QueryKit/Configuration/QueryKitConfiguration.cs index ce096d5..951adc4 100644 --- a/QueryKit/Configuration/QueryKitConfiguration.cs +++ b/QueryKit/Configuration/QueryKitConfiguration.cs @@ -23,6 +23,12 @@ public interface IQueryKitConfiguration public string OrOperator { get; set; } public bool AllowUnknownProperties { get; set; } public Type? DbContextType { get; set; } + public string HasCountEqualToOperator { get; set; } + public string HasCountNotEqualToOperator { get; set; } + public string HasCountGreaterThanOperator { get; set; } + public string HasCountLessThanOperator { get; set; } + public string HasCountGreaterThanOrEqualOperator { get; set; } + public string HasCountLessThanOrEqualOperator { get; set; } } public class QueryKitConfiguration : IQueryKitConfiguration @@ -43,6 +49,12 @@ public class QueryKitConfiguration : IQueryKitConfiguration public string InOperator { get; set; } public string SoundsLikeOperator { get; set; } public string DoesNotSoundLikeOperator { get; set; } + public string HasCountEqualToOperator { get; set; } + public string HasCountNotEqualToOperator { get; set; } + public string HasCountGreaterThanOperator { get; set; } + public string HasCountLessThanOperator { get; set; } + public string HasCountGreaterThanOrEqualOperator { get; set; } + public string HasCountLessThanOrEqualOperator { get; set; } public string CaseInsensitiveAppendix { get; set; } public string AndOperator { get; set; } public string OrOperator { get; set; } @@ -75,5 +87,12 @@ public QueryKitConfiguration(Action configureSettings) OrOperator = settings.OrOperator; AllowUnknownProperties = settings.AllowUnknownProperties; DbContextType = settings.DbContextType; + + HasCountEqualToOperator = settings.HasCountEqualToOperator; + HasCountNotEqualToOperator = settings.HasCountNotEqualToOperator; + HasCountGreaterThanOperator = settings.HasCountGreaterThanOperator; + HasCountLessThanOperator = settings.HasCountLessThanOperator; + HasCountGreaterThanOrEqualOperator = settings.HasCountGreaterThanOrEqualOperator; + HasCountLessThanOrEqualOperator = settings.HasCountLessThanOrEqualOperator; } } \ No newline at end of file diff --git a/QueryKit/Configuration/QueryKitSettings.cs b/QueryKit/Configuration/QueryKitSettings.cs index 7f66190..ca2c0f4 100644 --- a/QueryKit/Configuration/QueryKitSettings.cs +++ b/QueryKit/Configuration/QueryKitSettings.cs @@ -21,6 +21,12 @@ public class QueryKitSettings public string InOperator { get; set; } = ComparisonOperator.InOperator().Operator(); public string SoundsLikeOperator { get; set; } = ComparisonOperator.SoundsLikeOperator().Operator(); public string DoesNotSoundLikeOperator { get; set; } = ComparisonOperator.DoesNotSoundLikeOperator().Operator(); + public string HasCountEqualToOperator { get; set; } = ComparisonOperator.HasCountEqualToOperator().Operator(); + public string HasCountNotEqualToOperator { get; set; } = ComparisonOperator.HasCountNotEqualToOperator().Operator(); + public string HasCountGreaterThanOperator { get; set; } = ComparisonOperator.HasCountGreaterThanOperator().Operator(); + public string HasCountLessThanOperator { get; set; } = ComparisonOperator.HasCountLessThanOperator().Operator(); + public string HasCountGreaterThanOrEqualOperator { get; set; } = ComparisonOperator.HasCountGreaterThanOrEqualOperator().Operator(); + public string HasCountLessThanOrEqualOperator { get; set; } = ComparisonOperator.HasCountLessThanOrEqualOperator().Operator(); public string AndOperator { get; set; } = LogicalOperator.AndOperator.Operator(); public string OrOperator { get; set; } = LogicalOperator.OrOperator.Operator(); public string CaseInsensitiveAppendix { get; set; } = ComparisonOperator.CaseSensitiveAppendix.ToString(); diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 88dda90..8c87bfe 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -1,6 +1,7 @@  namespace QueryKit; +using System.Collections; using System.Globalization; using System.Linq.Expressions; using System.Reflection; @@ -60,6 +61,12 @@ from rest in Parse.LetterOrDigit.XOr(Parse.Char('_')).Many() .Or(Parse.String(ComparisonOperator.InOperator().Operator()).Text()) .Or(Parse.String(ComparisonOperator.SoundsLikeOperator().Operator()).Text()) .Or(Parse.String(ComparisonOperator.DoesNotSoundLikeOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.HasCountEqualToOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.HasCountNotEqualToOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.HasCountGreaterThanOrEqualOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.HasCountLessThanOrEqualOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.HasCountGreaterThanOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.HasCountLessThanOperator().Operator()).Text()) .SelectMany(op => Parse.Char(ComparisonOperator.CaseSensitiveAppendix).Optional(), (op, caseInsensitive) => new { op, caseInsensitive, hasHash }) .Select(x => ComparisonOperator.GetByOperatorString(x.op, x.caseInsensitive.IsDefined, x.hasHash))); @@ -153,19 +160,24 @@ from closingBracket in Parse.Char(']') private static Expression CreateRightExpr(Expression leftExpr, string right) { var targetType = leftExpr.Type; - - if (IsEnumerable(targetType)) - { - targetType = targetType.GetGenericArguments()[0]; - return CreateRightExprFromType(targetType, right); - } - - return CreateRightExprFromType(leftExpr.Type, right); + return CreateRightExprFromType(targetType, right); } private static Expression CreateRightExprFromType(Type leftExprType, string right) { + var isEnumerable = IsEnumerable(leftExprType); var targetType = leftExprType; + if (isEnumerable) + { + if (int.TryParse(right, out var intVal)) + { + // supports collection count + return Expression.Constant(intVal, typeof(int)); + } + targetType = targetType.GetGenericArguments()[0]; + return CreateRightExprFromType(targetType, right); + } + var rawType = targetType; targetType = TransformTargetTypeIfNullable(targetType); @@ -176,7 +188,7 @@ private static Expression CreateRightExprFromType(Type leftExprType, string righ { return Expression.Constant(null, leftExprType); } - + if (right.StartsWith("[") && right.EndsWith("]")) { var values = right.Trim('[', ']').Split(',').Select(x => x.Trim()).ToList(); diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index fb068b3..f78845f 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -1,6 +1,8 @@ namespace QueryKit.Operators; +using System.Collections; using System.Linq.Expressions; +using System.Reflection; using Ardalis.SmartEnum; using Configuration; using Exceptions; @@ -22,6 +24,12 @@ public abstract class ComparisonOperator : SmartEnum public static ComparisonOperator CaseSensitiveInOperator = new InType(); public static ComparisonOperator CaseSensitiveSoundsLikeOperator = new SoundsLikeType(); public static ComparisonOperator CaseSensitiveDoesNotSoundLikeOperator = new DoesNotSoundLikeType(); + public static ComparisonOperator CaseSensitiveHasCountEqualToOperator = new HasCountEqualToType(); + public static ComparisonOperator CaseSensitiveHasCountNotEqualToOperator = new HasCountNotEqualToType(); + public static ComparisonOperator CaseSensitiveHasCountGreaterThanOperator = new HasCountGreaterThanType(); + public static ComparisonOperator CaseSensitiveHasCountLessThanOperator = new HasCountLessThanType(); + public static ComparisonOperator CaseSensitiveHasCountGreaterThanOrEqualOperator = new HasCountGreaterThanOrEqualType(); + public static ComparisonOperator CaseSensitiveHasCountLessThanOrEqualOperator = new HasCountLessThanOrEqualType(); public static ComparisonOperator EqualsOperator(bool caseInsensitive = false, bool usesAll = false) => new EqualsType(caseInsensitive); public static ComparisonOperator NotEqualsOperator(bool caseInsensitive = false, bool usesAll = false) => new NotEqualsType(caseInsensitive); @@ -38,6 +46,12 @@ public abstract class ComparisonOperator : SmartEnum public static ComparisonOperator InOperator(bool caseInsensitive = false, bool usesAll = false) => new InType(caseInsensitive); public static ComparisonOperator SoundsLikeOperator(bool caseInsensitive = false, bool usesAll = false) => new SoundsLikeType(caseInsensitive); public static ComparisonOperator DoesNotSoundLikeOperator(bool caseInsensitive = false, bool usesAll = false) => new DoesNotSoundLikeType(caseInsensitive); + public static ComparisonOperator HasCountEqualToOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountEqualToType(caseInsensitive); + public static ComparisonOperator HasCountNotEqualToOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountNotEqualToType(caseInsensitive); + public static ComparisonOperator HasCountGreaterThanOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountGreaterThanType(caseInsensitive); + public static ComparisonOperator HasCountLessThanOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountLessThanType(caseInsensitive); + public static ComparisonOperator HasCountGreaterThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountGreaterThanOrEqualType(caseInsensitive); + public static ComparisonOperator HasCountLessThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountLessThanOrEqualType(caseInsensitive); public static ComparisonOperator GetByOperatorString(string op, bool caseInsensitive = false, bool usesAll = false) @@ -110,6 +124,30 @@ public static ComparisonOperator GetByOperatorString(string op, bool caseInsensi { newOperator = new DoesNotSoundLikeType(caseInsensitive, usesAll); } + if (comparisonOperator is HasCountEqualToType) + { + newOperator = new HasCountEqualToType(caseInsensitive, usesAll); + } + if (comparisonOperator is HasCountNotEqualToType) + { + newOperator = new HasCountNotEqualToType(caseInsensitive, usesAll); + } + if (comparisonOperator is HasCountGreaterThanType) + { + newOperator = new HasCountGreaterThanType(caseInsensitive, usesAll); + } + if (comparisonOperator is HasCountLessThanType) + { + newOperator = new HasCountLessThanType(caseInsensitive, usesAll); + } + if (comparisonOperator is HasCountGreaterThanOrEqualType) + { + newOperator = new HasCountGreaterThanOrEqualType(caseInsensitive, usesAll); + } + if (comparisonOperator is HasCountLessThanOrEqualType) + { + newOperator = new HasCountLessThanOrEqualType(caseInsensitive, usesAll); + } return newOperator == null ? throw new Exception($"Operator {op} is not supported") @@ -512,6 +550,84 @@ public override Expression GetExpression(Expression left, Expression right, T } } + private class HasCountEqualToType : ComparisonOperator + { + public HasCountEqualToType(bool caseInsensitive = false, bool usesAll = false) : base("#==", 15, caseInsensitive, usesAll) + { + } + + public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + { + return GetCountExpression(left, right, nameof(Expression.Equal)); + } + } + + private class HasCountNotEqualToType : ComparisonOperator + { + public HasCountNotEqualToType(bool caseInsensitive = false, bool usesAll = false) : base("#!=", 16, caseInsensitive, usesAll) + { + } + + public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + { + return GetCountExpression(left, right, nameof(Expression.NotEqual)); + } + } + + private class HasCountGreaterThanType : ComparisonOperator + { + public HasCountGreaterThanType(bool caseInsensitive = false, bool usesAll = false) : base("#>", 17, caseInsensitive, usesAll) + { + } + + public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + { + return GetCountExpression(left, right, nameof(Expression.GreaterThan)); + } + } + + private class HasCountLessThanType : ComparisonOperator + { + public HasCountLessThanType(bool caseInsensitive = false, bool usesAll = false) : base("#<", 18, caseInsensitive, usesAll) + { + } + + public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + { + return GetCountExpression(left, right, nameof(Expression.LessThan)); + } + } + + private class HasCountGreaterThanOrEqualType : ComparisonOperator + { + public HasCountGreaterThanOrEqualType(bool caseInsensitive = false, bool usesAll = false) : base("#>=", 19, caseInsensitive, usesAll) + { + } + + public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + { + return GetCountExpression(left, right, nameof(Expression.GreaterThanOrEqual)); + } + } + + private class HasCountLessThanOrEqualType : ComparisonOperator + { + public HasCountLessThanOrEqualType(bool caseInsensitive = false, bool usesAll = false) : base("#<=", 20, caseInsensitive, usesAll) + { + } + + public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + { + return GetCountExpression(left, right, nameof(Expression.LessThanOrEqual)); + } + } + internal class ComparisonAliasMatch { @@ -588,6 +704,36 @@ internal static List GetAliasMatches(IQueryKitConfiguratio matches.Add(new ComparisonAliasMatch { Alias = aliases.InOperator, Operator = InOperator().Operator() }); matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.InOperator}{caseInsensitiveAppendix}", Operator = $"{InOperator(true).Operator()}" }); } + if(aliases.HasCountEqualToOperator != EqualsOperator().Operator()) + { + matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountEqualToOperator, Operator = EqualsOperator().Operator() }); + matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountEqualToOperator}{caseInsensitiveAppendix}", Operator = $"{EqualsOperator(true).Operator()}"}); + } + if(aliases.HasCountNotEqualToOperator != NotEqualsOperator().Operator()) + { + matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountNotEqualToOperator, Operator = NotEqualsOperator().Operator() }); + matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountNotEqualToOperator}{caseInsensitiveAppendix}", Operator = $"{NotEqualsOperator(true).Operator()}" }); + } + if(aliases.HasCountGreaterThanOperator != GreaterThanOperator().Operator()) + { + matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountGreaterThanOperator, Operator = GreaterThanOperator().Operator() }); + matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountGreaterThanOperator}{caseInsensitiveAppendix}", Operator = $"{GreaterThanOperator(true).Operator()}" }); + } + if(aliases.HasCountLessThanOperator != LessThanOperator().Operator()) + { + matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountLessThanOperator, Operator = LessThanOperator().Operator() }); + matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountLessThanOperator}{caseInsensitiveAppendix}", Operator = $"{LessThanOperator(true).Operator()}" }); + } + if(aliases.HasCountGreaterThanOrEqualOperator != GreaterThanOrEqualOperator().Operator()) + { + matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountGreaterThanOrEqualOperator, Operator = GreaterThanOrEqualOperator().Operator() }); + matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountGreaterThanOrEqualOperator}{caseInsensitiveAppendix}", Operator = $"{GreaterThanOrEqualOperator(true).Operator()}" }); + } + if(aliases.HasCountLessThanOrEqualOperator != LessThanOrEqualOperator().Operator()) + { + matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountLessThanOrEqualOperator, Operator = LessThanOrEqualOperator().Operator() }); + matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountLessThanOrEqualOperator}{caseInsensitiveAppendix}", Operator = $"{LessThanOrEqualOperator(true).Operator()}" }); + } return matches; } @@ -645,4 +791,40 @@ private Expression GetCollectionExpression(Expression left, Expression right, st ? Expression.Not(Expression.Call(anyMethod, left, anyLambda)) : Expression.Call(anyMethod, left, anyLambda); } + + public Expression GetCountExpression(Expression left, Expression right, string methodName) + { + var leftAsEnumerableType = left.Type.GetInterface(nameof(IEnumerable)); + if (leftAsEnumerableType == null) + { + throw new Exception("Left expression should be of type IEnumerable"); + } + + var leftGenericType = left.Type.GetGenericArguments()[0]; + var rightType = right.Type; + + if (rightType != typeof(int)) + { + throw new Exception("The right expression should be of type int"); + } + var countMethod = typeof(Enumerable).GetMethods(BindingFlags.Public | BindingFlags.Static) + .FirstOrDefault(m => m.Name == "Count" && m.GetParameters().Length == 1 + && m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IEnumerable<>)); + if (countMethod == null) + { + throw new Exception("Count method not found"); + } + + var specificCountMethod = countMethod.MakeGenericMethod(leftGenericType); + + var countExpression = Expression.Call(null, specificCountMethod, left); + var comparisonMethod = typeof(Expression).GetMethod(methodName, new[] { typeof(Expression), typeof(Expression) }); + if (comparisonMethod == null) + { + throw new Exception($"Comparison method '{methodName}' not found"); + } + + // Invoke the comparison method + return (Expression)comparisonMethod.Invoke(null, new object[] { countExpression, right }); + } } From 91e9f95542a619852fc1e729054dc8c73bc5816d Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 23 Jul 2023 19:38:44 -0400 Subject: [PATCH 17/21] update: count integration test --- .../Tests/DatabaseFilteringTests.cs | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs index 743e549..cdb3419 100644 --- a/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs +++ b/QueryKit.IntegrationTests/Tests/DatabaseFilteringTests.cs @@ -71,6 +71,37 @@ public async Task can_filter_by_string_for_collection() recipes[0].Id.Should().Be(fakeRecipeOne.Id); } + [Fact] + public async Task can_filter_by_string_for_collection_with_count() + { + // Arrange + var testingServiceScope = new TestingServiceScope(); + var faker = new Faker(); + var fakeIngredientOne = new FakeIngredientBuilder() + .WithName(faker.Lorem.Sentence()) + .Build(); + var fakeRecipeOne = new FakeRecipeBuilder().Build(); + fakeRecipeOne.AddIngredient(fakeIngredientOne); + + var fakeIngredientTwo = new FakeIngredientBuilder() + .WithName(faker.Lorem.Sentence()) + .Build(); + var fakeRecipeTwo = new FakeRecipeBuilder().Build(); + fakeRecipeTwo.AddIngredient(fakeIngredientTwo); + await testingServiceScope.InsertAsync(fakeRecipeOne, fakeRecipeTwo); + + var input = $"""Title == "{fakeRecipeOne.Title}" && Ingredients #>= 1"""; + + // Act + var queryableRecipes = testingServiceScope.DbContext().Recipes; + var appliedQueryable = queryableRecipes.ApplyQueryKitFilter(input); + 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_contains() { From 17dd1e58e1da26b7aacd836fbd9b2b2272f1194e Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 23 Jul 2023 20:07:01 -0400 Subject: [PATCH 18/21] docs: collections --- README.md | 55 +++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 39 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8256452..c8c93dd 100644 --- a/README.md +++ b/README.md @@ -97,22 +97,23 @@ var input = """(FirstName == "Jane" && Age < 10) || FirstName == "John" """; There's a wide variety of comparison operators that use the same base syntax as [Sieve](https://github.com/Biarity/Sieve)'s operators. To do a case-insensitive operation, just append a ` *` at the end of the operator. -| Name | Operator | Case Insensitive Operator | -| --------------------- | -------- | ------------------------- | -| Equals | == | ==* | -| Not Equals | != | !=* | -| Greater Than | > | N/A | -| Less Than | < | N/A | -| Greater Than Or Equal | >= | N/A | -| Less Than Or Equal | <= | N/A | -| Starts With | _= | _=* | -| Does Not Start With | !_= | !_=* | -| Ends With | _-= | _-=* | -| Does Not End With | !_-= | !_-=* | -| Contains | @= | @=* | -| Does Not Contain | !@= | !@=* | -| Sounds Like | ~~ | N/A | -| Does Not Sound Like | !~ | N/A | +| Name | Operator | Case Insensitive Operator | Count Operator | +| --------------------- | -------- | ------------------------- | -------------- | +| Equals | == | ==* | #== | +| Not Equals | != | !=* | #!= | +| Greater Than | > | N/A | #> | +| Less Than | < | N/A | #< | +| Greater Than Or Equal | >= | N/A | #>= | +| Less Than Or Equal | <= | N/A | #<= | +| Starts With | _= | _=* | N/A | +| Does Not Start With | !_= | !_=* | N/A | +| Ends With | _-= | _-=* | N/A | +| Does Not End With | !_-= | !_-=* | N/A | +| Contains | @= | @=* | N/A | +| Does Not Contain | !@= | !@=* | N/A | +| Sounds Like | ~~ | N/A | N/A | +| Does Not Sound Like | !~ | N/A | N/A | + > `Sounds Like` and `Does Not Sound Like` requires a soundex configuration on your DbContext. For more info see [the docs below](#soundex) @@ -154,6 +155,28 @@ There's a wide variety of comparison operators that use the same base syntax as var input = """(Title == "lamb" && ((Age >= 25 && Rating < 4.5) || (SpecificDate <= 2022-07-01T00:00:03Z && Time == 00:00:03)) && (Favorite == true || Email.Value _= "hello@gmail.com"))"""; ``` +#### Filtering Collections + +You can also filter into collections with QueryKit by using most of the normal operators. For example, if I wanted to filter for recipes that only have an ingredient named `salt`, I could do something like this: + +```csharp +var input = """"Ingredients.Name == "salt" """"; +``` + +By default, QueryKit will use `Any` under the hood when building this filter, but if you want to use `All`, you just need to prefix the operator with a `%`: + +```csharp +var input = """"Ingredients.Stock %>= 1""""; +``` + +> 🚧 At the moment, nested collections like `Ingredients.Suppliers.Rating > 4` is still under active development + +If you want to filter on the count of a collection, you can prefix some of the operators with a `#`. For example, if i wanted to get all recipes that have more than 0 ingredients: + +```csharp +var input = """"Ingredients #>= 0""""; +``` + ### Settings #### Property Settings From ed8a81d781bf7eb565ee16cd85d5519bfcc4a042 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 23 Jul 2023 22:27:09 -0400 Subject: [PATCH 19/21] fix: count aliasing --- QueryKit/Operators/ComparisonOperator.cs | 38 ++++++++++++------------ 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index f78845f..bc3a4c1 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -704,37 +704,37 @@ internal static List GetAliasMatches(IQueryKitConfiguratio matches.Add(new ComparisonAliasMatch { Alias = aliases.InOperator, Operator = InOperator().Operator() }); matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.InOperator}{caseInsensitiveAppendix}", Operator = $"{InOperator(true).Operator()}" }); } - if(aliases.HasCountEqualToOperator != EqualsOperator().Operator()) + if(aliases.HasCountEqualToOperator != HasCountEqualToOperator().Operator()) { - matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountEqualToOperator, Operator = EqualsOperator().Operator() }); - matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountEqualToOperator}{caseInsensitiveAppendix}", Operator = $"{EqualsOperator(true).Operator()}"}); + matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountEqualToOperator, Operator = HasCountEqualToOperator().Operator() }); + matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountEqualToOperator}{caseInsensitiveAppendix}", Operator = $"{HasCountEqualToOperator(true).Operator()}"}); } - if(aliases.HasCountNotEqualToOperator != NotEqualsOperator().Operator()) + if(aliases.HasCountNotEqualToOperator != HasCountNotEqualToOperator().Operator()) { - matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountNotEqualToOperator, Operator = NotEqualsOperator().Operator() }); - matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountNotEqualToOperator}{caseInsensitiveAppendix}", Operator = $"{NotEqualsOperator(true).Operator()}" }); + matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountNotEqualToOperator, Operator = HasCountNotEqualToOperator().Operator() }); + matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountNotEqualToOperator}{caseInsensitiveAppendix}", Operator = $"{HasCountNotEqualToOperator(true).Operator()}" }); } - if(aliases.HasCountGreaterThanOperator != GreaterThanOperator().Operator()) + if(aliases.HasCountGreaterThanOperator != HasCountGreaterThanOperator().Operator()) { - matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountGreaterThanOperator, Operator = GreaterThanOperator().Operator() }); - matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountGreaterThanOperator}{caseInsensitiveAppendix}", Operator = $"{GreaterThanOperator(true).Operator()}" }); + matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountGreaterThanOperator, Operator = HasCountGreaterThanOperator().Operator() }); + matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountGreaterThanOperator}{caseInsensitiveAppendix}", Operator = $"{HasCountGreaterThanOperator(true).Operator()}" }); } - if(aliases.HasCountLessThanOperator != LessThanOperator().Operator()) + if(aliases.HasCountLessThanOperator != HasCountLessThanOperator().Operator()) { - matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountLessThanOperator, Operator = LessThanOperator().Operator() }); - matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountLessThanOperator}{caseInsensitiveAppendix}", Operator = $"{LessThanOperator(true).Operator()}" }); + matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountLessThanOperator, Operator = HasCountLessThanOperator().Operator() }); + matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountLessThanOperator}{caseInsensitiveAppendix}", Operator = $"{HasCountLessThanOperator(true).Operator()}" }); } - if(aliases.HasCountGreaterThanOrEqualOperator != GreaterThanOrEqualOperator().Operator()) + if(aliases.HasCountGreaterThanOrEqualOperator != HasCountGreaterThanOrEqualOperator().Operator()) { - matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountGreaterThanOrEqualOperator, Operator = GreaterThanOrEqualOperator().Operator() }); - matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountGreaterThanOrEqualOperator}{caseInsensitiveAppendix}", Operator = $"{GreaterThanOrEqualOperator(true).Operator()}" }); + matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountGreaterThanOrEqualOperator, Operator = HasCountGreaterThanOrEqualOperator().Operator() }); + matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountGreaterThanOrEqualOperator}{caseInsensitiveAppendix}", Operator = $"{HasCountGreaterThanOrEqualOperator(true).Operator()}" }); } - if(aliases.HasCountLessThanOrEqualOperator != LessThanOrEqualOperator().Operator()) + if(aliases.HasCountLessThanOrEqualOperator != HasCountLessThanOrEqualOperator().Operator()) { - matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountLessThanOrEqualOperator, Operator = LessThanOrEqualOperator().Operator() }); - matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountLessThanOrEqualOperator}{caseInsensitiveAppendix}", Operator = $"{LessThanOrEqualOperator(true).Operator()}" }); + matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountLessThanOrEqualOperator, Operator = HasCountLessThanOrEqualOperator().Operator() }); + matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountLessThanOrEqualOperator}{caseInsensitiveAppendix}", Operator = $"{HasCountLessThanOrEqualOperator(true).Operator()}" }); } - + return matches; } From 828c9db387649c9f02da9885104ca8ef76b746d6 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 23 Jul 2023 22:33:11 -0400 Subject: [PATCH 20/21] feat: can support primitive lists --- QueryKit.UnitTests/FilterParserTests.cs | 36 ++++++++++ .../Database/RecipeConfiguration.cs | 4 +- .../Entities/Recipes/Recipe.cs | 8 +++ ...24020352_BaseTestingMigration.Designer.cs} | 8 ++- ...=> 20230724020352_BaseTestingMigration.cs} | 4 +- .../TestingDbContextModelSnapshot.cs | 6 ++ .../Configuration/QueryKitConfiguration.cs | 6 ++ QueryKit/Configuration/QueryKitSettings.cs | 2 + QueryKit/FilterParser.cs | 2 + QueryKit/Operators/ComparisonOperator.cs | 67 ++++++++++++++++++- README.md | 11 ++- 11 files changed, 149 insertions(+), 5 deletions(-) rename QueryKit.WebApiTestProject/Migrations/{20230722192729_BaseTestingMigration.Designer.cs => 20230724020352_BaseTestingMigration.Designer.cs} (97%) rename QueryKit.WebApiTestProject/Migrations/{20230722192729_BaseTestingMigration.cs => 20230724020352_BaseTestingMigration.cs} (97%) diff --git a/QueryKit.UnitTests/FilterParserTests.cs b/QueryKit.UnitTests/FilterParserTests.cs index 908a0a2..664964e 100644 --- a/QueryKit.UnitTests/FilterParserTests.cs +++ b/QueryKit.UnitTests/FilterParserTests.cs @@ -763,5 +763,41 @@ public void collection_has_operator_greater_than_equal() filterExpression.ToString().Should() .Be(""""x => (x.Ingredients.Count() >= 0)""""); } + + [Fact] + public void primitive_collection_has() + { + var input = """Tags ^$ "winner" """; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Tags.Any(z => (z == "winner"))""""); + } + + [Fact] + public void primitive_collection_does_not_have() + { + var input = """Tags !^$ "winner" """; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Tags.Any(z => (z != "winner"))""""); + } + + [Fact] + public void primitive_collection_has_case_insensitive() + { + var input = """Tags ^$* "winner" """; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Tags.Any(z => (z.ToLower() == "winner".ToLower()))""""); + } + + [Fact] + public void primitive_collection_does_not_have_case_insensitive() + { + var input = """Tags !^$* "winner" """; + var filterExpression = FilterParser.ParseFilter(input); + filterExpression.ToString().Should() + .Be(""""x => x.Tags.Any(z => (z.ToLower() != "winner".ToLower()))""""); + } } \ No newline at end of file diff --git a/QueryKit.WebApiTestProject/Database/RecipeConfiguration.cs b/QueryKit.WebApiTestProject/Database/RecipeConfiguration.cs index 4ba8eac..fdbb09e 100644 --- a/QueryKit.WebApiTestProject/Database/RecipeConfiguration.cs +++ b/QueryKit.WebApiTestProject/Database/RecipeConfiguration.cs @@ -11,11 +11,13 @@ public sealed class RecipeConfiguration : IEntityTypeConfiguration /// public void Configure(EntityTypeBuilder builder) { + builder.Property(x => x.Tags).HasColumnType("text[]"); + // example for a simple 1:1 value object // builder.Property(x => x.Percent) // .HasConversion(x => x.Value, x => new Percent(x)) // .HasColumnName("percent"); - + // example for a more complex value object // builder.OwnsOne(x => x.PhysicalAddress, opts => // { diff --git a/QueryKit.WebApiTestProject/Entities/Recipes/Recipe.cs b/QueryKit.WebApiTestProject/Entities/Recipes/Recipe.cs index 768b6f6..24b6cfc 100644 --- a/QueryKit.WebApiTestProject/Entities/Recipes/Recipe.cs +++ b/QueryKit.WebApiTestProject/Entities/Recipes/Recipe.cs @@ -30,6 +30,8 @@ private set public DateOnly? DateOfOrigin { get; private set; } public bool HaveMadeItMyself { get; private set; } + + public List Tags { get; set; } = new(); [JsonIgnore, IgnoreDataMember] public Author Author { get; private set; } @@ -78,6 +80,12 @@ public Recipe SetIngredients(List ingredients) _ingredients = ingredients; return this; } + + public Recipe SetTags(List tags) + { + Tags = tags; + return this; + } protected Recipe() { } // For EF + Mocking } \ No newline at end of file diff --git a/QueryKit.WebApiTestProject/Migrations/20230722192729_BaseTestingMigration.Designer.cs b/QueryKit.WebApiTestProject/Migrations/20230724020352_BaseTestingMigration.Designer.cs similarity index 97% rename from QueryKit.WebApiTestProject/Migrations/20230722192729_BaseTestingMigration.Designer.cs rename to QueryKit.WebApiTestProject/Migrations/20230724020352_BaseTestingMigration.Designer.cs index 74d0258..b8db79b 100644 --- a/QueryKit.WebApiTestProject/Migrations/20230722192729_BaseTestingMigration.Designer.cs +++ b/QueryKit.WebApiTestProject/Migrations/20230724020352_BaseTestingMigration.Designer.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Migrations; @@ -12,7 +13,7 @@ namespace QueryKit.WebApiTestProject.Migrations { [DbContext(typeof(TestingDbContext))] - [Migration("20230722192729_BaseTestingMigration")] + [Migration("20230724020352_BaseTestingMigration")] partial class BaseTestingMigration { /// @@ -144,6 +145,11 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("rating"); + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + b.Property("Title") .IsRequired() .HasColumnType("text") diff --git a/QueryKit.WebApiTestProject/Migrations/20230722192729_BaseTestingMigration.cs b/QueryKit.WebApiTestProject/Migrations/20230724020352_BaseTestingMigration.cs similarity index 97% rename from QueryKit.WebApiTestProject/Migrations/20230722192729_BaseTestingMigration.cs rename to QueryKit.WebApiTestProject/Migrations/20230724020352_BaseTestingMigration.cs index d85e270..cb3fcb1 100644 --- a/QueryKit.WebApiTestProject/Migrations/20230722192729_BaseTestingMigration.cs +++ b/QueryKit.WebApiTestProject/Migrations/20230724020352_BaseTestingMigration.cs @@ -1,4 +1,5 @@ using System; +using System.Collections.Generic; using Microsoft.EntityFrameworkCore.Migrations; #nullable disable @@ -51,7 +52,8 @@ protected override void Up(MigrationBuilder migrationBuilder) directions = table.Column(type: "text", nullable: false), rating = table.Column(type: "integer", nullable: true), date_of_origin = table.Column(type: "date", nullable: true), - have_made_it_myself = table.Column(type: "boolean", nullable: false) + have_made_it_myself = table.Column(type: "boolean", nullable: false), + tags = table.Column>(type: "text[]", nullable: false) }, constraints: table => { diff --git a/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs b/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs index 322f7b9..4057e0c 100644 --- a/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs +++ b/QueryKit.WebApiTestProject/Migrations/TestingDbContextModelSnapshot.cs @@ -1,5 +1,6 @@ // using System; +using System.Collections.Generic; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -141,6 +142,11 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("integer") .HasColumnName("rating"); + b.Property>("Tags") + .IsRequired() + .HasColumnType("text[]") + .HasColumnName("tags"); + b.Property("Title") .IsRequired() .HasColumnType("text") diff --git a/QueryKit/Configuration/QueryKitConfiguration.cs b/QueryKit/Configuration/QueryKitConfiguration.cs index 951adc4..38a63cf 100644 --- a/QueryKit/Configuration/QueryKitConfiguration.cs +++ b/QueryKit/Configuration/QueryKitConfiguration.cs @@ -29,6 +29,8 @@ public interface IQueryKitConfiguration public string HasCountLessThanOperator { get; set; } public string HasCountGreaterThanOrEqualOperator { get; set; } public string HasCountLessThanOrEqualOperator { get; set; } + public string HasOperator { get; set; } + public string DoesNotHaveOperator { get; set; } } public class QueryKitConfiguration : IQueryKitConfiguration @@ -55,6 +57,8 @@ public class QueryKitConfiguration : IQueryKitConfiguration public string HasCountLessThanOperator { get; set; } public string HasCountGreaterThanOrEqualOperator { get; set; } public string HasCountLessThanOrEqualOperator { get; set; } + public string HasOperator { get; set; } + public string DoesNotHaveOperator { get; set; } public string CaseInsensitiveAppendix { get; set; } public string AndOperator { get; set; } public string OrOperator { get; set; } @@ -94,5 +98,7 @@ public QueryKitConfiguration(Action configureSettings) HasCountLessThanOperator = settings.HasCountLessThanOperator; HasCountGreaterThanOrEqualOperator = settings.HasCountGreaterThanOrEqualOperator; HasCountLessThanOrEqualOperator = settings.HasCountLessThanOrEqualOperator; + HasOperator = settings.HasOperator; + DoesNotHaveOperator = settings.DoesNotHaveOperator; } } \ No newline at end of file diff --git a/QueryKit/Configuration/QueryKitSettings.cs b/QueryKit/Configuration/QueryKitSettings.cs index ca2c0f4..1226044 100644 --- a/QueryKit/Configuration/QueryKitSettings.cs +++ b/QueryKit/Configuration/QueryKitSettings.cs @@ -27,6 +27,8 @@ public class QueryKitSettings public string HasCountLessThanOperator { get; set; } = ComparisonOperator.HasCountLessThanOperator().Operator(); public string HasCountGreaterThanOrEqualOperator { get; set; } = ComparisonOperator.HasCountGreaterThanOrEqualOperator().Operator(); public string HasCountLessThanOrEqualOperator { get; set; } = ComparisonOperator.HasCountLessThanOrEqualOperator().Operator(); + public string HasOperator { get; set; } = ComparisonOperator.HasOperator().Operator(); + public string DoesNotHaveOperator { get; set; } = ComparisonOperator.DoesNotHaveOperator().Operator(); public string AndOperator { get; set; } = LogicalOperator.AndOperator.Operator(); public string OrOperator { get; set; } = LogicalOperator.OrOperator.Operator(); public string CaseInsensitiveAppendix { get; set; } = ComparisonOperator.CaseSensitiveAppendix.ToString(); diff --git a/QueryKit/FilterParser.cs b/QueryKit/FilterParser.cs index 8c87bfe..565c1b9 100644 --- a/QueryKit/FilterParser.cs +++ b/QueryKit/FilterParser.cs @@ -67,6 +67,8 @@ from rest in Parse.LetterOrDigit.XOr(Parse.Char('_')).Many() .Or(Parse.String(ComparisonOperator.HasCountLessThanOrEqualOperator().Operator()).Text()) .Or(Parse.String(ComparisonOperator.HasCountGreaterThanOperator().Operator()).Text()) .Or(Parse.String(ComparisonOperator.HasCountLessThanOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.HasOperator().Operator()).Text()) + .Or(Parse.String(ComparisonOperator.DoesNotHaveOperator().Operator()).Text()) .SelectMany(op => Parse.Char(ComparisonOperator.CaseSensitiveAppendix).Optional(), (op, caseInsensitive) => new { op, caseInsensitive, hasHash }) .Select(x => ComparisonOperator.GetByOperatorString(x.op, x.caseInsensitive.IsDefined, x.hasHash))); diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index bc3a4c1..59ca246 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -30,6 +30,8 @@ public abstract class ComparisonOperator : SmartEnum public static ComparisonOperator CaseSensitiveHasCountLessThanOperator = new HasCountLessThanType(); public static ComparisonOperator CaseSensitiveHasCountGreaterThanOrEqualOperator = new HasCountGreaterThanOrEqualType(); public static ComparisonOperator CaseSensitiveHasCountLessThanOrEqualOperator = new HasCountLessThanOrEqualType(); + public static ComparisonOperator CaseSensitiveHasOperator = new HasType(); + public static ComparisonOperator CaseSensitiveDoesNotHaveOperator = new DoesNotHaveType(); public static ComparisonOperator EqualsOperator(bool caseInsensitive = false, bool usesAll = false) => new EqualsType(caseInsensitive); public static ComparisonOperator NotEqualsOperator(bool caseInsensitive = false, bool usesAll = false) => new NotEqualsType(caseInsensitive); @@ -52,6 +54,8 @@ public abstract class ComparisonOperator : SmartEnum public static ComparisonOperator HasCountLessThanOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountLessThanType(caseInsensitive); public static ComparisonOperator HasCountGreaterThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountGreaterThanOrEqualType(caseInsensitive); public static ComparisonOperator HasCountLessThanOrEqualOperator(bool caseInsensitive = false, bool usesAll = false) => new HasCountLessThanOrEqualType(caseInsensitive); + public static ComparisonOperator HasOperator(bool caseInsensitive = false, bool usesAll = false) => new HasType(caseInsensitive); + public static ComparisonOperator DoesNotHaveOperator(bool caseInsensitive = false, bool usesAll = false) => new DoesNotHaveType(caseInsensitive); public static ComparisonOperator GetByOperatorString(string op, bool caseInsensitive = false, bool usesAll = false) @@ -148,6 +152,14 @@ public static ComparisonOperator GetByOperatorString(string op, bool caseInsensi { newOperator = new HasCountLessThanOrEqualType(caseInsensitive, usesAll); } + if (comparisonOperator is HasType) + { + newOperator = new HasType(caseInsensitive, usesAll); + } + if (comparisonOperator is DoesNotHaveType) + { + newOperator = new DoesNotHaveType(caseInsensitive, usesAll); + } return newOperator == null ? throw new Exception($"Operator {op} is not supported") @@ -628,6 +640,49 @@ public override Expression GetExpression(Expression left, Expression right, T } } + private class HasType : ComparisonOperator + { + public HasType(bool caseInsensitive = false, bool usesAll = false) : base("^$", 21, caseInsensitive, usesAll) + { + } + + public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + { + if (left.Type.IsGenericType && + (left.Type.GetGenericTypeDefinition() == typeof(List<>) || + left.Type.GetGenericTypeDefinition() == typeof(ICollection<>) || + left.Type.GetGenericTypeDefinition() == typeof(IList<>) || + typeof(IEnumerable<>).IsAssignableFrom(left.Type.GetGenericTypeDefinition()))) + { + return GetCollectionExpression(left, right, Expression.Equal, UsesAll); + } + + throw new Exception("DoesNotHaveType is only supported for collections"); + } + } + + private class DoesNotHaveType : ComparisonOperator + { + public DoesNotHaveType(bool caseInsensitive = false, bool usesAll = false) : base("!^$", 22, caseInsensitive, usesAll) + { + } + + public override string Operator() => CaseInsensitive ? $"{Name}{CaseSensitiveAppendix}" : Name; + public override Expression GetExpression(Expression left, Expression right, Type? dbContextType) + { + if (left.Type.IsGenericType && + (left.Type.GetGenericTypeDefinition() == typeof(List<>) || + left.Type.GetGenericTypeDefinition() == typeof(ICollection<>) || + left.Type.GetGenericTypeDefinition() == typeof(IList<>) || + typeof(IEnumerable<>).IsAssignableFrom(left.Type.GetGenericTypeDefinition()))) + { + return GetCollectionExpression(left, right, Expression.NotEqual, UsesAll); + } + + throw new Exception("DoesNotHaveType is only supported for collections"); + } + } internal class ComparisonAliasMatch { @@ -734,6 +789,16 @@ internal static List GetAliasMatches(IQueryKitConfiguratio matches.Add(new ComparisonAliasMatch { Alias = aliases.HasCountLessThanOrEqualOperator, Operator = HasCountLessThanOrEqualOperator().Operator() }); matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasCountLessThanOrEqualOperator}{caseInsensitiveAppendix}", Operator = $"{HasCountLessThanOrEqualOperator(true).Operator()}" }); } + if(aliases.HasOperator != HasOperator().Operator()) + { + matches.Add(new ComparisonAliasMatch { Alias = aliases.HasOperator, Operator = HasOperator().Operator() }); + matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.HasOperator}{caseInsensitiveAppendix}", Operator = $"{HasOperator(true).Operator()}" }); + } + if(aliases.DoesNotHaveOperator != DoesNotHaveOperator().Operator()) + { + matches.Add(new ComparisonAliasMatch { Alias = aliases.DoesNotHaveOperator, Operator = DoesNotHaveOperator().Operator() }); + matches.Add(new ComparisonAliasMatch { Alias = $"{aliases.DoesNotHaveOperator}{caseInsensitiveAppendix}", Operator = $"{DoesNotHaveOperator(true).Operator()}" }); + } return matches; } @@ -792,7 +857,7 @@ private Expression GetCollectionExpression(Expression left, Expression right, st : Expression.Call(anyMethod, left, anyLambda); } - public Expression GetCountExpression(Expression left, Expression right, string methodName) + private Expression GetCountExpression(Expression left, Expression right, string methodName) { var leftAsEnumerableType = left.Type.GetInterface(nameof(IEnumerable)); if (leftAsEnumerableType == null) diff --git a/README.md b/README.md index c8c93dd..cb9a973 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,8 @@ There's a wide variety of comparison operators that use the same base syntax as | Does Not Contain | !@= | !@=* | N/A | | Sounds Like | ~~ | N/A | N/A | | Does Not Sound Like | !~ | N/A | N/A | - +| Has | ^$ | ^$* | N/A | +| Does Not Have | !^$ | !^$* | N/A | > `Sounds Like` and `Does Not Sound Like` requires a soundex configuration on your DbContext. For more info see [the docs below](#soundex) @@ -171,6 +172,14 @@ var input = """"Ingredients.Stock %>= 1""""; > 🚧 At the moment, nested collections like `Ingredients.Suppliers.Rating > 4` is still under active development +If you want to filter a primitve collection like `List` you can use the `Has` or `DoesNotHave` operator (can be case insensitive with the appended `*`): + +```csharp +var input = """Tags ^$ "winner" """; +// or +var input = """Tags !^$ "winner" """; +``` + If you want to filter on the count of a collection, you can prefix some of the operators with a `#`. For example, if i wanted to get all recipes that have more than 0 ingredients: ```csharp From b42dcd85d26460523123428aac8300cf2f8cd3a6 Mon Sep 17 00:00:00 2001 From: Paul DeVito Date: Sun, 23 Jul 2023 22:39:17 -0400 Subject: [PATCH 21/21] feat: QueryKitParsingException exception support --- QueryKit/Exceptions/QueryKitParsingException.cs | 9 +++++++++ QueryKit/Operators/ComparisonOperator.cs | 17 ++++++++--------- README.md | 1 + 3 files changed, 18 insertions(+), 9 deletions(-) create mode 100644 QueryKit/Exceptions/QueryKitParsingException.cs diff --git a/QueryKit/Exceptions/QueryKitParsingException.cs b/QueryKit/Exceptions/QueryKitParsingException.cs new file mode 100644 index 0000000..4318ee1 --- /dev/null +++ b/QueryKit/Exceptions/QueryKitParsingException.cs @@ -0,0 +1,9 @@ +namespace QueryKit.Exceptions; + +public sealed class QueryKitParsingException : Exception +{ + public QueryKitParsingException(string message) + : base(message) + { + } +} diff --git a/QueryKit/Operators/ComparisonOperator.cs b/QueryKit/Operators/ComparisonOperator.cs index 59ca246..d1fcc1e 100644 --- a/QueryKit/Operators/ComparisonOperator.cs +++ b/QueryKit/Operators/ComparisonOperator.cs @@ -63,7 +63,7 @@ public static ComparisonOperator GetByOperatorString(string op, bool caseInsensi var comparisonOperator = List.FirstOrDefault(x => x.Operator() == op); if (comparisonOperator == null) { - throw new Exception($"Operator {op} is not supported"); + throw new QueryKitParsingException($"Operator {op} is not supported"); } ComparisonOperator? newOperator = null; @@ -162,7 +162,7 @@ public static ComparisonOperator GetByOperatorString(string op, bool caseInsensi } return newOperator == null - ? throw new Exception($"Operator {op} is not supported") + ? throw new QueryKitParsingException($"Operator {op} is not supported") : newOperator!; } @@ -658,7 +658,7 @@ public override Expression GetExpression(Expression left, Expression right, T return GetCollectionExpression(left, right, Expression.Equal, UsesAll); } - throw new Exception("DoesNotHaveType is only supported for collections"); + throw new QueryKitParsingException("DoesNotHaveType is only supported for collections"); } } @@ -680,7 +680,7 @@ public override Expression GetExpression(Expression left, Expression right, T return GetCollectionExpression(left, right, Expression.NotEqual, UsesAll); } - throw new Exception("DoesNotHaveType is only supported for collections"); + throw new QueryKitParsingException("DoesNotHaveType is only supported for collections"); } } @@ -862,7 +862,7 @@ private Expression GetCountExpression(Expression left, Expression right, string var leftAsEnumerableType = left.Type.GetInterface(nameof(IEnumerable)); if (leftAsEnumerableType == null) { - throw new Exception("Left expression should be of type IEnumerable"); + throw new QueryKitParsingException("Left expression should be of type IEnumerable"); } var leftGenericType = left.Type.GetGenericArguments()[0]; @@ -870,14 +870,14 @@ private Expression GetCountExpression(Expression left, Expression right, string if (rightType != typeof(int)) { - throw new Exception("The right expression should be of type int"); + throw new QueryKitParsingException("The right expression should be of type int"); } var countMethod = typeof(Enumerable).GetMethods(BindingFlags.Public | BindingFlags.Static) .FirstOrDefault(m => m.Name == "Count" && m.GetParameters().Length == 1 && m.GetParameters()[0].ParameterType.GetGenericTypeDefinition() == typeof(IEnumerable<>)); if (countMethod == null) { - throw new Exception("Count method not found"); + throw new QueryKitParsingException("Count method not found"); } var specificCountMethod = countMethod.MakeGenericMethod(leftGenericType); @@ -886,10 +886,9 @@ private Expression GetCountExpression(Expression left, Expression right, string var comparisonMethod = typeof(Expression).GetMethod(methodName, new[] { typeof(Expression), typeof(Expression) }); if (comparisonMethod == null) { - throw new Exception($"Comparison method '{methodName}' not found"); + throw new QueryKitParsingException($"Comparison method '{methodName}' not found"); } - // Invoke the comparison method return (Expression)comparisonMethod.Invoke(null, new object[] { countExpression, right }); } } diff --git a/README.md b/README.md index cb9a973..7f2b06b 100644 --- a/README.md +++ b/README.md @@ -402,6 +402,7 @@ If you want to capture errors to easily throw a `400`, you can add error handlin * A `SortParsingException` will be thrown if a property or operation is not recognized during sorting * A `QueryKitDbContextTypeException` will be thrown when trying to use a `DbContext` specific workflow without passing that context (e.g. SoundEx) * A `SoundsLikeNotImplementedException` will be thrown when trying to use `soundex` on a `DbContext` that doesn't have it implemented. +* A `QueryKitParsingException` is a more generic error that will include specific details on a more granular error in the parsing pipeline. ## SoundEx