From 4441addd77efb4c73d2ec73e18820bdb73bffd39 Mon Sep 17 00:00:00 2001 From: Dzmitry Koush Date: Fri, 13 Oct 2023 10:06:41 +0200 Subject: [PATCH 01/13] Extend Gridify to work with Elasticsearch. Add filtering and sorting functionality. Add tests for different types of data and operators with different combinations of complexity. --- .gitignore | 1 + gridify.sln | 30 ++ .../ExpressionExtensions.cs | 47 ++ .../ExpressionToQueryConvertor.cs | 306 ++++++++++++ .../Gridify.Elasticsearch.csproj | 34 ++ .../GridifyExtensions.cs | 111 +++++ src/Gridify/Gridify.csproj | 12 + src/Gridify/GridifyExtensions.cs | 4 +- .../Gridify.Elasticsearch.Tests.csproj | 26 + .../GridifyExtensionsTests.cs | 466 ++++++++++++++++++ test/Gridify.Elasticsearch.Tests/TestClass.cs | 26 + 11 files changed, 1061 insertions(+), 2 deletions(-) create mode 100644 src/Gridify.Elasticsearch/ExpressionExtensions.cs create mode 100644 src/Gridify.Elasticsearch/ExpressionToQueryConvertor.cs create mode 100644 src/Gridify.Elasticsearch/Gridify.Elasticsearch.csproj create mode 100644 src/Gridify.Elasticsearch/GridifyExtensions.cs create mode 100644 test/Gridify.Elasticsearch.Tests/Gridify.Elasticsearch.Tests.csproj create mode 100644 test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs create mode 100644 test/Gridify.Elasticsearch.Tests/TestClass.cs diff --git a/.gitignore b/.gitignore index a5ccd60b..f3f73104 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,4 @@ node_modules .temp .cache docs/.vuepress/dist +/*.user diff --git a/gridify.sln b/gridify.sln index d9b975f7..1bee61bb 100644 --- a/gridify.sln +++ b/gridify.sln @@ -25,6 +25,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkSqlProviderI EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "EntityFrameworkPostgreSqlIntegrationTests", "test\EntityFrameworkPostgreSqlIntegrationTests\EntityFrameworkPostgreSqlIntegrationTests.csproj", "{7C6699E7-7B6E-48D4-920F-6DD3568FBFD9}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gridify.Elasticsearch", "src\Gridify.Elasticsearch\Gridify.Elasticsearch.csproj", "{21A86C33-1E72-4771-9C40-73EF9A21AFD5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Gridify.Elasticsearch.Tests", "test\Gridify.Elasticsearch.Tests\Gridify.Elasticsearch.Tests.csproj", "{08E66DC6-A741-49D2-978B-E644EF3A5BA8}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -129,6 +133,30 @@ Global {7C6699E7-7B6E-48D4-920F-6DD3568FBFD9}.Release|x64.Build.0 = Release|Any CPU {7C6699E7-7B6E-48D4-920F-6DD3568FBFD9}.Release|x86.ActiveCfg = Release|Any CPU {7C6699E7-7B6E-48D4-920F-6DD3568FBFD9}.Release|x86.Build.0 = Release|Any CPU + {21A86C33-1E72-4771-9C40-73EF9A21AFD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {21A86C33-1E72-4771-9C40-73EF9A21AFD5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {21A86C33-1E72-4771-9C40-73EF9A21AFD5}.Debug|x64.ActiveCfg = Debug|Any CPU + {21A86C33-1E72-4771-9C40-73EF9A21AFD5}.Debug|x64.Build.0 = Debug|Any CPU + {21A86C33-1E72-4771-9C40-73EF9A21AFD5}.Debug|x86.ActiveCfg = Debug|Any CPU + {21A86C33-1E72-4771-9C40-73EF9A21AFD5}.Debug|x86.Build.0 = Debug|Any CPU + {21A86C33-1E72-4771-9C40-73EF9A21AFD5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {21A86C33-1E72-4771-9C40-73EF9A21AFD5}.Release|Any CPU.Build.0 = Release|Any CPU + {21A86C33-1E72-4771-9C40-73EF9A21AFD5}.Release|x64.ActiveCfg = Release|Any CPU + {21A86C33-1E72-4771-9C40-73EF9A21AFD5}.Release|x64.Build.0 = Release|Any CPU + {21A86C33-1E72-4771-9C40-73EF9A21AFD5}.Release|x86.ActiveCfg = Release|Any CPU + {21A86C33-1E72-4771-9C40-73EF9A21AFD5}.Release|x86.Build.0 = Release|Any CPU + {08E66DC6-A741-49D2-978B-E644EF3A5BA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {08E66DC6-A741-49D2-978B-E644EF3A5BA8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {08E66DC6-A741-49D2-978B-E644EF3A5BA8}.Debug|x64.ActiveCfg = Debug|Any CPU + {08E66DC6-A741-49D2-978B-E644EF3A5BA8}.Debug|x64.Build.0 = Debug|Any CPU + {08E66DC6-A741-49D2-978B-E644EF3A5BA8}.Debug|x86.ActiveCfg = Debug|Any CPU + {08E66DC6-A741-49D2-978B-E644EF3A5BA8}.Debug|x86.Build.0 = Debug|Any CPU + {08E66DC6-A741-49D2-978B-E644EF3A5BA8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {08E66DC6-A741-49D2-978B-E644EF3A5BA8}.Release|Any CPU.Build.0 = Release|Any CPU + {08E66DC6-A741-49D2-978B-E644EF3A5BA8}.Release|x64.ActiveCfg = Release|Any CPU + {08E66DC6-A741-49D2-978B-E644EF3A5BA8}.Release|x64.Build.0 = Release|Any CPU + {08E66DC6-A741-49D2-978B-E644EF3A5BA8}.Release|x86.ActiveCfg = Release|Any CPU + {08E66DC6-A741-49D2-978B-E644EF3A5BA8}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(NestedProjects) = preSolution {CDFDBB16-1D9F-40FD-B693-96D1D4FB79EE} = {1BBCBA37-25E5-4BFF-A8E8-7EE582E0317F} @@ -139,5 +167,7 @@ Global {9A54A635-2B5B-4EDC-98FE-9BF963903BD4} = {1BBCBA37-25E5-4BFF-A8E8-7EE582E0317F} {3B9A8E46-1D4D-40EE-89C0-C3C376D9320A} = {1BBCBA37-25E5-4BFF-A8E8-7EE582E0317F} {7C6699E7-7B6E-48D4-920F-6DD3568FBFD9} = {1BBCBA37-25E5-4BFF-A8E8-7EE582E0317F} + {21A86C33-1E72-4771-9C40-73EF9A21AFD5} = {0FCD2937-1953-465E-8608-42B8EB8757C7} + {08E66DC6-A741-49D2-978B-E644EF3A5BA8} = {1BBCBA37-25E5-4BFF-A8E8-7EE582E0317F} EndGlobalSection EndGlobal diff --git a/src/Gridify.Elasticsearch/ExpressionExtensions.cs b/src/Gridify.Elasticsearch/ExpressionExtensions.cs new file mode 100644 index 00000000..6451738a --- /dev/null +++ b/src/Gridify.Elasticsearch/ExpressionExtensions.cs @@ -0,0 +1,47 @@ +using System; +using System.Linq.Expressions; +using System.Text; + +namespace Gridify.Elasticsearch; + +internal static class ExpressionExtensions +{ + internal static string ToPropertyPath(this Expression expression) + { + var memberAccessList = new StringBuilder(); + VisitMemberAccessChain(expression, memberAccessList); + + return memberAccessList.ToString(); + } + + public static Type GetRealType(this Expression> expression) + { + if (expression.Body is MemberExpression memberExpression) + return memberExpression.Type; + + if (expression.Body is UnaryExpression { NodeType: ExpressionType.Convert } unaryExpression) + return unaryExpression.Operand.Type; + + throw new InvalidOperationException("Unsupported expression type."); + } + + private static void VisitMemberAccessChain(Expression expression, StringBuilder result) + { + if (expression is MemberExpression memberExpression) + { + if (result.Length > 0) + { + result.Insert(0, "."); + } + + result.Insert(0, memberExpression.Member.Name); + + VisitMemberAccessChain(memberExpression.Expression, result); + } + else if (expression is UnaryExpression { NodeType: ExpressionType.Convert } unaryExpression) + { + // Handle cases when TValue is object and implicit conversion exists + VisitMemberAccessChain(unaryExpression.Operand, result); + } + } +} diff --git a/src/Gridify.Elasticsearch/ExpressionToQueryConvertor.cs b/src/Gridify.Elasticsearch/ExpressionToQueryConvertor.cs new file mode 100644 index 00000000..ce80a1b0 --- /dev/null +++ b/src/Gridify.Elasticsearch/ExpressionToQueryConvertor.cs @@ -0,0 +1,306 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Linq.Expressions; +using Elastic.Clients.Elasticsearch; +using Elastic.Clients.Elasticsearch.QueryDsl; +using Gridify.Syntax; + +namespace Gridify.Elasticsearch; + +internal static class ExpressionToQueryConvertor +{ + internal static (Query Expression, bool IsNested) + GenerateQuery(ExpressionSyntax expression, IGridifyMapper mapper) + { + while (true) + switch (expression.Kind) + { + case SyntaxKind.BinaryExpression: + { + var bExp = expression as BinaryExpressionSyntax; + + if (bExp!.Left is FieldExpressionSyntax && bExp.Right is ValueExpressionSyntax) + { + try + { + return ConvertBinaryExpressionSyntaxToQuery(bExp, mapper) + ?? throw new GridifyFilteringException("Invalid expression"); + } + catch (GridifyMapperException) + { + if (mapper.Configuration.IgnoreNotMappedFields) + return (new BoolQuery(), false); + + throw; + } + } + + (Query exp, bool isNested) leftQuery; + (Query exp, bool isNested) rightQuery; + + if (bExp.Left is ParenthesizedExpressionSyntax lpExp) + leftQuery = GenerateQuery(lpExp.Expression, mapper); + else + leftQuery = GenerateQuery(bExp.Left, mapper); + + if (bExp.Right is ParenthesizedExpressionSyntax rpExp) + rightQuery = GenerateQuery(rpExp.Expression, mapper); + else + rightQuery = GenerateQuery(bExp.Right, mapper); + + var result = bExp.OperatorToken.Kind switch + { + SyntaxKind.And => leftQuery.exp & rightQuery.exp, + SyntaxKind.Or => leftQuery.exp | rightQuery.exp, + _ => throw new GridifyFilteringException($"Invalid expression Operator '{bExp.OperatorToken.Kind}'") + }; + return (result, false); + } + case SyntaxKind.ParenthesizedExpression: // first entrypoint only + { + var pExp = expression as ParenthesizedExpressionSyntax; + return GenerateQuery(pExp!.Expression, mapper); + } + default: + throw new GridifyFilteringException($"Invalid expression format '{expression.Kind}'."); + } + } + + private static (Query Query, bool IsNested)? ConvertBinaryExpressionSyntaxToQuery( + BinaryExpressionSyntax binarySyntax, IGridifyMapper mapper) + { + var fieldExpression = binarySyntax.Left as FieldExpressionSyntax; + + var left = fieldExpression?.FieldToken.Text.Trim(); + var right = binarySyntax.Right as ValueExpressionSyntax; + var op = binarySyntax.OperatorToken; + + if (left == null || right == null) return null; + + var gMap = mapper.GetGMap(left); + + if (gMap == null) throw new GridifyMapperException($"Mapping '{left}' not found"); + + if (fieldExpression!.IsCollection) + throw new NotSupportedException(); + + var isNested = ((GMap)gMap).IsNestedCollection(); + if (isNested) + { + throw new NotSupportedException(); + } + + var result = GenerateQuery( + gMap.To.Body, + right, + op, + mapper.Configuration.AllowNullSearch, + gMap.Convertor, + left, + right.ValueToken.Text); + + return (result, false); + } + + private static Query GenerateQuery( + Expression body, + ValueExpressionSyntax valueExpression, + SyntaxNode op, + bool allowNullSearch, + Func? convertor, + string left, + string right) + { + // Remove the boxing for value types + if (body.NodeType == ExpressionType.Convert) body = ((UnaryExpression)body).Operand; + + object? value = valueExpression.ValueToken.Text; + + // execute user custom Convertor + if (convertor != null) + value = convertor.Invoke(valueExpression.ValueToken.Text); + + // handle the `null` keyword in value + if (allowNullSearch && op.Kind is SyntaxKind.Equal or SyntaxKind.NotEqual && value.ToString() == "null") + value = null; + + // type fixer + if (value is not null && body.Type != value.GetType()) + { + try + { + // handle bool, github issue #71 + if (body.Type == typeof(bool) && value is "true" or "false" or "1" or "0") + value = (((string)value).ToLower() is "1" or "true"); + // handle broken guids, github issue #2 + else if (body.Type == typeof(Guid) && !Guid.TryParse(value.ToString(), out _)) value = Guid.NewGuid().ToString(); + + var converter = TypeDescriptor.GetConverter(body.Type); + var isConvertable = converter.CanConvertFrom(typeof(string)); + if (isConvertable) + value = converter.ConvertFromString(value.ToString()!); + } + catch (FormatException) + { + // this code should never run + // return no records in case of any exception in formatting + return new BoolQuery { MustNot = new List { new MatchAllQuery() } }; + } + } + + bool isStringValue = false, isNumberExceptDecimalValue = false; + if (IsString(value)) + { + isStringValue = true; + } + else if (IsNumberExceptDecimal(value)) + { + isNumberExceptDecimalValue = true; + } + + var propertyPath = body.ToPropertyPath(); + + var field = isStringValue + ? new Field($"{propertyPath}.keyword") + : new Field(propertyPath); + + Query query; + switch (op.Kind) + { + case SyntaxKind.Equal when value is null: + query = new BoolQuery { MustNot = new List { new ExistsQuery { Field = field } } }; + break; + case SyntaxKind.Equal when value is DateTime dateTime: + query = new TermQuery(field) { Value = ConvertToString(dateTime) }; + break; + case SyntaxKind.Equal when isNumberExceptDecimalValue: + query = new TermQuery(field) { Value = Convert.ToDouble(right) }; + break; + case SyntaxKind.Equal when value is decimal decimalValue: + query = new TermQuery(field) { Value = ConvertToString(decimalValue) }; + break; + case SyntaxKind.Equal: + query = new TermQuery(field) { Value = right }; + break; + case SyntaxKind.NotEqual when value is null: + query = new ExistsQuery { Field = field }; + break; + case SyntaxKind.NotEqual when value is DateTime dateTime: + query = new BoolQuery { MustNot = new List { new TermQuery(field) { Value = ConvertToString(dateTime) } } }; + break; + case SyntaxKind.NotEqual when isNumberExceptDecimalValue: + query = new BoolQuery { MustNot = new List { new TermQuery(field) { Value = Convert.ToDouble(right) } } }; + break; + case SyntaxKind.NotEqual when value is decimal decimalValue: + query = new BoolQuery { MustNot = new List { new TermQuery(field) { Value = ConvertToString(decimalValue) } } }; + break; + case SyntaxKind.NotEqual: + query = new BoolQuery { MustNot = new List { new TermQuery(field) { Value = right } } }; + break; + case SyntaxKind.GreaterThan when value is DateTime dateTime: + query = new DateRangeQuery(field) { Gt = dateTime }; + break; + case SyntaxKind.GreaterThan when isNumberExceptDecimalValue: + query = new NumberRangeQuery(field) { Gt = Convert.ToDouble(right) }; + break; + case SyntaxKind.GreaterThan when value is decimal decimalValue: + // NOTE: RangeQuery doesn't have a public constructor, so we use DateRangeQuery instead, that works correctly. + query = new DateRangeQuery(field) { Gt = ConvertToString(decimalValue) }; + break; + case SyntaxKind.GreaterThan: + query = new DateRangeQuery(field) { Gt = right }; + break; + case SyntaxKind.LessThan when value is DateTime dateTime: + query = new DateRangeQuery(field) { Lt = dateTime }; + break; + case SyntaxKind.LessThan when isNumberExceptDecimalValue: + query = new NumberRangeQuery(field) { Lt = Convert.ToDouble(right) }; + break; + case SyntaxKind.LessThan when value is decimal decimalValue: + query = new DateRangeQuery(field) { Lt = ConvertToString(decimalValue) }; + break; + case SyntaxKind.LessThan: + query = new DateRangeQuery(field) { Lt = right }; + break; + case SyntaxKind.GreaterOrEqualThan when value is DateTime dateTime: + query = new DateRangeQuery(field) { Gte = dateTime }; + break; + case SyntaxKind.GreaterOrEqualThan when isNumberExceptDecimalValue: + query = new NumberRangeQuery(field) { Gte = Convert.ToDouble(right) }; + break; + case SyntaxKind.GreaterOrEqualThan when value is decimal decimalValue: + query = new DateRangeQuery(field) { Gte = ConvertToString(decimalValue) }; + break; + case SyntaxKind.GreaterOrEqualThan: + query = new DateRangeQuery(field) { Gte = right }; + break; + case SyntaxKind.LessOrEqualThan when value is DateTime dateTime: + query = new DateRangeQuery(field) { Lte = dateTime }; + break; + case SyntaxKind.LessOrEqualThan when isNumberExceptDecimalValue: + query = new NumberRangeQuery(field) { Lte = Convert.ToDouble(right) }; + break; + case SyntaxKind.LessOrEqualThan when value is decimal decimalValue: + query = new DateRangeQuery(field) { Lte = ConvertToString(decimalValue) }; + break; + case SyntaxKind.LessOrEqualThan: + query = new DateRangeQuery(field) { Lte = right }; + break; + case SyntaxKind.Like: + query = new WildcardQuery(field) { Value = $"*{right}*" }; + break; + case SyntaxKind.NotLike: + query = new BoolQuery { MustNot = new List { new WildcardQuery(field) { Value = $"*{right}*" } } }; + break; + case SyntaxKind.StartsWith: + query = new WildcardQuery(field) { Value = $"{right}*" }; + break; + case SyntaxKind.EndsWith: + query = new WildcardQuery(field) { Value = $"*{right}" }; + break; + case SyntaxKind.NotStartsWith: + query = new BoolQuery { MustNot = new List { new WildcardQuery(field) { Value = $"{right}*" } } }; + break; + case SyntaxKind.NotEndsWith: + query = new BoolQuery { MustNot = new List { new WildcardQuery(field) { Value = $"*{right}" } } }; + break; + case SyntaxKind.CustomOperator: + throw new NotImplementedException(); + default: + throw new GridifyFilteringException("Invalid expression");; + } + + return query; + } + + private static bool IsString(object? value) + { + return value?.GetType() == typeof(string) || value?.GetType() == typeof(Guid); + } + + private static bool IsNumberExceptDecimal(object? value) + { + return value + is byte + or sbyte + or short + or ushort + or int + or uint + or long + or ulong + or double + or float; + } + + private static string ConvertToString(DateTime dateTime) + { + return dateTime.ToString("yyyy-MM-ddTHH:mm:ss"); + } + + private static string ConvertToString(decimal decimalValue) + { + return decimalValue.ToString("0.0#################"); + } +} diff --git a/src/Gridify.Elasticsearch/Gridify.Elasticsearch.csproj b/src/Gridify.Elasticsearch/Gridify.Elasticsearch.csproj new file mode 100644 index 00000000..c03f8256 --- /dev/null +++ b/src/Gridify.Elasticsearch/Gridify.Elasticsearch.csproj @@ -0,0 +1,34 @@ + + + Gridify.Elasticsearch + 0.0.1 + Alireza Sabouri; Dzmitry Koush + Gridify (Elasticsearch), Easy way to apply Filtering, Sorting, and Pagination using text-based data. + https://github.com/alirezanet/Gridify + MIT + true + true + true + true + true + snupkg + README.md + net5.0;net6.0;netstandard2.0;netstandard2.1;net7.0 + default + enable + + + + + + + + + + + + + + + + diff --git a/src/Gridify.Elasticsearch/GridifyExtensions.cs b/src/Gridify.Elasticsearch/GridifyExtensions.cs new file mode 100644 index 00000000..2960b200 --- /dev/null +++ b/src/Gridify.Elasticsearch/GridifyExtensions.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Elastic.Clients.Elasticsearch; +using Elastic.Clients.Elasticsearch.QueryDsl; +using Gridify.Syntax; + +namespace Gridify.Elasticsearch; + +public static class GridifyExtensions +{ + public static Query ToElasticsearchQuery(this string? filter, IGridifyMapper? mapper = null) + { + if (string.IsNullOrWhiteSpace(filter)) + return new MatchAllQuery(); + + var syntaxTree = SyntaxTree.Parse(filter, GridifyGlobalConfiguration.CustomOperators.Operators); + if (syntaxTree.Diagnostics.Any()) + throw new GridifyFilteringException(syntaxTree.Diagnostics.Last()); + + mapper ??= BuildMapperWithNestedProperties(syntaxTree); + + var (queryExpression, _) = ExpressionToQueryConvertor.GenerateQuery(syntaxTree.Root, mapper); + return queryExpression; + } + + public static ICollection ToElasticsearchSortOptions(this string? ordering, IGridifyMapper? mapper = null) + { + if (string.IsNullOrWhiteSpace(ordering)) + return new List(); + + var sortOptions = ProcessOrdering(ordering, mapper); + return sortOptions; + } + + private static GridifyMapper BuildMapperWithNestedProperties(SyntaxTree syntaxTree) + { + var mapper = new GridifyMapper(); + foreach (var field in syntaxTree.Root.Descendants() + .Where(q => q.Kind == SyntaxKind.FieldExpression) + .Cast()) + try + { + mapper.AddMap(field.FieldToken.Text); + } + catch (Exception) + { + if (!mapper.Configuration.IgnoreNotMappedFields) + throw new GridifyMapperException($"Property '{field.FieldToken.Text}' not found."); + } + + return mapper; + } + + private static IEnumerable Descendants(this SyntaxNode root) + { + var nodes = new Stack(new[] { root }); + while (nodes.Any()) + { + var node = nodes.Pop(); + yield return node; + foreach (var n in node.GetChildren()) nodes.Push(n); + } + } + + private static ICollection ProcessOrdering(string orderings, IGridifyMapper? mapper) + { + var orders = orderings.ParseOrderings().ToList(); + + if (mapper is null) + { + mapper = new GridifyMapper(); + foreach (var order in orders) + try + { + mapper.AddMap(order.MemberName); + } + catch (Exception) + { + if (!mapper.Configuration.IgnoreNotMappedFields) + throw new GridifyMapperException($"Mapping '{order.MemberName}' not found"); + } + } + + var sortOptions = new List(); + foreach (var order in orders) + { + if (!mapper.HasMap(order.MemberName)) + { + // skip if there is no mappings available + if (mapper.Configuration.IgnoreNotMappedFields) + continue; + + throw new GridifyMapperException($"Mapping '{order.MemberName}' not found"); + } + + var field = mapper.GetExpression(order.MemberName).Body.ToPropertyPath(); + field = mapper.GetExpression(order.MemberName).GetRealType() == typeof(string) + ? $"{field}.keyword" + : field; + + var sortOption = SortOptions.Field( + field, + new FieldSort { Order = order.IsAscending ? SortOrder.Asc : SortOrder.Desc }); + + sortOptions.Add(sortOption); + } + + return sortOptions; + } +} diff --git a/src/Gridify/Gridify.csproj b/src/Gridify/Gridify.csproj index d6c4b478..d6e47096 100644 --- a/src/Gridify/Gridify.csproj +++ b/src/Gridify/Gridify.csproj @@ -55,6 +55,18 @@ + + + <_Parameter1>$(AssemblyName).Tests + + + <_Parameter1>$(AssemblyName).EntityFramework + + + <_Parameter1>$(AssemblyName).Elasticsearch + + + diff --git a/src/Gridify/GridifyExtensions.cs b/src/Gridify/GridifyExtensions.cs index b8be65ff..a3cd6918 100644 --- a/src/Gridify/GridifyExtensions.cs +++ b/src/Gridify/GridifyExtensions.cs @@ -390,7 +390,7 @@ internal static IQueryable ProcessOrdering(IQueryable query, string ord return query; } - internal static Expression> GetOrderExpression(ParsedOrdering order, IGridifyMapper mapper) + internal static Expression> GetOrderExpression(this ParsedOrdering order, IGridifyMapper mapper) { var exp = mapper.GetExpression(order.MemberName); switch (order.OrderingType) @@ -438,7 +438,7 @@ internal static string ReplaceAll(this string seed, IEnumerable chars, cha return chars.Aggregate(seed, (str, cItem) => str.Replace(cItem, replacementCharacter)); } - private static IEnumerable ParseOrderings(string orderings) + internal static IEnumerable ParseOrderings(this string orderings) { var nullableChars = new[] { '?', '!' }; foreach (var field in orderings.Split(',')) diff --git a/test/Gridify.Elasticsearch.Tests/Gridify.Elasticsearch.Tests.csproj b/test/Gridify.Elasticsearch.Tests/Gridify.Elasticsearch.Tests.csproj new file mode 100644 index 00000000..7dac1c67 --- /dev/null +++ b/test/Gridify.Elasticsearch.Tests/Gridify.Elasticsearch.Tests.csproj @@ -0,0 +1,26 @@ + + + + net7.0 + false + latest + enable + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + diff --git a/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs b/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs new file mode 100644 index 00000000..2866837c --- /dev/null +++ b/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs @@ -0,0 +1,466 @@ +using Elastic.Clients.Elasticsearch; +using Elastic.Transport.Extensions; +using Xunit; + +namespace Gridify.Elasticsearch.Tests; + +public class GridifyExtensionsTests +{ + private readonly ElasticsearchClient _client = new(); + + [Theory] + // byte equals + [InlineData("MyByte=1", """{"term":{"MyByte":{"value":1}}}""")] + // byte does not equal + [InlineData("MyByte!=1", """{"bool":{"must_not":{"term":{"MyByte":{"value":1}}}}}""")] + // byte greater than + [InlineData("MyByte>1", """{"range":{"MyByte":{"gt":1}}}""")] + // byte greater than or equal + [InlineData("MyByte>=1", """{"range":{"MyByte":{"gte":1}}}""")] + // byte less than + [InlineData("MyByte<1", """{"range":{"MyByte":{"lt":1}}}""")] + // byte less than or equal + [InlineData("MyByte<=1", """{"range":{"MyByte":{"lte":1}}}""")] + // byte is null + [InlineData("MyByte=null", """{"bool":{"must_not":{"exists":{"field":"MyByte"}}}}""")] + // byte is not null + [InlineData("MyByte!=null", """{"exists":{"field":"MyByte"}}""")] + public void ToElasticsearchQuery_WhenCalledWithByteValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // sbyte equals + [InlineData("MySByte=1", """{"term":{"MySByte":{"value":1}}}""")] + // sbyte does not equal + [InlineData("MySByte!=1", """{"bool":{"must_not":{"term":{"MySByte":{"value":1}}}}}""")] + // sbyte greater than + [InlineData("MySByte>1", """{"range":{"MySByte":{"gt":1}}}""")] + // sbyte greater than or equal + [InlineData("MySByte>=1", """{"range":{"MySByte":{"gte":1}}}""")] + // sbyte less than + [InlineData("MySByte<1", """{"range":{"MySByte":{"lt":1}}}""")] + // sbyte less than or equal + [InlineData("MySByte<=1", """{"range":{"MySByte":{"lte":1}}}""")] + // sbyte is null + [InlineData("MySByte=null", """{"bool":{"must_not":{"exists":{"field":"MySByte"}}}}""")] + // sbyte is not null + [InlineData("MySByte!=null", """{"exists":{"field":"MySByte"}}""")] + public void ToElasticsearchQuery_WhenCalledWithSByteValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // short equals + [InlineData("MyShort=1", """{"term":{"MyShort":{"value":1}}}""")] + // short does not equal + [InlineData("MyShort!=1", """{"bool":{"must_not":{"term":{"MyShort":{"value":1}}}}}""")] + // short greater than + [InlineData("MyShort>1", """{"range":{"MyShort":{"gt":1}}}""")] + // short greater than or equal + [InlineData("MyShort>=1", """{"range":{"MyShort":{"gte":1}}}""")] + // short less than + [InlineData("MyShort<1", """{"range":{"MyShort":{"lt":1}}}""")] + // short less than or equal + [InlineData("MyShort<=1", """{"range":{"MyShort":{"lte":1}}}""")] + // short is null + [InlineData("MyShort=null", """{"bool":{"must_not":{"exists":{"field":"MyShort"}}}}""")] + // short is not null + [InlineData("MyShort!=null", """{"exists":{"field":"MyShort"}}""")] + public void ToElasticsearchQuery_WhenCalledWithShortValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // ushort equals + [InlineData("MyUShort=1", """{"term":{"MyUShort":{"value":1}}}""")] + // ushort does not equal + [InlineData("MyUShort!=1", """{"bool":{"must_not":{"term":{"MyUShort":{"value":1}}}}}""")] + // ushort greater than + [InlineData("MyUShort>1", """{"range":{"MyUShort":{"gt":1}}}""")] + // ushort greater than or equal + [InlineData("MyUShort>=1", """{"range":{"MyUShort":{"gte":1}}}""")] + // ushort less than + [InlineData("MyUShort<1", """{"range":{"MyUShort":{"lt":1}}}""")] + // ushort less than or equal + [InlineData("MyUShort<=1", """{"range":{"MyUShort":{"lte":1}}}""")] + // ushort is null + [InlineData("MyUShort=null", """{"bool":{"must_not":{"exists":{"field":"MyUShort"}}}}""")] + // ushort is not null + [InlineData("MyUShort!=null", """{"exists":{"field":"MyUShort"}}""")] + public void ToElasticsearchQuery_WhenCalledWithUShortValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // int equals + [InlineData("Id=1", """{"term":{"Id":{"value":1}}}""")] + // int does not equal + [InlineData("Id!=1", """{"bool":{"must_not":{"term":{"Id":{"value":1}}}}}""")] + // int greater than + [InlineData("Id>1", """{"range":{"Id":{"gt":1}}}""")] + // int greater than or equal + [InlineData("Id>=1", """{"range":{"Id":{"gte":1}}}""")] + // int less than + [InlineData("Id<1", """{"range":{"Id":{"lt":1}}}""")] + // int less than or equal + [InlineData("Id<=1", """{"range":{"Id":{"lte":1}}}""")] + // int is null + [InlineData("Id=null", """{"bool":{"must_not":{"exists":{"field":"Id"}}}}""")] + // int is not null + [InlineData("Id!=null", """{"exists":{"field":"Id"}}""")] + public void ToElasticsearchQuery_WhenCalledWithNumber_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // uint equals + [InlineData("MyUInt=1", """{"term":{"MyUInt":{"value":1}}}""")] + // uint does not equal + [InlineData("MyUInt!=1", """{"bool":{"must_not":{"term":{"MyUInt":{"value":1}}}}}""")] + // uint greater than + [InlineData("MyUInt>1", """{"range":{"MyUInt":{"gt":1}}}""")] + // uint greater than or equal + [InlineData("MyUInt>=1", """{"range":{"MyUInt":{"gte":1}}}""")] + // uint less than + [InlineData("MyUInt<1", """{"range":{"MyUInt":{"lt":1}}}""")] + // uint less than or equal + [InlineData("MyUInt<=1", """{"range":{"MyUInt":{"lte":1}}}""")] + // uint is null + [InlineData("MyUInt=null", """{"bool":{"must_not":{"exists":{"field":"MyUInt"}}}}""")] + // uint is not null + [InlineData("MyUInt!=null", """{"exists":{"field":"MyUInt"}}""")] + public void ToElasticsearchQuery_WhenCalledWithUIntValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // long equals + [InlineData("MyLong=1", """{"term":{"MyLong":{"value":1}}}""")] + // long does not equal + [InlineData("MyLong!=1", """{"bool":{"must_not":{"term":{"MyLong":{"value":1}}}}}""")] + // long greater than + [InlineData("MyLong>1", """{"range":{"MyLong":{"gt":1}}}""")] + // long greater than or equal + [InlineData("MyLong>=1", """{"range":{"MyLong":{"gte":1}}}""")] + // long less than + [InlineData("MyLong<1", """{"range":{"MyLong":{"lt":1}}}""")] + // long less than or equal + [InlineData("MyLong<=1", """{"range":{"MyLong":{"lte":1}}}""")] + // long is null + [InlineData("MyLong=null", """{"bool":{"must_not":{"exists":{"field":"MyLong"}}}}""")] + // long is not null + [InlineData("MyLong!=null", """{"exists":{"field":"MyLong"}}""")] + public void ToElasticsearchQuery_WhenCalledWithLongValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // ulong equals + [InlineData("MyULong=1", """{"term":{"MyULong":{"value":1}}}""")] + // ulong does not equal + [InlineData("MyULong!=1", """{"bool":{"must_not":{"term":{"MyULong":{"value":1}}}}}""")] + // ulong greater than + [InlineData("MyULong>1", """{"range":{"MyULong":{"gt":1}}}""")] + // ulong greater than or equal + [InlineData("MyULong>=1", """{"range":{"MyULong":{"gte":1}}}""")] + // ulong less than + [InlineData("MyULong<1", """{"range":{"MyULong":{"lt":1}}}""")] + // ulong less than or equal + [InlineData("MyULong<=1", """{"range":{"MyULong":{"lte":1}}}""")] + // ulong is null + [InlineData("MyULong=null", """{"bool":{"must_not":{"exists":{"field":"MyULong"}}}}""")] + // ulong is not null + [InlineData("MyULong!=null", """{"exists":{"field":"MyULong"}}""")] + public void ToElasticsearchQuery_WhenCalledWithULongValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // float equals + [InlineData("MyFloat=56.7", """{"term":{"MyFloat":{"value":56.7}}}""")] + // float does not equal + [InlineData("MyFloat!=56.7", """{"bool":{"must_not":{"term":{"MyFloat":{"value":56.7}}}}}""")] + // float greater than + [InlineData("MyFloat>56.7", """{"range":{"MyFloat":{"gt":56.7}}}""")] + // float greater than or equal + [InlineData("MyFloat>=56.7", """{"range":{"MyFloat":{"gte":56.7}}}""")] + // float less than + [InlineData("MyFloat<56.7", """{"range":{"MyFloat":{"lt":56.7}}}""")] + // float less than or equal + [InlineData("MyFloat<=56.7", """{"range":{"MyFloat":{"lte":56.7}}}""")] + // float is null + [InlineData("MyFloat=null", """{"bool":{"must_not":{"exists":{"field":"MyFloat"}}}}""")] + // float is not null + [InlineData("MyFloat!=null", """{"exists":{"field":"MyFloat"}}""")] + public void ToElasticsearchQuery_WhenCalledWithFloatValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // double equals + [InlineData("MyDouble=56.7", """{"term":{"MyDouble":{"value":56.7}}}""")] + // double does not equal + [InlineData("MyDouble!=56.7", """{"bool":{"must_not":{"term":{"MyDouble":{"value":56.7}}}}}""")] + // double greater than + [InlineData("MyDouble>56.7", """{"range":{"MyDouble":{"gt":56.7}}}""")] + // double greater than or equal + [InlineData("MyDouble>=56.7", """{"range":{"MyDouble":{"gte":56.7}}}""")] + // double less than + [InlineData("MyDouble<56.7", """{"range":{"MyDouble":{"lt":56.7}}}""")] + // double less than or equal + [InlineData("MyDouble<=56.7", """{"range":{"MyDouble":{"lte":56.7}}}""")] + // double is null + [InlineData("MyDouble=null", """{"bool":{"must_not":{"exists":{"field":"MyDouble"}}}}""")] + // double is not null + [InlineData("MyDouble!=null", """{"exists":{"field":"MyDouble"}}""")] + public void ToElasticsearchQuery_WhenCalledWithDoubleValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // decimal equals + [InlineData("MyDecimal=56.7", """{"term":{"MyDecimal":{"value":"56.7"}}}""")] + // decimal does not equal + [InlineData("MyDecimal!=56.7", """{"bool":{"must_not":{"term":{"MyDecimal":{"value":"56.7"}}}}}""")] + // decimal greater than + [InlineData("MyDecimal>56.7", """{"range":{"MyDecimal":{"gt":"56.7"}}}""")] + // decimal greater than or equal + [InlineData("MyDecimal>=56.7", """{"range":{"MyDecimal":{"gte":"56.7"}}}""")] + // decimal less than + [InlineData("MyDecimal<56.7", """{"range":{"MyDecimal":{"lt":"56.7"}}}""")] + // decimal less than or equal + [InlineData("MyDecimal<=56.7", """{"range":{"MyDecimal":{"lte":"56.7"}}}""")] + // decimal is null + [InlineData("MyDecimal=null", """{"bool":{"must_not":{"exists":{"field":"MyDecimal"}}}}""")] + // decimal is not null + [InlineData("MyDecimal!=null", """{"exists":{"field":"MyDecimal"}}""")] + public void ToElasticsearchQuery_WhenCalledWithDecimalValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // string equals + [InlineData("Name=Dzmitry", """{"term":{"Name.keyword":{"value":"Dzmitry"}}}""")] + // string does not equal + [InlineData("Name!=Dzmitry", """{"bool":{"must_not":{"term":{"Name.keyword":{"value":"Dzmitry"}}}}}""")] + // string contains + [InlineData("Name=*itr", """{"wildcard":{"Name.keyword":{"value":"*itr*"}}}""")] + // string does not contain + [InlineData("Name!*itr", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"*itr*"}}}}}""")] + // string starts with + [InlineData("Name^Dzm", """{"wildcard":{"Name.keyword":{"value":"Dzm*"}}}""")] + // string does not start with + [InlineData("Name!^Dzm", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"Dzm*"}}}}}""")] + // string ends with + [InlineData("Name$try", """{"wildcard":{"Name.keyword":{"value":"*try"}}}""")] + // string does not end with + [InlineData("Name!$try", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"*try"}}}}}""")] + // string is null + [InlineData("Name=null", """{"bool":{"must_not":{"exists":{"field":"Name"}}}}""")] + // string is not null + [InlineData("Name!=null", """{"exists":{"field":"Name"}}""")] + // string is empty + [InlineData("Name=", """{"term":{"Name.keyword":{"value":""}}}""")] + // string is not empty + [InlineData("Name!=", """{"bool":{"must_not":{"term":{"Name.keyword":{"value":""}}}}}""")] + // string is empty or null + [InlineData("Name=null|Name=", """{"bool":{"should":[{"bool":{"must_not":{"exists":{"field":"Name"}}}},{"term":{"Name.keyword":{"value":""}}}]}}""")] + // string is not empty and not null + [InlineData("Name!=null,Name!=", """{"bool":{"must":{"exists":{"field":"Name"}},"must_not":{"term":{"Name.keyword":{"value":""}}}}}""")] + public void ToElasticsearchQuery_WhenCalledWithStringValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // date equals + [InlineData("MyDateTime=2021-09-01", """{"term":{"MyDateTime":{"value":"2021-09-01T00:00:00"}}}""")] + // date and time equals + [InlineData("MyDateTime=2021-09-01T00:00:00", """{"term":{"MyDateTime":{"value":"2021-09-01T00:00:00"}}}""")] + [InlineData("MyDateTime=2021-09-01 00:00:00", """{"term":{"MyDateTime":{"value":"2021-09-01T00:00:00"}}}""")] + [InlineData("MyDateTime=2021-09-01 23:15:11", """{"term":{"MyDateTime":{"value":"2021-09-01T23:15:11"}}}""")] + // date is null + [InlineData("MyDateTime=null", """{"bool":{"must_not":{"exists":{"field":"MyDateTime"}}}}""")] + // date is not null + [InlineData("MyDateTime!=null", """{"exists":{"field":"MyDateTime"}}""")] + // date greater than + [InlineData("MyDateTime>2021-09-01", """{"range":{"MyDateTime":{"gt":"2021-09-01T00:00:00"}}}""")] + // date greater than or equal + [InlineData("MyDateTime>=2021-09-01", """{"range":{"MyDateTime":{"gte":"2021-09-01T00:00:00"}}}""")] + // date less than + [InlineData("MyDateTime<2021-09-01", """{"range":{"MyDateTime":{"lt":"2021-09-01T00:00:00"}}}""")] + // date less than or equal + [InlineData("MyDateTime<=2021-09-01", """{"range":{"MyDateTime":{"lte":"2021-09-01T00:00:00"}}}""")] + public void ToElasticsearchQuery_WhenCalledWithDateTimeValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // date only equals + [InlineData("MyDateOnly=2021-09-01", """{"term":{"MyDateOnly":{"value":"2021-09-01"}}}""")] + // date only is null + [InlineData("MyDateOnly=null", """{"bool":{"must_not":{"exists":{"field":"MyDateOnly"}}}}""")] + // date only is not null + [InlineData("MyDateOnly!=null", """{"exists":{"field":"MyDateOnly"}}""")] + // date only greater than + [InlineData("MyDateOnly>2021-09-01", """{"range":{"MyDateOnly":{"gt":"2021-09-01"}}}""")] + // date only greater than or equal + [InlineData("MyDateOnly>=2021-09-01", """{"range":{"MyDateOnly":{"gte":"2021-09-01"}}}""")] + // date only less than + [InlineData("MyDateOnly<2021-09-01", """{"range":{"MyDateOnly":{"lt":"2021-09-01"}}}""")] + // date only less than or equal + [InlineData("MyDateOnly<=2021-09-01", """{"range":{"MyDateOnly":{"lte":"2021-09-01"}}}""")] + public void ToElasticsearchQuery_WhenCalledWithDateOnlyValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // bool equals + [InlineData("IsActive=true", """{"term":{"IsActive":{"value":"true"}}}""")] + // bool does not equal + [InlineData("IsActive!=true", """{"bool":{"must_not":{"term":{"IsActive":{"value":"true"}}}}}""")] + // bool is null + [InlineData("IsActive=null", """{"bool":{"must_not":{"exists":{"field":"IsActive"}}}}""")] + // bool is not null + [InlineData("IsActive!=null", """{"exists":{"field":"IsActive"}}""")] + public void ToElasticsearchQuery_WhenCalledWithBoolValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // guid equals + [InlineData("MyGuid=69C3BB3A-3A85-4750-BA03-1F916FA5C0B1", """{"term":{"MyGuid.keyword":{"value":"69C3BB3A-3A85-4750-BA03-1F916FA5C0B1"}}}""")] + // guid is null + [InlineData("MyGuid=null", """{"bool":{"must_not":{"exists":{"field":"MyGuid"}}}}""")] + // guid is not null + [InlineData("MyGuid!=null", """{"exists":{"field":"MyGuid"}}""")] + public void ToElasticsearchQuery_WhenCalledWithGuidValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // , operator + [InlineData("Id=1,Name=Dzmitry", """{"bool":{"must":[{"term":{"Id":{"value":1}}},{"term":{"Name.keyword":{"value":"Dzmitry"}}}]}}""")] + // | operator + [InlineData("Id=1|Id=2", """{"bool":{"should":[{"term":{"Id":{"value":1}}},{"term":{"Id":{"value":2}}}]}}""")] + // | , operators + [InlineData("Id=1|Id=2,Name=Dzmitry", """{"bool":{"must":[{"bool":{"should":[{"term":{"Id":{"value":1}}},{"term":{"Id":{"value":2}}}]}},{"term":{"Name.keyword":{"value":"Dzmitry"}}}]}}""")] + // , | operators + [InlineData("Id=1,Name=Dzmitry|Name=John", """{"bool":{"should":[{"bool":{"must":[{"term":{"Id":{"value":1}}},{"term":{"Name.keyword":{"value":"Dzmitry"}}}]}},{"term":{"Name.keyword":{"value":"John"}}}]}}""")] + // , , , operators + [InlineData("Id=1,Name=Dzmitry,MyDateTime=2021-09-01", """{"bool":{"must":[{"term":{"Id":{"value":1}}},{"term":{"Name.keyword":{"value":"Dzmitry"}}},{"term":{"MyDateTime":{"value":"2021-09-01T00:00:00"}}}]}}""")] + // | | | operators + [InlineData("Id=1|Id=2|Id=3", """{"bool":{"should":[{"term":{"Id":{"value":1}}},{"term":{"Id":{"value":2}}},{"term":{"Id":{"value":3}}}]}}""")] + // ( | ) operators + [InlineData("(Id=1|Id=2)", """{"bool":{"should":[{"term":{"Id":{"value":1}}},{"term":{"Id":{"value":2}}}]}}""")] + // ( , ) operators + [InlineData("(Id=1,Id=2)", """{"bool":{"must":[{"term":{"Id":{"value":1}}},{"term":{"Id":{"value":2}}}]}}""")] + // ( | ) , operators + [InlineData("(Id=1|Id=2),Name=Dzmitry", """{"bool":{"must":[{"bool":{"should":[{"term":{"Id":{"value":1}}},{"term":{"Id":{"value":2}}}]}},{"term":{"Name.keyword":{"value":"Dzmitry"}}}]}}""")] + // ( | | ) , operator + [InlineData("(Id=1|Id=2|Id=3),Name=Dzmitry", """{"bool":{"must":[{"bool":{"should":[{"term":{"Id":{"value":1}}},{"term":{"Id":{"value":2}}},{"term":{"Id":{"value":3}}}]}},{"term":{"Name.keyword":{"value":"Dzmitry"}}}]}}""")] + // , ( | ) operators + [InlineData("Id=1,(Name=Dzmitry|Name=John)", """{"bool":{"must":[{"term":{"Id":{"value":1}}},{"bool":{"should":[{"term":{"Name.keyword":{"value":"Dzmitry"}}},{"term":{"Name.keyword":{"value":"John"}}}]}}]}}""")] + // ( | ) , ( | ) operators + [InlineData("(Name=Dzmitry|Name=John),(Id=1|Id=2)", """{"bool":{"must":[{"bool":{"should":[{"term":{"Name.keyword":{"value":"Dzmitry"}}},{"term":{"Name.keyword":{"value":"John"}}}]}},{"bool":{"should":[{"term":{"Id":{"value":1}}},{"term":{"Id":{"value":2}}}]}}]}}""")] + // , ( , | ) | ) operators + [InlineData("Id=1,(Name=Dzmitry,(Id=1|Id=2)|Id=3)", """{"bool":{"must":[{"term":{"Id":{"value":1}}},{"bool":{"should":[{"bool":{"must":[{"term":{"Name.keyword":{"value":"Dzmitry"}}},{"bool":{"should":[{"term":{"Id":{"value":1}}},{"term":{"Id":{"value":2}}}]}}]}},{"term":{"Id":{"value":3}}}]}}]}}""")] + public void ToElasticsearchQuery_WhenCalledWithDifferentOperators_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // Nested class + [InlineData("Name=Dzmitry,ChildClass.Name=Kiryl", """{"bool":{"must":[{"term":{"Name.keyword":{"value":"Dzmitry"}}},{"term":{"ChildClass.Name.keyword":{"value":"Kiryl"}}}]}}""")] + // Double nested class + [InlineData("Name=Sergey,ChildClass.Name=Dzmitry,ChildClass.ChildClass.Name=Kiryl", """{"bool":{"must":[{"term":{"Name.keyword":{"value":"Sergey"}}},{"term":{"ChildClass.Name.keyword":{"value":"Dzmitry"}}},{"term":{"ChildClass.ChildClass.Name.keyword":{"value":"Kiryl"}}}]}}""")] + public void ToElasticsearchQuery_WhenCalledWithNestedClass_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + // empty query + [InlineData("", """{"match_all":{}}""")] + // string starts with empty + [InlineData("Name^", """{"wildcard":{"Name.keyword":{"value":"*"}}}""")] + // string ends with empty + [InlineData("Name$", """{"wildcard":{"Name.keyword":{"value":"*"}}}""")] + // string does not start with empty + [InlineData("Name!^", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"*"}}}}}""")] + // string does not end with empty + [InlineData("Name!$", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"*"}}}}}""")] + public void ToElasticsearchQuery_WhenCalledWithEmptyValue_ReturnsElasticsearchQuery(string query, string expected) + { + AssertQuery(query, expected); + } + + [Theory] + [InlineData("name=Dzmitry,childname=Kiryl", """{"bool":{"must":[{"term":{"Name.keyword":{"value":"Dzmitry"}}},{"term":{"ChildClass.Name.keyword":{"value":"Kiryl"}}}]}}""")] + public void ToElasticsearchQuery_WhenCalledWithCustomMapper_ShouldUseCorrectFieldNames(string query, string expected) + { + var mapper = new GridifyMapper() + .GenerateMappings() + .AddMap("name", x => x.Name) + .AddMap("childname", x => x.ChildClass.Name); + + AssertQuery(query, expected, mapper); + } + + [Theory] + [InlineData("Id asc", """[{"Id":{"order":"asc"}}]""")] + [InlineData("Id desc", """[{"Id":{"order":"desc"}}]""")] + [InlineData("Id asc, Name desc", """[{"Id":{"order":"asc"}},{"Name.keyword":{"order":"desc"}}]""")] + [InlineData("Id asc, Name desc, MyDateTime", """[{"Id":{"order":"asc"}},{"Name.keyword":{"order":"desc"}},{"MyDateTime":{"order":"asc"}}]""")] + [InlineData("ChildClass.Name desc", """[{"ChildClass.Name.keyword":{"order":"desc"}}]""")] + [InlineData("", "[]")] + public void ToSortOptions_WhenCalledWithOrdering_ReturnsElasticsearchSortOptions(string ordering, string expected) + { + AssertOrdering(ordering, expected); + } + + [Theory] + [InlineData("name asc,childname desc", """[{"Name.keyword":{"order":"asc"}},{"ChildClass.Name.keyword":{"order":"desc"}}]""")] + public void ToSortOptions_WhenCalledWithCustomMapper_ShouldUseCorrectFieldNames(string ordering, string expected) + { + var mapper = new GridifyMapper() + .GenerateMappings() + .AddMap("name", x => x.Name) + .AddMap("childname", x => x.ChildClass.Name); + + AssertOrdering(ordering, expected, mapper); + } + + private void AssertQuery(string query, string expected, IGridifyMapper? mapper = null) + { + var result = query.ToElasticsearchQuery(mapper); + + var jsonQuery = _client.RequestResponseSerializer.SerializeToString(result); + Assert.Equal(expected, jsonQuery); + } + + private void AssertOrdering(string ordering, string expected, IGridifyMapper? mapper = null) + { + var result = ordering.ToElasticsearchSortOptions(mapper); + + var jsonQuery = _client.RequestResponseSerializer.SerializeToString(result); + Assert.Equal(expected, jsonQuery); + } +} diff --git a/test/Gridify.Elasticsearch.Tests/TestClass.cs b/test/Gridify.Elasticsearch.Tests/TestClass.cs new file mode 100644 index 00000000..96170a13 --- /dev/null +++ b/test/Gridify.Elasticsearch.Tests/TestClass.cs @@ -0,0 +1,26 @@ +using System; + +namespace Gridify.Elasticsearch.Tests; + +public class TestClass +{ + public int Id { get; set; } + public string? Name { get; set; } + public TestClass? ChildClass { get; set; } + public DateTime? MyDateTime { get; set; } + public DateOnly MyDateOnly { get; set; } + public Guid MyGuid { get; set; } + public string? Tag { get; set; } + public bool IsActive { get; set; } + public byte MyByte { get; set; } + public sbyte MySByte { get; set; } + public short MyShort { get; set; } + public ushort MyUShort { get; set; } + public int MyInt { get; set; } + public uint MyUInt { get; set; } + public long MyLong { get; set; } + public ulong MyULong { get; set; } + public float MyFloat { get; set; } + public double MyDouble { get; set; } + public decimal MyDecimal { get; set; } +} From 8542f63b8a5ae391009d076d1228e00a61d5165e Mon Sep 17 00:00:00 2001 From: Dzmitry Koush Date: Fri, 13 Oct 2023 10:11:06 +0200 Subject: [PATCH 02/13] Update README.md with info about Elasticsearch compatibility --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a52032d1..1af9b0c3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Gridify (A Modern Dynamic LINQ library) -![GitHub](https://img.shields.io/github/license/alirezanet/gridify) -![Nuget](https://img.shields.io/nuget/dt/gridify?color=%239100ff) +![GitHub](https://img.shields.io/github/license/alirezanet/gridify) +![Nuget](https://img.shields.io/nuget/dt/gridify?color=%239100ff) ![Nuget](https://img.shields.io/nuget/v/gridify?label=stable) ![Nuget (with prereleases)](https://img.shields.io/nuget/vpre/gridify?label=latest) [![NuGet version (Gridify)](https://img.shields.io/nuget/v/Gridify.svg?style=flat-square)](https://www.nuget.org/packages/Gridify/) @@ -29,6 +29,7 @@ Gridify is a dynamic LINQ library that simplifies the process of converting stri - Compatible with ORMs, especially Entity Framework - Can be used on every collection that LINQ supports - Compatible with object-mappers like AutoMapper +- Compatible with Elasticsearch ## Documentation From 4819892f164fd71101761eb869d8544f1752dbcc Mon Sep 17 00:00:00 2001 From: Dzmitry Koush Date: Fri, 13 Oct 2023 17:56:08 +0200 Subject: [PATCH 03/13] Add ability to apply CustomElasticsearchNamingAction. Add more tests. --- .../GridifyExtensions.cs | 58 ++--- ...nvertor.cs => ToElasticsearchConverter.cs} | 83 +++++-- src/Gridify/GridifyGlobalConfiguration.cs | 13 +- src/Gridify/GridifyMapperConfiguration.cs | 12 +- .../GridifyExtensionsTests.cs | 221 ++++++++++++++---- 5 files changed, 274 insertions(+), 113 deletions(-) rename src/Gridify.Elasticsearch/{ExpressionToQueryConvertor.cs => ToElasticsearchConverter.cs} (81%) diff --git a/src/Gridify.Elasticsearch/GridifyExtensions.cs b/src/Gridify.Elasticsearch/GridifyExtensions.cs index 2960b200..000d36fd 100644 --- a/src/Gridify.Elasticsearch/GridifyExtensions.cs +++ b/src/Gridify.Elasticsearch/GridifyExtensions.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using Elastic.Clients.Elasticsearch; using Elastic.Clients.Elasticsearch.QueryDsl; using Gridify.Syntax; @@ -20,7 +21,7 @@ public static Query ToElasticsearchQuery(this string? filter, IGridifyMapper< mapper ??= BuildMapperWithNestedProperties(syntaxTree); - var (queryExpression, _) = ExpressionToQueryConvertor.GenerateQuery(syntaxTree.Root, mapper); + var queryExpression = ToElasticsearchConverter.GenerateQuery(syntaxTree.Root, mapper); return queryExpression; } @@ -29,7 +30,11 @@ public static ICollection ToElasticsearchSortOptions(this string if (string.IsNullOrWhiteSpace(ordering)) return new List(); - var sortOptions = ProcessOrdering(ordering, mapper); + var orderings = ordering.ParseOrderings().ToList(); + + mapper ??= BuildMapperForSorting(orderings); + + var sortOptions = ToElasticsearchConverter.GenerateSortOptions(orderings, mapper); return sortOptions; } @@ -63,49 +68,22 @@ private static IEnumerable Descendants(this SyntaxNode root) } } - private static ICollection ProcessOrdering(string orderings, IGridifyMapper? mapper) + private static GridifyMapper BuildMapperForSorting(List orderings) { - var orders = orderings.ParseOrderings().ToList(); - - if (mapper is null) - { - mapper = new GridifyMapper(); - foreach (var order in orders) - try - { - mapper.AddMap(order.MemberName); - } - catch (Exception) - { - if (!mapper.Configuration.IgnoreNotMappedFields) - throw new GridifyMapperException($"Mapping '{order.MemberName}' not found"); - } - } - - var sortOptions = new List(); - foreach (var order in orders) + var mapper = new GridifyMapper(); + foreach (var order in orderings) { - if (!mapper.HasMap(order.MemberName)) + try { - // skip if there is no mappings available - if (mapper.Configuration.IgnoreNotMappedFields) - continue; - - throw new GridifyMapperException($"Mapping '{order.MemberName}' not found"); + mapper.AddMap(order.MemberName); + } + catch (Exception) + { + if (!mapper.Configuration.IgnoreNotMappedFields) + throw new GridifyMapperException($"Mapping '{order.MemberName}' not found"); } - - var field = mapper.GetExpression(order.MemberName).Body.ToPropertyPath(); - field = mapper.GetExpression(order.MemberName).GetRealType() == typeof(string) - ? $"{field}.keyword" - : field; - - var sortOption = SortOptions.Field( - field, - new FieldSort { Order = order.IsAscending ? SortOrder.Asc : SortOrder.Desc }); - - sortOptions.Add(sortOption); } - return sortOptions; + return mapper; } } diff --git a/src/Gridify.Elasticsearch/ExpressionToQueryConvertor.cs b/src/Gridify.Elasticsearch/ToElasticsearchConverter.cs similarity index 81% rename from src/Gridify.Elasticsearch/ExpressionToQueryConvertor.cs rename to src/Gridify.Elasticsearch/ToElasticsearchConverter.cs index ce80a1b0..e107c855 100644 --- a/src/Gridify.Elasticsearch/ExpressionToQueryConvertor.cs +++ b/src/Gridify.Elasticsearch/ToElasticsearchConverter.cs @@ -1,17 +1,18 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using System.Linq.Expressions; +using System.Text.Json; using Elastic.Clients.Elasticsearch; using Elastic.Clients.Elasticsearch.QueryDsl; using Gridify.Syntax; namespace Gridify.Elasticsearch; -internal static class ExpressionToQueryConvertor +internal static class ToElasticsearchConverter { - internal static (Query Expression, bool IsNested) - GenerateQuery(ExpressionSyntax expression, IGridifyMapper mapper) + internal static Query GenerateQuery(ExpressionSyntax expression, IGridifyMapper mapper) { while (true) switch (expression.Kind) @@ -30,14 +31,14 @@ internal static (Query Expression, bool IsNested) catch (GridifyMapperException) { if (mapper.Configuration.IgnoreNotMappedFields) - return (new BoolQuery(), false); + return new BoolQuery(); throw; } } - (Query exp, bool isNested) leftQuery; - (Query exp, bool isNested) rightQuery; + Query leftQuery; + Query rightQuery; if (bExp.Left is ParenthesizedExpressionSyntax lpExp) leftQuery = GenerateQuery(lpExp.Expression, mapper); @@ -51,11 +52,11 @@ internal static (Query Expression, bool IsNested) var result = bExp.OperatorToken.Kind switch { - SyntaxKind.And => leftQuery.exp & rightQuery.exp, - SyntaxKind.Or => leftQuery.exp | rightQuery.exp, + SyntaxKind.And => leftQuery & rightQuery, + SyntaxKind.Or => leftQuery | rightQuery, _ => throw new GridifyFilteringException($"Invalid expression Operator '{bExp.OperatorToken.Kind}'") }; - return (result, false); + return (result); } case SyntaxKind.ParenthesizedExpression: // first entrypoint only { @@ -67,8 +68,35 @@ internal static (Query Expression, bool IsNested) } } - private static (Query Query, bool IsNested)? ConvertBinaryExpressionSyntaxToQuery( - BinaryExpressionSyntax binarySyntax, IGridifyMapper mapper) + internal static ICollection GenerateSortOptions(List orderings, IGridifyMapper mapper) + { + var sortOptions = new List(); + foreach (var order in orderings) + { + if (!mapper.HasMap(order.MemberName)) + { + // skip if there is no mappings available + if (mapper.Configuration.IgnoreNotMappedFields) + continue; + + throw new GridifyMapperException($"Mapping '{order.MemberName}' not found"); + } + + var propExpression = mapper.GetExpression(order.MemberName); + var isStringValue = propExpression.GetRealType() == typeof(string); + var fieldName = BuildFieldName(propExpression.Body, mapper.Configuration, isStringValue); + + var sortOption = SortOptions.Field( + fieldName, + new FieldSort { Order = order.IsAscending ? SortOrder.Asc : SortOrder.Desc }); + + sortOptions.Add(sortOption); + } + + return sortOptions; + } + + private static Query? ConvertBinaryExpressionSyntaxToQuery(BinaryExpressionSyntax binarySyntax, IGridifyMapper mapper) { var fieldExpression = binarySyntax.Left as FieldExpressionSyntax; @@ -79,7 +107,6 @@ private static (Query Query, bool IsNested)? ConvertBinaryExpressionSyntaxToQuer if (left == null || right == null) return null; var gMap = mapper.GetGMap(left); - if (gMap == null) throw new GridifyMapperException($"Mapping '{left}' not found"); if (fieldExpression!.IsCollection) @@ -95,22 +122,20 @@ private static (Query Query, bool IsNested)? ConvertBinaryExpressionSyntaxToQuer gMap.To.Body, right, op, - mapper.Configuration.AllowNullSearch, gMap.Convertor, - left, - right.ValueToken.Text); + right.ValueToken.Text, + mapper.Configuration); - return (result, false); + return result; } private static Query GenerateQuery( Expression body, ValueExpressionSyntax valueExpression, SyntaxNode op, - bool allowNullSearch, Func? convertor, - string left, - string right) + string right, + GridifyMapperConfiguration mapperConfiguration) { // Remove the boxing for value types if (body.NodeType == ExpressionType.Convert) body = ((UnaryExpression)body).Operand; @@ -122,7 +147,7 @@ private static Query GenerateQuery( value = convertor.Invoke(valueExpression.ValueToken.Text); // handle the `null` keyword in value - if (allowNullSearch && op.Kind is SyntaxKind.Equal or SyntaxKind.NotEqual && value.ToString() == "null") + if (mapperConfiguration.AllowNullSearch && op.Kind is SyntaxKind.Equal or SyntaxKind.NotEqual && value.ToString() == "null") value = null; // type fixer @@ -159,11 +184,8 @@ private static Query GenerateQuery( isNumberExceptDecimalValue = true; } - var propertyPath = body.ToPropertyPath(); - - var field = isStringValue - ? new Field($"{propertyPath}.keyword") - : new Field(propertyPath); + var fieldName = BuildFieldName(body, mapperConfiguration, isStringValue); + var field = new Field(fieldName); Query query; switch (op.Kind) @@ -274,6 +296,17 @@ private static Query GenerateQuery( return query; } + private static string BuildFieldName( + Expression expression, GridifyMapperConfiguration mapperConfiguration, bool isStringValue) + { + var propertyPath = expression.ToPropertyPath(); + var propertyPathParts = propertyPath.Split('.'); + propertyPath = string.Join(".", propertyPathParts.Select( + mapperConfiguration.CustomElasticsearchNamingAction ?? JsonNamingPolicy.CamelCase.ConvertName)); + + return isStringValue ? $"{propertyPath}.keyword" : propertyPath; + } + private static bool IsString(object? value) { return value?.GetType() == typeof(string) || value?.GetType() == typeof(Guid); diff --git a/src/Gridify/GridifyGlobalConfiguration.cs b/src/Gridify/GridifyGlobalConfiguration.cs index 08836a47..c93bd541 100644 --- a/src/Gridify/GridifyGlobalConfiguration.cs +++ b/src/Gridify/GridifyGlobalConfiguration.cs @@ -1,5 +1,4 @@ -using System.Collections.Generic; -using System.Linq; +using System; using Gridify.Syntax; namespace Gridify @@ -48,6 +47,16 @@ public static class GridifyGlobalConfiguration /// public static bool DisableNullChecks { get; set; } = false; + /// + /// Specifies how field names are inferred from CLR property names. + /// By default, Elastic.Clients.Elasticsearch uses camel-case property names. + /// + /// + /// If false CLR property EmailAddress will be inferred as "emailAddress" Elasticsearch document field name + /// If true, the CLR property EmailAddress will be inferred as "EmailAddress" Elasticsearch document field name + /// + public static Func? CustomElasticsearchNamingAction { get; set; } + /// /// You can extend the gridify supported operators by adding /// your own operators to OperatorManager. diff --git a/src/Gridify/GridifyMapperConfiguration.cs b/src/Gridify/GridifyMapperConfiguration.cs index 32e2cba3..1f681e2b 100644 --- a/src/Gridify/GridifyMapperConfiguration.cs +++ b/src/Gridify/GridifyMapperConfiguration.cs @@ -1,3 +1,5 @@ +using System; + namespace Gridify; public record GridifyMapperConfiguration @@ -22,5 +24,13 @@ public record GridifyMapperConfiguration /// public bool IgnoreNotMappedFields { get; set; } = GridifyGlobalConfiguration.IgnoreNotMappedFields; + /// + /// Specifies how field names are inferred from CLR property names. + /// By default, Elastic.Clients.Elasticsearch uses camel-case property names. + /// + /// + /// If false CLR property EmailAddress will be inferred as "emailAddress" Elasticsearch document field name + /// If true, the CLR property EmailAddress will be inferred as "EmailAddress" Elasticsearch document field name + /// + public Func? CustomElasticsearchNamingAction { get; set; } = GridifyGlobalConfiguration.CustomElasticsearchNamingAction; } - diff --git a/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs b/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs index 2866837c..8d6e67a0 100644 --- a/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs +++ b/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs @@ -1,4 +1,5 @@ -using Elastic.Clients.Elasticsearch; +using System; +using Elastic.Clients.Elasticsearch; using Elastic.Transport.Extensions; using Xunit; @@ -8,6 +9,12 @@ public class GridifyExtensionsTests { private readonly ElasticsearchClient _client = new(); + public GridifyExtensionsTests() + { + // Disable Elasticsearch naming policy and use property names as they are + GridifyGlobalConfiguration.CustomElasticsearchNamingAction = p => p; + } + [Theory] // byte equals [InlineData("MyByte=1", """{"term":{"MyByte":{"value":1}}}""")] @@ -25,9 +32,9 @@ public class GridifyExtensionsTests [InlineData("MyByte=null", """{"bool":{"must_not":{"exists":{"field":"MyByte"}}}}""")] // byte is not null [InlineData("MyByte!=null", """{"exists":{"field":"MyByte"}}""")] - public void ToElasticsearchQuery_WhenCalledWithByteValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithByteValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -47,9 +54,9 @@ public void ToElasticsearchQuery_WhenCalledWithByteValue_ReturnsElasticsearchQue [InlineData("MySByte=null", """{"bool":{"must_not":{"exists":{"field":"MySByte"}}}}""")] // sbyte is not null [InlineData("MySByte!=null", """{"exists":{"field":"MySByte"}}""")] - public void ToElasticsearchQuery_WhenCalledWithSByteValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithSByteValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -69,9 +76,9 @@ public void ToElasticsearchQuery_WhenCalledWithSByteValue_ReturnsElasticsearchQu [InlineData("MyShort=null", """{"bool":{"must_not":{"exists":{"field":"MyShort"}}}}""")] // short is not null [InlineData("MyShort!=null", """{"exists":{"field":"MyShort"}}""")] - public void ToElasticsearchQuery_WhenCalledWithShortValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithShortValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -91,9 +98,9 @@ public void ToElasticsearchQuery_WhenCalledWithShortValue_ReturnsElasticsearchQu [InlineData("MyUShort=null", """{"bool":{"must_not":{"exists":{"field":"MyUShort"}}}}""")] // ushort is not null [InlineData("MyUShort!=null", """{"exists":{"field":"MyUShort"}}""")] - public void ToElasticsearchQuery_WhenCalledWithUShortValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithUShortValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -113,9 +120,9 @@ public void ToElasticsearchQuery_WhenCalledWithUShortValue_ReturnsElasticsearchQ [InlineData("Id=null", """{"bool":{"must_not":{"exists":{"field":"Id"}}}}""")] // int is not null [InlineData("Id!=null", """{"exists":{"field":"Id"}}""")] - public void ToElasticsearchQuery_WhenCalledWithNumber_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithNumber_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -135,9 +142,9 @@ public void ToElasticsearchQuery_WhenCalledWithNumber_ReturnsElasticsearchQuery( [InlineData("MyUInt=null", """{"bool":{"must_not":{"exists":{"field":"MyUInt"}}}}""")] // uint is not null [InlineData("MyUInt!=null", """{"exists":{"field":"MyUInt"}}""")] - public void ToElasticsearchQuery_WhenCalledWithUIntValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithUIntValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -157,9 +164,9 @@ public void ToElasticsearchQuery_WhenCalledWithUIntValue_ReturnsElasticsearchQue [InlineData("MyLong=null", """{"bool":{"must_not":{"exists":{"field":"MyLong"}}}}""")] // long is not null [InlineData("MyLong!=null", """{"exists":{"field":"MyLong"}}""")] - public void ToElasticsearchQuery_WhenCalledWithLongValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithLongValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -179,9 +186,9 @@ public void ToElasticsearchQuery_WhenCalledWithLongValue_ReturnsElasticsearchQue [InlineData("MyULong=null", """{"bool":{"must_not":{"exists":{"field":"MyULong"}}}}""")] // ulong is not null [InlineData("MyULong!=null", """{"exists":{"field":"MyULong"}}""")] - public void ToElasticsearchQuery_WhenCalledWithULongValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithULongValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -201,9 +208,9 @@ public void ToElasticsearchQuery_WhenCalledWithULongValue_ReturnsElasticsearchQu [InlineData("MyFloat=null", """{"bool":{"must_not":{"exists":{"field":"MyFloat"}}}}""")] // float is not null [InlineData("MyFloat!=null", """{"exists":{"field":"MyFloat"}}""")] - public void ToElasticsearchQuery_WhenCalledWithFloatValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithFloatValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -223,9 +230,9 @@ public void ToElasticsearchQuery_WhenCalledWithFloatValue_ReturnsElasticsearchQu [InlineData("MyDouble=null", """{"bool":{"must_not":{"exists":{"field":"MyDouble"}}}}""")] // double is not null [InlineData("MyDouble!=null", """{"exists":{"field":"MyDouble"}}""")] - public void ToElasticsearchQuery_WhenCalledWithDoubleValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithDoubleValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -245,9 +252,9 @@ public void ToElasticsearchQuery_WhenCalledWithDoubleValue_ReturnsElasticsearchQ [InlineData("MyDecimal=null", """{"bool":{"must_not":{"exists":{"field":"MyDecimal"}}}}""")] // decimal is not null [InlineData("MyDecimal!=null", """{"exists":{"field":"MyDecimal"}}""")] - public void ToElasticsearchQuery_WhenCalledWithDecimalValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithDecimalValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -279,9 +286,9 @@ public void ToElasticsearchQuery_WhenCalledWithDecimalValue_ReturnsElasticsearch [InlineData("Name=null|Name=", """{"bool":{"should":[{"bool":{"must_not":{"exists":{"field":"Name"}}}},{"term":{"Name.keyword":{"value":""}}}]}}""")] // string is not empty and not null [InlineData("Name!=null,Name!=", """{"bool":{"must":{"exists":{"field":"Name"}},"must_not":{"term":{"Name.keyword":{"value":""}}}}}""")] - public void ToElasticsearchQuery_WhenCalledWithStringValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithStringValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -303,9 +310,9 @@ public void ToElasticsearchQuery_WhenCalledWithStringValue_ReturnsElasticsearchQ [InlineData("MyDateTime<2021-09-01", """{"range":{"MyDateTime":{"lt":"2021-09-01T00:00:00"}}}""")] // date less than or equal [InlineData("MyDateTime<=2021-09-01", """{"range":{"MyDateTime":{"lte":"2021-09-01T00:00:00"}}}""")] - public void ToElasticsearchQuery_WhenCalledWithDateTimeValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithDateTimeValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -323,9 +330,9 @@ public void ToElasticsearchQuery_WhenCalledWithDateTimeValue_ReturnsElasticsearc [InlineData("MyDateOnly<2021-09-01", """{"range":{"MyDateOnly":{"lt":"2021-09-01"}}}""")] // date only less than or equal [InlineData("MyDateOnly<=2021-09-01", """{"range":{"MyDateOnly":{"lte":"2021-09-01"}}}""")] - public void ToElasticsearchQuery_WhenCalledWithDateOnlyValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithDateOnlyValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -337,9 +344,9 @@ public void ToElasticsearchQuery_WhenCalledWithDateOnlyValue_ReturnsElasticsearc [InlineData("IsActive=null", """{"bool":{"must_not":{"exists":{"field":"IsActive"}}}}""")] // bool is not null [InlineData("IsActive!=null", """{"exists":{"field":"IsActive"}}""")] - public void ToElasticsearchQuery_WhenCalledWithBoolValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithBoolValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -349,9 +356,9 @@ public void ToElasticsearchQuery_WhenCalledWithBoolValue_ReturnsElasticsearchQue [InlineData("MyGuid=null", """{"bool":{"must_not":{"exists":{"field":"MyGuid"}}}}""")] // guid is not null [InlineData("MyGuid!=null", """{"exists":{"field":"MyGuid"}}""")] - public void ToElasticsearchQuery_WhenCalledWithGuidValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithGuidValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -381,9 +388,9 @@ public void ToElasticsearchQuery_WhenCalledWithGuidValue_ReturnsElasticsearchQue [InlineData("(Name=Dzmitry|Name=John),(Id=1|Id=2)", """{"bool":{"must":[{"bool":{"should":[{"term":{"Name.keyword":{"value":"Dzmitry"}}},{"term":{"Name.keyword":{"value":"John"}}}]}},{"bool":{"should":[{"term":{"Id":{"value":1}}},{"term":{"Id":{"value":2}}}]}}]}}""")] // , ( , | ) | ) operators [InlineData("Id=1,(Name=Dzmitry,(Id=1|Id=2)|Id=3)", """{"bool":{"must":[{"term":{"Id":{"value":1}}},{"bool":{"should":[{"bool":{"must":[{"term":{"Name.keyword":{"value":"Dzmitry"}}},{"bool":{"should":[{"term":{"Id":{"value":1}}},{"term":{"Id":{"value":2}}}]}}]}},{"term":{"Id":{"value":3}}}]}}]}}""")] - public void ToElasticsearchQuery_WhenCalledWithDifferentOperators_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithDifferentOperators_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -391,9 +398,9 @@ public void ToElasticsearchQuery_WhenCalledWithDifferentOperators_ReturnsElastic [InlineData("Name=Dzmitry,ChildClass.Name=Kiryl", """{"bool":{"must":[{"term":{"Name.keyword":{"value":"Dzmitry"}}},{"term":{"ChildClass.Name.keyword":{"value":"Kiryl"}}}]}}""")] // Double nested class [InlineData("Name=Sergey,ChildClass.Name=Dzmitry,ChildClass.ChildClass.Name=Kiryl", """{"bool":{"must":[{"term":{"Name.keyword":{"value":"Sergey"}}},{"term":{"ChildClass.Name.keyword":{"value":"Dzmitry"}}},{"term":{"ChildClass.ChildClass.Name.keyword":{"value":"Kiryl"}}}]}}""")] - public void ToElasticsearchQuery_WhenCalledWithNestedClass_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithNestedClass_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] @@ -407,21 +414,87 @@ public void ToElasticsearchQuery_WhenCalledWithNestedClass_ReturnsElasticsearchQ [InlineData("Name!^", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"*"}}}}}""")] // string does not end with empty [InlineData("Name!$", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"*"}}}}}""")] - public void ToElasticsearchQuery_WhenCalledWithEmptyValue_ReturnsElasticsearchQuery(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithEmptyValue_ReturnsElasticsearchQuery(string filter, string expected) { - AssertQuery(query, expected); + AssertFilter(filter, expected); } [Theory] [InlineData("name=Dzmitry,childname=Kiryl", """{"bool":{"must":[{"term":{"Name.keyword":{"value":"Dzmitry"}}},{"term":{"ChildClass.Name.keyword":{"value":"Kiryl"}}}]}}""")] - public void ToElasticsearchQuery_WhenCalledWithCustomMapper_ShouldUseCorrectFieldNames(string query, string expected) + public void ToElasticsearchQuery_WhenCalledWithCustomMapper_ShouldUseCorrectFieldNames(string filter, string expected) { var mapper = new GridifyMapper() .GenerateMappings() .AddMap("name", x => x.Name) .AddMap("childname", x => x.ChildClass.Name); - AssertQuery(query, expected, mapper); + AssertFilter(filter, expected, mapper); + } + + [Theory] + [InlineData("tag=Dzmitry", true, "")] + [InlineData("tag=Dzmitry", false, """{"term":{"Tag.keyword":{"value":"Dzmitry"}}}""")] + public void ToElasticsearchQuery_WhenCalledWithCaseSensitiveMapperSetting_ShouldApplyTheSetting(string filter, bool caseSensitive, string expected) + { + var mapper = new GridifyMapper { Configuration = { CaseSensitive = caseSensitive } } + .AddMap("Tag", x => x.Tag); + + if (caseSensitive) + { + var ex = Assert.Throws(() => filter.ToElasticsearchQuery(mapper)); + Assert.Equal("Mapping 'tag' not found", ex.Message); + return; + } + + AssertFilter(filter, expected, mapper); + } + + [Theory] + [InlineData("Tag=null", true, """{"bool":{"must_not":{"exists":{"field":"Tag"}}}}""")] + [InlineData("Tag=null", false, """{"term":{"Tag.keyword":{"value":"null"}}}""")] + public void ToElasticsearchQuery_WhenCalledWithAllowNullSearchSetting_ShouldApplyTheSetting(string filter, bool allowNullSearch, string expected) + { + var mapper = new GridifyMapper(autoGenerateMappings: true) { Configuration = { AllowNullSearch = allowNullSearch } }; + + AssertFilter(filter, expected, mapper); + } + + [Theory] + [InlineData("NotMappedField=Dzmitry", true, """{"bool":{}}"""/* equivalent of """{"match_all":{}}"""*/)] + [InlineData("NotMappedField=Homer,MappedField=Dzmitry", true, """{"bool":{"must":{"term":{"Name.keyword":{"value":"Dzmitry"}}}}}"""/* equivalent of """{"term":{"Name.keyword":{"value":"Dzmitry"}}}"""*/)] + [InlineData("NotMappedField=Dzmitry", false, "")] + [InlineData("NotMappedField=Homer,MappedField=Dzmitry", false, "")] + public void ToElasticsearchQuery_WhenCalledWithIgnoreNotMappedFieldsSetting_ShouldApplyTheSetting(string filter, bool ignoreNotMappedFields, string expected) + { + var mapper = new GridifyMapper { Configuration = { IgnoreNotMappedFields = ignoreNotMappedFields }} + .AddMap("MappedField", x => x.Name); + + if (!ignoreNotMappedFields) + { + var ex = Assert.Throws(() => filter.ToElasticsearchQuery(mapper)); + Assert.Equal("Mapping 'NotMappedField' not found", ex.Message); + return; + } + + AssertFilter(filter, expected, mapper); + } + + [Theory] + [InlineData("Name=Bart", true, """{"term":{"_Name_.keyword":{"value":"Bart"}}}""")] + [InlineData("Name=Bart", false, """{"term":{"name.keyword":{"value":"Bart"}}}""")] + [InlineData("Name=Homer,childName=Lisa", true, """{"bool":{"must":[{"term":{"_Name_.keyword":{"value":"Homer"}}},{"term":{"_ChildClass_._Name_.keyword":{"value":"Lisa"}}}]}}""")] + [InlineData("Name=Homer,childName=Lisa", false, """{"bool":{"must":[{"term":{"name.keyword":{"value":"Homer"}}},{"term":{"childClass.name.keyword":{"value":"Lisa"}}}]}}""")] + public void ToElasticsearchQuery_WhenCalledWithCustomElasticsearchNamingActionSetting_ShouldApplyTheSetting( + string filter, bool ignoreElasticsearchPropertyNamingPolicy, string expected) + { + Func? namingAction = ignoreElasticsearchPropertyNamingPolicy ? p => $"_{p}_" : null; + var mapper = new GridifyMapper(autoGenerateMappings: true) + { + Configuration = { CustomElasticsearchNamingAction = namingAction } + }; + mapper.AddMap("childName", x => x.ChildClass.Name); + + AssertFilter(filter, expected, mapper); } [Theory] @@ -431,14 +504,14 @@ public void ToElasticsearchQuery_WhenCalledWithCustomMapper_ShouldUseCorrectFiel [InlineData("Id asc, Name desc, MyDateTime", """[{"Id":{"order":"asc"}},{"Name.keyword":{"order":"desc"}},{"MyDateTime":{"order":"asc"}}]""")] [InlineData("ChildClass.Name desc", """[{"ChildClass.Name.keyword":{"order":"desc"}}]""")] [InlineData("", "[]")] - public void ToSortOptions_WhenCalledWithOrdering_ReturnsElasticsearchSortOptions(string ordering, string expected) + public void ToElasticsearchSortOptions_WhenCalledWithOrdering_ReturnsElasticsearchSortOptions(string ordering, string expected) { AssertOrdering(ordering, expected); } [Theory] [InlineData("name asc,childname desc", """[{"Name.keyword":{"order":"asc"}},{"ChildClass.Name.keyword":{"order":"desc"}}]""")] - public void ToSortOptions_WhenCalledWithCustomMapper_ShouldUseCorrectFieldNames(string ordering, string expected) + public void ToElasticsearchSortOptions_WhenCalledWithCustomMapper_ShouldUseCorrectFieldNames(string ordering, string expected) { var mapper = new GridifyMapper() .GenerateMappings() @@ -448,9 +521,67 @@ public void ToSortOptions_WhenCalledWithCustomMapper_ShouldUseCorrectFieldNames( AssertOrdering(ordering, expected, mapper); } - private void AssertQuery(string query, string expected, IGridifyMapper? mapper = null) + [Theory] + [InlineData("tag asc", true, "")] + [InlineData("tag asc", false, """[{"Tag.keyword":{"order":"asc"}}]""")] + public void ToElasticsearchSortOptions_WhenCalledWithCaseSensitiveMapperSetting_ShouldApplyTheSetting( + string ordering, bool caseSensitive, string expected) + { + var mapper = new GridifyMapper { Configuration = { CaseSensitive = caseSensitive } } + .AddMap("Tag", x => x.Tag); + + if (caseSensitive) + { + var ex = Assert.Throws(() => ordering.ToElasticsearchSortOptions(mapper)); + Assert.Equal("Mapping 'tag' not found", ex.Message); + return; + } + + AssertOrdering(ordering, expected, mapper); + } + + [Theory] + [InlineData("NotMappedField asc", true, "[]")] + [InlineData("NotMappedField asc,MappedField desc", true, """[{"Name.keyword":{"order":"desc"}}]""")] + [InlineData("NotMappedField asc", false, "")] + [InlineData("NotMappedField asc,MappedField desc", false, "")] + public void ToElasticsearchSortOptions_WhenCalledWithIgnoreNotMappedFieldsSetting_ShouldApplyTheSetting( + string ordering, bool ignoreNotMappedFields, string expected) + { + var mapper = new GridifyMapper { Configuration = { IgnoreNotMappedFields = ignoreNotMappedFields }} + .AddMap("MappedField", x => x.Name); + + if (!ignoreNotMappedFields) + { + var ex = Assert.Throws(() => ordering.ToElasticsearchSortOptions(mapper)); + Assert.Equal("Mapping 'NotMappedField' not found", ex.Message); + return; + } + + AssertOrdering(ordering, expected, mapper); + } + + [Theory] + [InlineData("Name asc", true, """[{"_Name_.keyword":{"order":"asc"}}]""")] + [InlineData("Name asc", false, """[{"name.keyword":{"order":"asc"}}]""")] + [InlineData("Name asc,childName desc", true, """[{"_Name_.keyword":{"order":"asc"}},{"_ChildClass_._Name_.keyword":{"order":"desc"}}]""")] + [InlineData("Name asc,childName desc", false, """[{"name.keyword":{"order":"asc"}},{"childClass.name.keyword":{"order":"desc"}}]""")] + public void ToElasticsearchSortOptions_WhenCalledWithIgnoreElasticsearchPropertyNamingPolicySetting_ShouldApplyTheSetting( + string ordering, bool ignoreElasticsearchPropertyNamingPolicy, string expected) + { + Func? namingAction = ignoreElasticsearchPropertyNamingPolicy ? p => $"_{p}_" : null; + var mapper = new GridifyMapper(autoGenerateMappings: true) + { + Configuration = { CustomElasticsearchNamingAction = namingAction } + }; + mapper.AddMap("childName", x => x.ChildClass.Name); + + AssertOrdering(ordering, expected, mapper); + } + + private void AssertFilter(string filter, string expected, IGridifyMapper? mapper = null) { - var result = query.ToElasticsearchQuery(mapper); + var result = filter.ToElasticsearchQuery(mapper); var jsonQuery = _client.RequestResponseSerializer.SerializeToString(result); Assert.Equal(expected, jsonQuery); From 19ee6dd1c952ceb0a8196b61a475f751383a8953 Mon Sep 17 00:00:00 2001 From: Dzmitry Koush Date: Wed, 18 Oct 2023 15:36:06 +0200 Subject: [PATCH 04/13] Refactor filtering and ordering functionality. Create base builders and use them for LINQ and Elasticsearch query builders. --- ...verter.cs => ElasticsearchQueryBuilder.cs} | 194 ++----- .../ElasticsearchSortOptionsBuilder.cs | 37 ++ .../ExpressionExtensions.cs | 32 +- .../GridifyExtensions.cs | 65 +-- src/Gridify/GridifyExtensions.cs | 111 +--- src/Gridify/QueryBuilders/BaseQueryBuilder.cs | 215 ++++++++ .../QueryBuilders/BaseSortingQueryBuilder.cs | 70 +++ .../LinqQueryBuilder.cs} | 489 +++++++----------- .../QueryBuilders/LinqSortingQueryBuilder.cs | 66 +++ 9 files changed, 658 insertions(+), 621 deletions(-) rename src/Gridify.Elasticsearch/{ToElasticsearchConverter.cs => ElasticsearchQueryBuilder.cs} (51%) create mode 100644 src/Gridify.Elasticsearch/ElasticsearchSortOptionsBuilder.cs create mode 100644 src/Gridify/QueryBuilders/BaseQueryBuilder.cs create mode 100644 src/Gridify/QueryBuilders/BaseSortingQueryBuilder.cs rename src/Gridify/{Syntax/SyntaxTreeToQueryConvertor.cs => QueryBuilders/LinqQueryBuilder.cs} (60%) create mode 100644 src/Gridify/QueryBuilders/LinqSortingQueryBuilder.cs diff --git a/src/Gridify.Elasticsearch/ToElasticsearchConverter.cs b/src/Gridify.Elasticsearch/ElasticsearchQueryBuilder.cs similarity index 51% rename from src/Gridify.Elasticsearch/ToElasticsearchConverter.cs rename to src/Gridify.Elasticsearch/ElasticsearchQueryBuilder.cs index e107c855..78ea4246 100644 --- a/src/Gridify.Elasticsearch/ToElasticsearchConverter.cs +++ b/src/Gridify.Elasticsearch/ElasticsearchQueryBuilder.cs @@ -1,179 +1,51 @@ using System; using System.Collections.Generic; -using System.ComponentModel; -using System.Linq; using System.Linq.Expressions; -using System.Text.Json; using Elastic.Clients.Elasticsearch; using Elastic.Clients.Elasticsearch.QueryDsl; +using Gridify.QueryBuilders; using Gridify.Syntax; namespace Gridify.Elasticsearch; -internal static class ToElasticsearchConverter +internal class ElasticsearchQueryBuilder : BaseQueryBuilder { - internal static Query GenerateQuery(ExpressionSyntax expression, IGridifyMapper mapper) + public ElasticsearchQueryBuilder(IGridifyMapper mapper) : base(mapper) { - while (true) - switch (expression.Kind) - { - case SyntaxKind.BinaryExpression: - { - var bExp = expression as BinaryExpressionSyntax; - - if (bExp!.Left is FieldExpressionSyntax && bExp.Right is ValueExpressionSyntax) - { - try - { - return ConvertBinaryExpressionSyntaxToQuery(bExp, mapper) - ?? throw new GridifyFilteringException("Invalid expression"); - } - catch (GridifyMapperException) - { - if (mapper.Configuration.IgnoreNotMappedFields) - return new BoolQuery(); - - throw; - } - } - - Query leftQuery; - Query rightQuery; - - if (bExp.Left is ParenthesizedExpressionSyntax lpExp) - leftQuery = GenerateQuery(lpExp.Expression, mapper); - else - leftQuery = GenerateQuery(bExp.Left, mapper); - - if (bExp.Right is ParenthesizedExpressionSyntax rpExp) - rightQuery = GenerateQuery(rpExp.Expression, mapper); - else - rightQuery = GenerateQuery(bExp.Right, mapper); - - var result = bExp.OperatorToken.Kind switch - { - SyntaxKind.And => leftQuery & rightQuery, - SyntaxKind.Or => leftQuery | rightQuery, - _ => throw new GridifyFilteringException($"Invalid expression Operator '{bExp.OperatorToken.Kind}'") - }; - return (result); - } - case SyntaxKind.ParenthesizedExpression: // first entrypoint only - { - var pExp = expression as ParenthesizedExpressionSyntax; - return GenerateQuery(pExp!.Expression, mapper); - } - default: - throw new GridifyFilteringException($"Invalid expression format '{expression.Kind}'."); - } } - internal static ICollection GenerateSortOptions(List orderings, IGridifyMapper mapper) + protected override Query BuildNestedQuery( + Expression body, IGMap gMap, ValueExpressionSyntax value, SyntaxNode op) { - var sortOptions = new List(); - foreach (var order in orderings) - { - if (!mapper.HasMap(order.MemberName)) - { - // skip if there is no mappings available - if (mapper.Configuration.IgnoreNotMappedFields) - continue; - - throw new GridifyMapperException($"Mapping '{order.MemberName}' not found"); - } - - var propExpression = mapper.GetExpression(order.MemberName); - var isStringValue = propExpression.GetRealType() == typeof(string); - var fieldName = BuildFieldName(propExpression.Body, mapper.Configuration, isStringValue); - - var sortOption = SortOptions.Field( - fieldName, - new FieldSort { Order = order.IsAscending ? SortOrder.Asc : SortOrder.Desc }); - - sortOptions.Add(sortOption); - } - - return sortOptions; + throw new NotSupportedException(); } - private static Query? ConvertBinaryExpressionSyntaxToQuery(BinaryExpressionSyntax binarySyntax, IGridifyMapper mapper) + protected override Query BuildAlwaysTrueQuery() { - var fieldExpression = binarySyntax.Left as FieldExpressionSyntax; - - var left = fieldExpression?.FieldToken.Text.Trim(); - var right = binarySyntax.Right as ValueExpressionSyntax; - var op = binarySyntax.OperatorToken; - - if (left == null || right == null) return null; - - var gMap = mapper.GetGMap(left); - if (gMap == null) throw new GridifyMapperException($"Mapping '{left}' not found"); - - if (fieldExpression!.IsCollection) - throw new NotSupportedException(); - - var isNested = ((GMap)gMap).IsNestedCollection(); - if (isNested) - { - throw new NotSupportedException(); - } + return new BoolQuery(); + } - var result = GenerateQuery( - gMap.To.Body, - right, - op, - gMap.Convertor, - right.ValueToken.Text, - mapper.Configuration); + protected override Query BuildAlwaysFalseQuery(ParameterExpression parameter) + { + return new BoolQuery { MustNot = new List { new MatchAllQuery() } }; + } - return result; + protected override Query? CheckIfCanMergeQueries( + (Query query, bool isNested) leftQuery, + (Query query, bool isNested) rightQuery, + SyntaxKind op) + { + return null; } - private static Query GenerateQuery( + protected override object BuildQueryAccordingToValueType( Expression body, - ValueExpressionSyntax valueExpression, + ParameterExpression parameter, + object? value, SyntaxNode op, - Func? convertor, - string right, - GridifyMapperConfiguration mapperConfiguration) + ValueExpressionSyntax valueExpression, + bool isConvertable) { - // Remove the boxing for value types - if (body.NodeType == ExpressionType.Convert) body = ((UnaryExpression)body).Operand; - - object? value = valueExpression.ValueToken.Text; - - // execute user custom Convertor - if (convertor != null) - value = convertor.Invoke(valueExpression.ValueToken.Text); - - // handle the `null` keyword in value - if (mapperConfiguration.AllowNullSearch && op.Kind is SyntaxKind.Equal or SyntaxKind.NotEqual && value.ToString() == "null") - value = null; - - // type fixer - if (value is not null && body.Type != value.GetType()) - { - try - { - // handle bool, github issue #71 - if (body.Type == typeof(bool) && value is "true" or "false" or "1" or "0") - value = (((string)value).ToLower() is "1" or "true"); - // handle broken guids, github issue #2 - else if (body.Type == typeof(Guid) && !Guid.TryParse(value.ToString(), out _)) value = Guid.NewGuid().ToString(); - - var converter = TypeDescriptor.GetConverter(body.Type); - var isConvertable = converter.CanConvertFrom(typeof(string)); - if (isConvertable) - value = converter.ConvertFromString(value.ToString()!); - } - catch (FormatException) - { - // this code should never run - // return no records in case of any exception in formatting - return new BoolQuery { MustNot = new List { new MatchAllQuery() } }; - } - } - bool isStringValue = false, isNumberExceptDecimalValue = false; if (IsString(value)) { @@ -184,8 +56,9 @@ private static Query GenerateQuery( isNumberExceptDecimalValue = true; } - var fieldName = BuildFieldName(body, mapperConfiguration, isStringValue); + var fieldName = body.BuildFieldName(isStringValue, mapper); var field = new Field(fieldName); + var right = valueExpression.ValueToken.Text; Query query; switch (op.Kind) @@ -290,21 +163,20 @@ private static Query GenerateQuery( case SyntaxKind.CustomOperator: throw new NotImplementedException(); default: - throw new GridifyFilteringException("Invalid expression");; + throw new GridifyFilteringException("Invalid expression"); } return query; } - private static string BuildFieldName( - Expression expression, GridifyMapperConfiguration mapperConfiguration, bool isStringValue) + protected override Query CombineWithAndOperator(Query left, Query right) { - var propertyPath = expression.ToPropertyPath(); - var propertyPathParts = propertyPath.Split('.'); - propertyPath = string.Join(".", propertyPathParts.Select( - mapperConfiguration.CustomElasticsearchNamingAction ?? JsonNamingPolicy.CamelCase.ConvertName)); + return left & right; + } - return isStringValue ? $"{propertyPath}.keyword" : propertyPath; + protected override Query CombineWithOrOperator(Query left, Query right) + { + return left | right; } private static bool IsString(object? value) diff --git a/src/Gridify.Elasticsearch/ElasticsearchSortOptionsBuilder.cs b/src/Gridify.Elasticsearch/ElasticsearchSortOptionsBuilder.cs new file mode 100644 index 00000000..d19b685a --- /dev/null +++ b/src/Gridify.Elasticsearch/ElasticsearchSortOptionsBuilder.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using Elastic.Clients.Elasticsearch; +using Gridify.QueryBuilders; +using Gridify.Syntax; + +namespace Gridify.Elasticsearch; + +internal class ElasticsearchSortOptionsBuilder : BaseSortingQueryBuilder, T> +{ + internal ElasticsearchSortOptionsBuilder(IGridifyMapper? mapper = null) : base(mapper) + { + } + + internal ICollection Build(string ordering) + { + return ProcessOrdering(new List(), ordering, false); + } + + protected override ICollection ApplySorting(ICollection sortOptions, ParsedOrdering ordering) + { + var propExpression = mapper!.GetExpression(ordering.MemberName); + var isStringValue = propExpression.GetRealType() == typeof(string); + var fieldName = propExpression.Body.BuildFieldName(isStringValue, mapper); + + var sortOption = SortOptions.Field( + fieldName, + new FieldSort { Order = ordering.IsAscending ? SortOrder.Asc : SortOrder.Desc }); + + sortOptions.Add(sortOption); + return sortOptions; + } + + protected override ICollection ApplyAnotherSorting(ICollection sortOptions, ParsedOrdering ordering) + { + return ApplySorting(sortOptions, ordering); + } +} diff --git a/src/Gridify.Elasticsearch/ExpressionExtensions.cs b/src/Gridify.Elasticsearch/ExpressionExtensions.cs index 6451738a..965bcca8 100644 --- a/src/Gridify.Elasticsearch/ExpressionExtensions.cs +++ b/src/Gridify.Elasticsearch/ExpressionExtensions.cs @@ -1,20 +1,14 @@ using System; +using System.Linq; using System.Linq.Expressions; using System.Text; +using System.Text.Json; namespace Gridify.Elasticsearch; internal static class ExpressionExtensions { - internal static string ToPropertyPath(this Expression expression) - { - var memberAccessList = new StringBuilder(); - VisitMemberAccessChain(expression, memberAccessList); - - return memberAccessList.ToString(); - } - - public static Type GetRealType(this Expression> expression) + internal static Type GetRealType(this Expression> expression) { if (expression.Body is MemberExpression memberExpression) return memberExpression.Type; @@ -25,7 +19,25 @@ public static Type GetRealType(this Expression> expression) throw new InvalidOperationException("Unsupported expression type."); } - private static void VisitMemberAccessChain(Expression expression, StringBuilder result) + internal static string BuildFieldName(this Expression expression, bool isStringValue, IGridifyMapper mapper) + { + var propertyPath = expression.ToPropertyPath(); + var propertyPathParts = propertyPath.Split('.'); + propertyPath = string.Join(".", propertyPathParts.Select( + mapper.Configuration.CustomElasticsearchNamingAction ?? JsonNamingPolicy.CamelCase.ConvertName)); + + return isStringValue ? $"{propertyPath}.keyword" : propertyPath; + } + + private static string ToPropertyPath(this Expression expression) + { + var memberAccessList = new StringBuilder(); + VisitMemberAccessChain(expression, memberAccessList); + + return memberAccessList.ToString(); + } + + private static void VisitMemberAccessChain(Expression? expression, StringBuilder result) { if (expression is MemberExpression memberExpression) { diff --git a/src/Gridify.Elasticsearch/GridifyExtensions.cs b/src/Gridify.Elasticsearch/GridifyExtensions.cs index 000d36fd..abc4d8c7 100644 --- a/src/Gridify.Elasticsearch/GridifyExtensions.cs +++ b/src/Gridify.Elasticsearch/GridifyExtensions.cs @@ -1,7 +1,5 @@ -using System; -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; -using System.Text.Json; using Elastic.Clients.Elasticsearch; using Elastic.Clients.Elasticsearch.QueryDsl; using Gridify.Syntax; @@ -15,13 +13,13 @@ public static Query ToElasticsearchQuery(this string? filter, IGridifyMapper< if (string.IsNullOrWhiteSpace(filter)) return new MatchAllQuery(); - var syntaxTree = SyntaxTree.Parse(filter, GridifyGlobalConfiguration.CustomOperators.Operators); + var syntaxTree = SyntaxTree.Parse(filter!, GridifyGlobalConfiguration.CustomOperators.Operators); if (syntaxTree.Diagnostics.Any()) throw new GridifyFilteringException(syntaxTree.Diagnostics.Last()); - mapper ??= BuildMapperWithNestedProperties(syntaxTree); + mapper ??= mapper.FixMapper(syntaxTree); - var queryExpression = ToElasticsearchConverter.GenerateQuery(syntaxTree.Root, mapper); + var queryExpression = new ElasticsearchQueryBuilder(mapper).Build(syntaxTree.Root); return queryExpression; } @@ -30,60 +28,7 @@ public static ICollection ToElasticsearchSortOptions(this string if (string.IsNullOrWhiteSpace(ordering)) return new List(); - var orderings = ordering.ParseOrderings().ToList(); - - mapper ??= BuildMapperForSorting(orderings); - - var sortOptions = ToElasticsearchConverter.GenerateSortOptions(orderings, mapper); + var sortOptions = new ElasticsearchSortOptionsBuilder(mapper).Build(ordering!); return sortOptions; } - - private static GridifyMapper BuildMapperWithNestedProperties(SyntaxTree syntaxTree) - { - var mapper = new GridifyMapper(); - foreach (var field in syntaxTree.Root.Descendants() - .Where(q => q.Kind == SyntaxKind.FieldExpression) - .Cast()) - try - { - mapper.AddMap(field.FieldToken.Text); - } - catch (Exception) - { - if (!mapper.Configuration.IgnoreNotMappedFields) - throw new GridifyMapperException($"Property '{field.FieldToken.Text}' not found."); - } - - return mapper; - } - - private static IEnumerable Descendants(this SyntaxNode root) - { - var nodes = new Stack(new[] { root }); - while (nodes.Any()) - { - var node = nodes.Pop(); - yield return node; - foreach (var n in node.GetChildren()) nodes.Push(n); - } - } - - private static GridifyMapper BuildMapperForSorting(List orderings) - { - var mapper = new GridifyMapper(); - foreach (var order in orderings) - { - try - { - mapper.AddMap(order.MemberName); - } - catch (Exception) - { - if (!mapper.Configuration.IgnoreNotMappedFields) - throw new GridifyMapperException($"Mapping '{order.MemberName}' not found"); - } - } - - return mapper; - } } diff --git a/src/Gridify/GridifyExtensions.cs b/src/Gridify/GridifyExtensions.cs index a3cd6918..dcaad13a 100644 --- a/src/Gridify/GridifyExtensions.cs +++ b/src/Gridify/GridifyExtensions.cs @@ -2,11 +2,9 @@ using System.Collections.Generic; using System.Linq; using System.Linq.Expressions; -using System.Runtime.CompilerServices; +using Gridify.QueryBuilders; using Gridify.Syntax; -[assembly: InternalsVisibleTo("Gridify.EntityFramework")] - namespace Gridify; public static partial class GridifyExtensions @@ -44,7 +42,7 @@ public static Expression> GetFilteringExpression(this IGridifyF throw new GridifyFilteringException(syntaxTree.Diagnostics.Last()!); mapper = mapper.FixMapper(syntaxTree); - var (queryExpression, _) = ExpressionToQueryConvertor.GenerateQuery(syntaxTree.Root, mapper); + var queryExpression = new LinqQueryBuilder(mapper).Build(syntaxTree.Root); if (queryExpression == null) throw new GridifyQueryException("Can not create expression with current data"); return queryExpression; } @@ -88,7 +86,7 @@ public static IEnumerable>> GetOrderingExpressions /// the member expression to apply /// the sort order to apply /// Query with sorting applied as IOrderedQueryable of type T - private static IOrderedQueryable OrderByMember( + internal static IOrderedQueryable OrderByMember( this IQueryable query, Expression> expression, bool isSortAsc) @@ -178,7 +176,7 @@ private static IGridifyMapper GetDefaultMapper() /// optional syntaxTree to Lazy mapping generation /// type to set mappings /// return back mapper or new generated mapper if it was null - private static IGridifyMapper FixMapper(this IGridifyMapper? mapper, SyntaxTree syntaxTree) + internal static IGridifyMapper FixMapper(this IGridifyMapper? mapper, SyntaxTree syntaxTree) { if (mapper != null) return mapper; @@ -247,7 +245,7 @@ public static IQueryable ApplyOrdering(this IQueryable query, IGridifyO if (gridifyOrdering == null) return query; return string.IsNullOrWhiteSpace(gridifyOrdering.OrderBy) ? query - : ProcessOrdering(query, gridifyOrdering.OrderBy!, startWithThenBy, mapper); + : new LinqSortingQueryBuilder(mapper).ProcessOrdering(query, gridifyOrdering.OrderBy!, startWithThenBy); } /// @@ -267,7 +265,7 @@ public static IQueryable ApplyOrdering(this IQueryable query, string or { return string.IsNullOrWhiteSpace(orderBy) ? query - : ProcessOrdering(query, orderBy, startWithThenBy, mapper); + : new LinqSortingQueryBuilder(mapper).ProcessOrdering(query, orderBy, startWithThenBy); } public static IQueryable ApplySelect(this IQueryable query, string props, IGridifyMapper? mapper = null) @@ -343,96 +341,6 @@ public static bool IsValid(this IGridifyOrdering ordering, IGridifyMapper? return true; } - internal static IQueryable ProcessOrdering(IQueryable query, string orderings, bool startWithThenBy, IGridifyMapper? mapper) - { - var isFirst = !startWithThenBy; - - var orders = ParseOrderings(orderings).ToList(); - - // build the mapper if it is null - if (mapper is null) - { - mapper = new GridifyMapper(); - foreach (var order in orders) - try - { - mapper.AddMap(order.MemberName); - } - catch (Exception) - { - if (!mapper.Configuration.IgnoreNotMappedFields) - throw new GridifyMapperException($"Mapping '{order.MemberName}' not found"); - } - } - - foreach (var order in orders) - { - if (!mapper.HasMap(order.MemberName)) - { - // skip if there is no mappings available - if (mapper.Configuration.IgnoreNotMappedFields) - continue; - - throw new GridifyMapperException($"Mapping '{order.MemberName}' not found"); - } - - if (isFirst) - { - query = query.OrderByMember(GetOrderExpression(order, mapper), order.IsAscending); - isFirst = false; - } - else - { - query = query.ThenByMember(GetOrderExpression(order, mapper), order.IsAscending); - } - } - - return query; - } - - internal static Expression> GetOrderExpression(this ParsedOrdering order, IGridifyMapper mapper) - { - var exp = mapper.GetExpression(order.MemberName); - switch (order.OrderingType) - { - case OrderingType.Normal: - return exp; - case OrderingType.NullCheck: - case OrderingType.NotNullCheck: - default: - { - // member should be nullable - if (exp.Body is not UnaryExpression unary || Nullable.GetUnderlyingType(unary.Operand.Type) == null) - { - throw new GridifyOrderingException($"'{order.MemberName}' is not nullable type"); - } - - var prop = Expression.Property(exp.Parameters[0], order.MemberName); - var hasValue = Expression.PropertyOrField(prop, "HasValue"); - - switch (order.OrderingType) - { - case OrderingType.NullCheck: - { - var boxedExpression = Expression.Convert(hasValue, typeof(object)); - return Expression.Lambda>(boxedExpression, exp.Parameters); - } - case OrderingType.NotNullCheck: - { - var notHasValue = Expression.Not(hasValue); - var boxedExpression = Expression.Convert(notHasValue, typeof(object)); - return Expression.Lambda>(boxedExpression, exp.Parameters); - } - // should never reach here - case OrderingType.Normal: - return exp; - default: - throw new ArgumentOutOfRangeException(); - } - } - } - } - internal static string ReplaceAll(this string seed, IEnumerable chars, char replacementCharacter) { return chars.Aggregate(seed, (str, cItem) => str.Replace(cItem, replacementCharacter)); @@ -502,7 +410,7 @@ public static IQueryable ApplyOrdering(this IQueryable query, IGr ? query.OrderBy(groupOrder) : query.OrderByDescending(groupOrder); - query = ProcessOrdering(query, gridifyOrdering.OrderBy!, true, mapper); + query = new LinqSortingQueryBuilder(mapper).ProcessOrdering(query, gridifyOrdering.OrderBy!, true); return query; } @@ -543,8 +451,7 @@ public static IQueryable ApplyFiltering(this IQueryable query, string? mapper = mapper.FixMapper(syntaxTree); - var (queryExpression, _) = ExpressionToQueryConvertor.GenerateQuery(syntaxTree.Root, mapper); - + var queryExpression = new LinqQueryBuilder(mapper).Build(syntaxTree.Root); query = query.Where(queryExpression); return query; @@ -561,7 +468,7 @@ public static IQueryable ApplyFilteringAndOrdering(this IQueryable quer public static Expression> CreateQuery(this SyntaxTree syntaxTree, IGridifyMapper? mapper = null) { mapper = mapper.FixMapper(syntaxTree); - var exp = ExpressionToQueryConvertor.GenerateQuery(syntaxTree.Root, mapper).Expression; + var exp = new LinqQueryBuilder(mapper).Build(syntaxTree.Root); if (exp == null) throw new GridifyQueryException("Invalid SyntaxTree."); return exp; } diff --git a/src/Gridify/QueryBuilders/BaseQueryBuilder.cs b/src/Gridify/QueryBuilders/BaseQueryBuilder.cs new file mode 100644 index 00000000..e143dee9 --- /dev/null +++ b/src/Gridify/QueryBuilders/BaseQueryBuilder.cs @@ -0,0 +1,215 @@ +using System; +using System.ComponentModel; +using System.Linq.Expressions; +using System.Reflection; +using Gridify.Syntax; + +namespace Gridify.QueryBuilders; + +internal abstract class BaseQueryBuilder + where TQuery : class +{ + protected readonly IGridifyMapper mapper; + + protected BaseQueryBuilder(IGridifyMapper mapper) + { + this.mapper = mapper; + } + + internal TQuery Build(ExpressionSyntax expression) + { + var (query, _) = BuildQuery(expression); + return query; + } + + protected abstract TQuery? BuildNestedQuery( + Expression body, IGMap gMap, ValueExpressionSyntax value, SyntaxNode op); + + protected abstract TQuery BuildAlwaysTrueQuery(); + + protected abstract TQuery BuildAlwaysFalseQuery(ParameterExpression parameter); + + protected abstract TQuery? CheckIfCanMergeQueries( + (TQuery query, bool isNested) leftQuery, + (TQuery query, bool isNested) rightQuery, + SyntaxKind op); + + protected abstract object? BuildQueryAccordingToValueType( + Expression body, + ParameterExpression parameter, + object? value, + SyntaxNode op, + ValueExpressionSyntax valueExpression, + bool isConvertable); + + protected abstract TQuery CombineWithAndOperator(TQuery left, TQuery right); + + protected abstract TQuery CombineWithOrOperator(TQuery left, TQuery right); + + private (TQuery Query, bool IsNested) BuildQuery(ExpressionSyntax expression, bool isParenthesisOpen = false) + { + while (true) + switch (expression.Kind) + { + case SyntaxKind.BinaryExpression: + { + var bExp = expression as BinaryExpressionSyntax; + + if (bExp!.Left is FieldExpressionSyntax && bExp.Right is ValueExpressionSyntax) + { + try + { + return ConvertBinaryExpressionSyntaxToQuery(bExp) + ?? throw new GridifyFilteringException("Invalid expression"); + } + catch (GridifyMapperException) + { + if (mapper.Configuration.IgnoreNotMappedFields) + return (BuildAlwaysTrueQuery(), false); + + throw; + } + } + + (TQuery query, bool isNested) leftQuery; + (TQuery query, bool isNested) rightQuery; + + if (bExp.Left is ParenthesizedExpressionSyntax lpExp) + { + leftQuery = BuildQuery(lpExp.Expression, true); + } + else + leftQuery = BuildQuery(bExp.Left); + + + if (bExp.Right is ParenthesizedExpressionSyntax rpExp) + rightQuery = BuildQuery(rpExp.Expression, true); + else + rightQuery = BuildQuery(bExp.Right); + + // check for nested collections + if (isParenthesisOpen && + CheckIfCanMergeQueries(leftQuery, rightQuery, bExp.OperatorToken.Kind) is { } mergedResult) + return (mergedResult, true); + + var result = bExp.OperatorToken.Kind switch + { + SyntaxKind.And => CombineWithAndOperator(leftQuery.query, rightQuery.query), + SyntaxKind.Or => CombineWithOrOperator(leftQuery.query, rightQuery.query), + _ => throw new GridifyFilteringException($"Invalid expression Operator '{bExp.OperatorToken.Kind}'") + }; + return (result, false); + } + case SyntaxKind.ParenthesizedExpression: // first entrypoint only + { + var pExp = expression as ParenthesizedExpressionSyntax; + return BuildQuery(pExp!.Expression, true); + } + default: + throw new GridifyFilteringException($"Invalid expression format '{expression.Kind}'."); + } + } + + private (TQuery, bool IsNested)? ConvertBinaryExpressionSyntaxToQuery(BinaryExpressionSyntax binarySyntax) + { + var fieldExpression = binarySyntax.Left as FieldExpressionSyntax; + + var left = fieldExpression?.FieldToken.Text.Trim(); + var right = (binarySyntax.Right as ValueExpressionSyntax); + var op = binarySyntax.OperatorToken; + + if (left == null || right == null) return null; + + var gMap = mapper.GetGMap(left); + + if (gMap == null) throw new GridifyMapperException($"Mapping '{left}' not found"); + + if (fieldExpression!.IsCollection) + gMap.To = UpdateExpressionIndex(gMap.To, fieldExpression.Index); + + var isNested = ((GMap)gMap).IsNestedCollection(); + if (isNested) + { + var result = BuildNestedQuery(gMap.To.Body, gMap, right, op); + if (result == null) return null; + return (result, isNested); + } + + var query = BuildQuery(gMap.To.Body, gMap.To.Parameters[0], right, op, gMap.Convertor) as TQuery; + if (query == null) return null; + return (query, false); + } + + protected object? BuildQuery( + Expression body, + ParameterExpression parameter, + ValueExpressionSyntax valueExpression, + SyntaxNode op, + Func? convertor) + { + // Remove the boxing for value types + if (body.NodeType == ExpressionType.Convert) body = ((UnaryExpression)body).Operand; + + object? value = valueExpression.ValueToken.Text; + + // execute user custom Convertor + if (convertor != null) + value = convertor.Invoke(valueExpression.ValueToken.Text); + + // handle the `null` keyword in value + if (mapper.Configuration.AllowNullSearch && op.Kind is SyntaxKind.Equal or SyntaxKind.NotEqual && value.ToString() == "null") + value = null; + + var isConvertable = true; + + // type fixer + if (value is not null && body.Type != value.GetType()) + { + // handle bool, github issue #71 + if (body.Type == typeof(bool) && value is "true" or "false" or "1" or "0") + value = ((string)value).ToLower() is "1" or "true"; + // handle broken guids, github issue #2 + else if (body.Type == typeof(Guid) && !Guid.TryParse(value.ToString(), out _)) value = Guid.NewGuid().ToString(); + + var converter = TypeDescriptor.GetConverter(body.Type); + isConvertable = converter.CanConvertFrom(typeof(string)); + if (isConvertable) + { + try + { + value = converter.ConvertFromString(value.ToString()!); + } + catch (ArgumentException) + { + isConvertable = false; + } + catch (FormatException) + { + return BuildAlwaysFalseQuery(parameter); + } + } + } + + // handle case-Insensitive search + if (value is not null && valueExpression.IsCaseInsensitive + && op.Kind is not SyntaxKind.GreaterThan + && op.Kind is not SyntaxKind.LessThan + && op.Kind is not SyntaxKind.GreaterOrEqualThan + && op.Kind is not SyntaxKind.LessOrEqualThan) + { + value = value.ToString()?.ToLower(); + body = Expression.Call(body, GetToLowerMethod()); + } + + var query = BuildQueryAccordingToValueType(body, parameter, value, op, valueExpression, isConvertable); + return query; + } + + private static LambdaExpression UpdateExpressionIndex(LambdaExpression exp, int index) + { + var body = new ReplaceExpressionVisitor(exp.Parameters[1], Expression.Constant(index, typeof(int))).Visit(exp.Body); + return Expression.Lambda(body, exp.Parameters); + } + + private static MethodInfo GetToLowerMethod() => typeof(string).GetMethod("ToLower", new Type[] { })!; +} diff --git a/src/Gridify/QueryBuilders/BaseSortingQueryBuilder.cs b/src/Gridify/QueryBuilders/BaseSortingQueryBuilder.cs new file mode 100644 index 00000000..5e696fce --- /dev/null +++ b/src/Gridify/QueryBuilders/BaseSortingQueryBuilder.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Gridify.Syntax; + +namespace Gridify.QueryBuilders; + +internal abstract class BaseSortingQueryBuilder +{ + protected IGridifyMapper? mapper; + + protected BaseSortingQueryBuilder(IGridifyMapper? mapper = null) + { + this.mapper = mapper; + } + + protected abstract TSortingQuery ApplySorting(TSortingQuery query, ParsedOrdering ordering); + + protected abstract TSortingQuery ApplyAnotherSorting(TSortingQuery query, ParsedOrdering ordering); + + internal TSortingQuery ProcessOrdering(TSortingQuery query, string orderings, bool startWithThenBy) + { + var isFirst = !startWithThenBy; + var orders = orderings.ParseOrderings().ToList(); + mapper ??= BuildMapper(orders); + + foreach (var order in orders) + { + if (!mapper.HasMap(order.MemberName)) + { + // skip if there is no mappings available + if (mapper.Configuration.IgnoreNotMappedFields) + continue; + + throw new GridifyMapperException($"Mapping '{order.MemberName}' not found"); + } + + if (isFirst) + { + query = ApplySorting(query, order); + isFirst = false; + } + else + { + query = ApplyAnotherSorting(query, order); + } + } + + return query; + } + + private static GridifyMapper BuildMapper(List orderings) + { + var mapper = new GridifyMapper(); + foreach (var order in orderings) + { + try + { + mapper.AddMap(order.MemberName); + } + catch (Exception) + { + if (!mapper.Configuration.IgnoreNotMappedFields) + throw new GridifyMapperException($"Mapping '{order.MemberName}' not found"); + } + } + + return mapper; + } +} diff --git a/src/Gridify/Syntax/SyntaxTreeToQueryConvertor.cs b/src/Gridify/QueryBuilders/LinqQueryBuilder.cs similarity index 60% rename from src/Gridify/Syntax/SyntaxTreeToQueryConvertor.cs rename to src/Gridify/QueryBuilders/LinqQueryBuilder.cs index a574b958..74b96db7 100644 --- a/src/Gridify/Syntax/SyntaxTreeToQueryConvertor.cs +++ b/src/Gridify/QueryBuilders/LinqQueryBuilder.cs @@ -1,76 +1,45 @@ -using System; -using System.ComponentModel; +using System; using System.Data; using System.Linq; using System.Linq.Expressions; using System.Reflection; using System.Runtime.InteropServices; +using Gridify.Syntax; -namespace Gridify.Syntax; +namespace Gridify.QueryBuilders; -internal static class ExpressionToQueryConvertor +internal class LinqQueryBuilder : BaseQueryBuilder>, T> { - private static (Expression> Expression, bool IsNested)? ConvertBinaryExpressionSyntaxToQuery( - BinaryExpressionSyntax binarySyntax, IGridifyMapper mapper) + public LinqQueryBuilder(IGridifyMapper mapper) : base(mapper) { - var fieldExpression = binarySyntax.Left as FieldExpressionSyntax; - - var left = fieldExpression?.FieldToken.Text.Trim(); - var right = (binarySyntax.Right as ValueExpressionSyntax); - var op = binarySyntax.OperatorToken; - - if (left == null || right == null) return null; - - var gMap = mapper.GetGMap(left); - - if (gMap == null) throw new GridifyMapperException($"Mapping '{left}' not found"); - - if (fieldExpression!.IsCollection) - gMap.To = UpdateExpressionIndex(gMap.To, fieldExpression.Index); - - var isNested = ((GMap)gMap).IsNestedCollection(); - if (isNested) - { - var result = GenerateNestedExpression(gMap.To.Body, mapper, gMap, right, op); - if (result == null) return null; - return (result, isNested); - } - else - { - if (GenerateExpression(gMap.To.Body, gMap.To.Parameters[0], right, - op, mapper.Configuration.AllowNullSearch, gMap.Convertor) is not Expression> result) return null; - return (result, false); - } - } - - private static LambdaExpression UpdateExpressionIndex(LambdaExpression exp, int index) - { - var body = new ReplaceExpressionVisitor(exp.Parameters[1], Expression.Constant(index, typeof(int))).Visit(exp.Body); - return Expression.Lambda(body, exp.Parameters); } - private static Expression>? GenerateNestedExpression(Expression body, IGridifyMapper mapper, IGMap gMap, - ValueExpressionSyntax value, SyntaxNode op) + protected override Expression>? BuildNestedQuery( + Expression body, IGMap gMap, ValueExpressionSyntax value, SyntaxNode op) { while (true) { switch (body) { - case MethodCallExpression selectExp when selectExp.Method.Name == "Select": + case MethodCallExpression { Method.Name: "Select" } selectExp: { var targetExp = selectExp.Arguments.Single(a => a.NodeType == ExpressionType.Lambda) as LambdaExpression; - var conditionExp = GenerateExpression(targetExp!.Body, targetExp.Parameters[0], value, op, mapper.Configuration.AllowNullSearch, + var conditionExp = BuildQuery( + targetExp!.Body, + targetExp.Parameters[0], + value, + op, gMap.Convertor); - if (conditionExp == null) return null; + if (conditionExp is not LambdaExpression lambdaExp) return null; - return ParseMethodCallExpression(selectExp, conditionExp) as Expression>; + return ParseMethodCallExpression(selectExp, lambdaExp) as Expression>; } case ConditionalExpression cExp: { - var ifTrue = GenerateNestedExpression(cExp.IfTrue, mapper, gMap, value, op); + var ifTrue = GenerateNestedExpression(cExp.IfTrue, gMap, value, op); ifTrue = new ReplaceExpressionVisitor(ifTrue!.Parameters[0], gMap.To.Parameters[0]).Visit(ifTrue) as Expression>; - var ifFalse = GenerateNestedExpression(cExp.IfFalse, mapper, gMap, value, op); + var ifFalse = GenerateNestedExpression(cExp.IfFalse, gMap, value, op); ifFalse = new ReplaceExpressionVisitor(ifFalse!.Parameters[0], gMap.To.Parameters[0]).Visit(ifFalse) as Expression>; var newExp = Expression.Condition(cExp.Test, ifTrue!.Body, ifFalse!.Body); @@ -92,146 +61,63 @@ private static LambdaExpression UpdateExpressionIndex(LambdaExpression exp, int } } - - private static LambdaExpression ParseMethodCallExpression(MethodCallExpression exp, LambdaExpression predicate) + protected override Expression> BuildAlwaysTrueQuery() { - switch (exp.Arguments.First()) - { - case MemberExpression member: - return GetAnyExpression(member, predicate); - case MethodCallExpression subExp when subExp.Method.Name == "SelectMany" && - subExp.Arguments.Last() is LambdaExpression { Body: MemberExpression lambdaMember }: - { - var newPredicate = GetAnyExpression(lambdaMember, predicate); - return ParseMethodCallExpression(subExp, newPredicate); - } - case MethodCallExpression subExp when subExp.Method.Name == "Select" && subExp.Arguments.Last() is LambdaExpression - { - Body: MemberExpression lambdaMember - } lambda: - { - var newExp = new ReplaceExpressionVisitor(predicate.Parameters[0], lambdaMember).Visit(predicate.Body); - var newPredicate = GetExpressionWithNullCheck(lambdaMember, lambda.Parameters[0], newExp); - return ParseMethodCallExpression(subExp, newPredicate); - } - default: - throw new InvalidOperationException(); - } + return _ => true; } - private static ParameterExpression GetParameterExpression(MemberExpression member) + protected override Expression> BuildAlwaysFalseQuery(ParameterExpression parameter) { - return member.Expression switch - { - ParameterExpression => Expression.Parameter(member.Expression!.Type, member.Expression.ToString()), - MemberExpression subExp => GetParameterExpression(subExp), - _ => throw new InvalidOperationException($"Invalid expression '{member.Expression}'") - }; + var expression = Expression.Lambda(Expression.Constant(false), parameter) as Expression>; + return expression!; } - private static MemberExpression GetPropertyOrField(MemberExpression member, ParameterExpression param) + protected override Expression>? CheckIfCanMergeQueries( + (Expression> query, bool isNested) leftQuery, + (Expression> query, bool isNested) rightQuery, + SyntaxKind op) { - return member.Expression switch + if (leftQuery.isNested && rightQuery.isNested) { - ParameterExpression => Expression.PropertyOrField(param, member.Member.Name), - MemberExpression subExp => Expression.PropertyOrField(GetPropertyOrField(subExp, param), member.Member.Name), - _ => throw new InvalidOperationException($"Invalid expression '{member.Expression}'") - }; - } - - private static LambdaExpression GetAnyExpression(MemberExpression member, Expression predicate) - { - var param = GetParameterExpression(member); - var prop = GetPropertyOrField(member, param); - - var tp = prop.Type.IsGenericType - ? prop.Type.GenericTypeArguments.First() // list - : prop.Type.GetElementType(); // array + var leftExp = ParseNestedExpression(leftQuery.query.Body); + var rightExp = ParseNestedExpression(rightQuery.query.Body); - if (tp == null) throw new GridifyFilteringException($"Can not detect the '{member.Member.Name}' property type."); + if (leftExp.Arguments.First() is MemberExpression leftMember && + rightExp.Arguments.First() is MemberExpression rightMember && + leftMember.Type == rightMember.Type) + { + if (leftExp.Arguments.Last() is not LambdaExpression leftLambda + || rightExp.Arguments.Last() is not LambdaExpression rightLambda) + { + return null; + } - var anyMethod = GetAnyMethod(tp); - var anyExp = Expression.Call(anyMethod, prop, predicate); + var visitedRight = new ReplaceExpressionVisitor(rightLambda.Parameters[0], leftLambda.Parameters[0]) + .Visit(rightLambda.Body); - return GetExpressionWithNullCheck(prop, param, anyExp); - } + var mergedExpression = op switch + { + SyntaxKind.And => Expression.AndAlso(leftLambda.Body, visitedRight), + SyntaxKind.Or => Expression.OrElse(leftLambda.Body, visitedRight), + _ => throw new InvalidOperationException() + }; - private static LambdaExpression GetExpressionWithNullCheck(MemberExpression prop, ParameterExpression param, Expression right) - { - // This check is needed for EF6 - It doesn't support NullChecking for Collections (issue #58) - // also issue #70 for NHibernate - if (GridifyGlobalConfiguration.DisableNullChecks || - GridifyGlobalConfiguration.EntityFrameworkCompatibilityLayer && - RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework")) - return Expression.Lambda(right, param); + var mergedLambda = Expression.Lambda(mergedExpression, leftLambda.Parameters); + return GetAnyExpression(leftMember, mergedLambda) as Expression>; + } + } - var nullChecker = Expression.NotEqual(prop, Expression.Constant(null)); - var exp = Expression.AndAlso(nullChecker, right); - return Expression.Lambda(exp, param); + return null; } - private static LambdaExpression? GenerateExpression( + protected override object? BuildQueryAccordingToValueType( Expression body, ParameterExpression parameter, - ValueExpressionSyntax valueExpression, + object? value, SyntaxNode op, - bool allowNullSearch, - Func? convertor) + ValueExpressionSyntax valueExpression, + bool isConvertable) { - // Remove the boxing for value types - if (body.NodeType == ExpressionType.Convert) body = ((UnaryExpression)body).Operand; - - object? value = valueExpression.ValueToken.Text; - - // execute user custom Convertor - if (convertor != null) - value = convertor.Invoke(valueExpression.ValueToken.Text); - - // handle the `null` keyword in value - if (allowNullSearch && op.Kind is SyntaxKind.Equal or SyntaxKind.NotEqual && value.ToString() == "null") - value = null; - - var isConvertable = true; - - // type fixer - if (value is not null && body.Type != value.GetType()) - { - // handle bool, github issue #71 - if (body.Type == typeof(bool) && value is "true" or "false" or "1" or "0") - value = ((string)value).ToLower() is "1" or "true"; - // handle broken guids, github issue #2 - else if (body.Type == typeof(Guid) && !Guid.TryParse(value.ToString(), out _)) value = Guid.NewGuid().ToString(); - - var converter = TypeDescriptor.GetConverter(body.Type); - isConvertable = converter.CanConvertFrom(typeof(string)); - if (isConvertable) - { - try - { - value = converter.ConvertFromString(value.ToString()!); - } - catch (ArgumentException) - { - isConvertable = false; - } - catch (FormatException) - { - return Expression.Lambda(Expression.Constant(false), parameter); // q => false - } - } - } - - // handle case-Insensitive search - if (value is not null && valueExpression.IsCaseInsensitive - && op.Kind is not SyntaxKind.GreaterThan - && op.Kind is not SyntaxKind.LessThan - && op.Kind is not SyntaxKind.GreaterOrEqualThan - && op.Kind is not SyntaxKind.LessOrEqualThan) - { - value = value.ToString()?.ToLower(); - body = Expression.Call(body, GetToLowerMethod()); - } - Expression be; // use string.Compare instead of operators if value and field are both strings @@ -361,7 +247,137 @@ private static LambdaExpression GetExpressionWithNullCheck(MemberExpression prop return Expression.Lambda(be, parameter); } - private static Expression GetValueExpression(Type type, object? value) + protected override Expression> CombineWithAndOperator(Expression> left, Expression> right) + { + return left.And(right); + } + + protected override Expression> CombineWithOrOperator(Expression> left, Expression> right) + { + return left.Or(right); + } + + private Expression>? GenerateNestedExpression(Expression body, IGMap gMap, ValueExpressionSyntax value, SyntaxNode op) + { + while (true) + { + switch (body) + { + case MethodCallExpression { Method.Name: "Select" } selectExp: + { + var targetExp = selectExp.Arguments.Single(a => a.NodeType == ExpressionType.Lambda) as LambdaExpression; + var conditionExp = BuildQuery(targetExp!.Body, targetExp.Parameters[0], value, op, gMap.Convertor); + + if (conditionExp is not LambdaExpression lambdaExp) return null; + + return ParseMethodCallExpression(selectExp, lambdaExp) as Expression>; + } + case ConditionalExpression cExp: + { + var ifTrue = GenerateNestedExpression(cExp.IfTrue, gMap, value, op); + ifTrue = new ReplaceExpressionVisitor(ifTrue!.Parameters[0], gMap.To.Parameters[0]).Visit(ifTrue) as Expression>; + var ifFalse = GenerateNestedExpression(cExp.IfFalse, gMap, value, op); + ifFalse = new ReplaceExpressionVisitor(ifFalse!.Parameters[0], gMap.To.Parameters[0]).Visit(ifFalse) as Expression>; + + var newExp = Expression.Condition(cExp.Test, ifTrue!.Body, ifFalse!.Body); + return Expression.Lambda>(newExp, gMap.To.Parameters[0]); + } + case ConstantExpression constantExpression: + { + return Expression.Lambda>(constantExpression, gMap.To.Parameters[0]); + } + case UnaryExpression uExp: + { + body = uExp.Operand; + continue; + } + default: + // this should never happening + throw new GridifyFilteringException($"The 'Select' method on '{gMap.From}' for type {body.Type} not found"); + } + } + } + + private LambdaExpression ParseMethodCallExpression(MethodCallExpression exp, LambdaExpression predicate) + { + switch (exp.Arguments.First()) + { + case MemberExpression member: + return GetAnyExpression(member, predicate); + case MethodCallExpression { Method.Name: "SelectMany" } subExp + when subExp.Arguments.Last() + is LambdaExpression { Body: MemberExpression lambdaMember }: + { + var newPredicate = GetAnyExpression(lambdaMember, predicate); + return ParseMethodCallExpression(subExp, newPredicate); + } + case MethodCallExpression { Method.Name: "Select" } subExp + when subExp.Arguments.Last() is LambdaExpression + { + Body: MemberExpression lambdaMember + } lambda: + { + var newExp = new ReplaceExpressionVisitor(predicate.Parameters[0], lambdaMember).Visit(predicate.Body); + var newPredicate = GetExpressionWithNullCheck(lambdaMember, lambda.Parameters[0], newExp); + return ParseMethodCallExpression(subExp, newPredicate); + } + default: + throw new InvalidOperationException(); + } + } + + private ParameterExpression GetParameterExpression(MemberExpression member) + { + return member.Expression switch + { + ParameterExpression => Expression.Parameter(member.Expression!.Type, member.Expression.ToString()), + MemberExpression subExp => GetParameterExpression(subExp), + _ => throw new InvalidOperationException($"Invalid expression '{member.Expression}'") + }; + } + + private MemberExpression GetPropertyOrField(MemberExpression member, ParameterExpression param) + { + return member.Expression switch + { + ParameterExpression => Expression.PropertyOrField(param, member.Member.Name), + MemberExpression subExp => Expression.PropertyOrField(GetPropertyOrField(subExp, param), member.Member.Name), + _ => throw new InvalidOperationException($"Invalid expression '{member.Expression}'") + }; + } + + private LambdaExpression GetAnyExpression(MemberExpression member, Expression predicate) + { + var param = GetParameterExpression(member); + var prop = GetPropertyOrField(member, param); + + var tp = prop.Type.IsGenericType + ? prop.Type.GenericTypeArguments.First() // list + : prop.Type.GetElementType(); // array + + if (tp == null) throw new GridifyFilteringException($"Can not detect the '{member.Member.Name}' property type."); + + var anyMethod = GetAnyMethod(tp); + var anyExp = Expression.Call(anyMethod, prop, predicate); + + return GetExpressionWithNullCheck(prop, param, anyExp); + } + + private LambdaExpression GetExpressionWithNullCheck(MemberExpression prop, ParameterExpression param, Expression right) + { + // This check is needed for EF6 - It doesn't support NullChecking for Collections (issue #58) + // also issue #70 for NHibernate + if (GridifyGlobalConfiguration.DisableNullChecks || + GridifyGlobalConfiguration.EntityFrameworkCompatibilityLayer && + RuntimeInformation.FrameworkDescription.StartsWith(".NET Framework")) + return Expression.Lambda(right, param); + + var nullChecker = Expression.NotEqual(prop, Expression.Constant(null)); + var exp = Expression.AndAlso(nullChecker, right); + return Expression.Lambda(exp, param); + } + + private Expression GetValueExpression(Type type, object? value) { if (!GridifyGlobalConfiguration.EntityFrameworkCompatibilityLayer) return Expression.Constant(value, type); @@ -372,7 +388,7 @@ private static Expression GetValueExpression(Type type, object? value) return Expression.PropertyOrField(Expression.Constant(instance, type1), fieldName); } - private static BinaryExpression GetLessThanOrEqualExpression(Expression body, ValueExpressionSyntax valueExpression, object? value) + private BinaryExpression GetLessThanOrEqualExpression(Expression body, ValueExpressionSyntax valueExpression, object? value) { if (GridifyGlobalConfiguration.EntityFrameworkCompatibilityLayer) return Expression.LessThanOrEqual(Expression.Call(null, GetCompareMethod(), body, GetValueExpression(typeof(string), value)), @@ -383,7 +399,7 @@ private static BinaryExpression GetLessThanOrEqualExpression(Expression body, Va GetStringComparisonCaseExpression(valueExpression.IsCaseInsensitive)), Expression.Constant(0)); } - private static BinaryExpression GetGreaterThanOrEqualExpression(Expression body, ValueExpressionSyntax valueExpression, object? value) + private BinaryExpression GetGreaterThanOrEqualExpression(Expression body, ValueExpressionSyntax valueExpression, object? value) { if (GridifyGlobalConfiguration.EntityFrameworkCompatibilityLayer) return Expression.GreaterThanOrEqual(Expression.Call(null, GetCompareMethod(), body, GetValueExpression(typeof(string), value)), @@ -394,7 +410,7 @@ private static BinaryExpression GetGreaterThanOrEqualExpression(Expression body, GetStringComparisonCaseExpression(valueExpression.IsCaseInsensitive)), Expression.Constant(0)); } - private static BinaryExpression GetLessThanExpression(Expression body, ValueExpressionSyntax valueExpression, object? value) + private BinaryExpression GetLessThanExpression(Expression body, ValueExpressionSyntax valueExpression, object? value) { if (GridifyGlobalConfiguration.EntityFrameworkCompatibilityLayer) return Expression.LessThan(Expression.Call(null, GetCompareMethod(), body, GetValueExpression(typeof(string), value)), @@ -405,7 +421,7 @@ private static BinaryExpression GetLessThanExpression(Expression body, ValueExpr GetStringComparisonCaseExpression(valueExpression.IsCaseInsensitive)), Expression.Constant(0)); } - private static BinaryExpression GetGreaterThanExpression(Expression body, ValueExpressionSyntax valueExpression, object? value) + private BinaryExpression GetGreaterThanExpression(Expression body, ValueExpressionSyntax valueExpression, object? value) { if (GridifyGlobalConfiguration.EntityFrameworkCompatibilityLayer) return Expression.GreaterThan(Expression.Call(null, GetCompareMethod(), body, GetValueExpression(typeof(string), value)), @@ -416,136 +432,33 @@ private static BinaryExpression GetGreaterThanExpression(Expression body, ValueE GetStringComparisonCaseExpression(valueExpression.IsCaseInsensitive)), Expression.Constant(0)); } - private static ConstantExpression GetStringComparisonCaseExpression(bool isCaseInsensitive) + private ConstantExpression GetStringComparisonCaseExpression(bool isCaseInsensitive) { return isCaseInsensitive ? Expression.Constant(StringComparison.OrdinalIgnoreCase) : Expression.Constant(StringComparison.Ordinal); } - private static MethodInfo GetAnyMethod(Type @type) => + private MethodInfo GetAnyMethod(Type @type) => typeof(Enumerable).GetMethods().Single(m => m.Name == "Any" && m.GetParameters().Length == 2).MakeGenericMethod(@type); - private static MethodInfo GetEndsWithMethod() => typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!; + private MethodInfo GetEndsWithMethod() => typeof(string).GetMethod("EndsWith", new[] { typeof(string) })!; + + private MethodInfo GetStartWithMethod() => typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!; - private static MethodInfo GetStartWithMethod() => typeof(string).GetMethod("StartsWith", new[] { typeof(string) })!; + private MethodInfo GetContainsMethod() => typeof(string).GetMethod("Contains", new[] { typeof(string) })!; - private static MethodInfo GetContainsMethod() => typeof(string).GetMethod("Contains", new[] { typeof(string) })!; - private static MethodInfo GetIsNullOrEmptyMethod() => typeof(string).GetMethod("IsNullOrEmpty", new[] { typeof(string) })!; - private static MethodInfo GetToLowerMethod() => typeof(string).GetMethod("ToLower", new Type[] { })!; + private MethodInfo GetIsNullOrEmptyMethod() => typeof(string).GetMethod("IsNullOrEmpty", new[] { typeof(string) })!; - private static MethodInfo GetCompareMethodWithStringComparison() => + private MethodInfo GetCompareMethodWithStringComparison() => typeof(string).GetMethod("Compare", new[] { typeof(string), typeof(string), typeof(StringComparison) })!; - private static MethodInfo GetCompareMethod() => + private MethodInfo GetCompareMethod() => typeof(string).GetMethod("Compare", new[] { typeof(string), typeof(string) })!; - private static MethodInfo GetToStringMethod() => typeof(object).GetMethod("ToString")!; - - - internal static (Expression> Expression, bool IsNested) - GenerateQuery(ExpressionSyntax expression, IGridifyMapper mapper, bool isParenthesisOpen = false) - { - while (true) - switch (expression.Kind) - { - case SyntaxKind.BinaryExpression: - { - var bExp = expression as BinaryExpressionSyntax; - - if (bExp!.Left is FieldExpressionSyntax && bExp.Right is ValueExpressionSyntax) - { - try - { - return ConvertBinaryExpressionSyntaxToQuery(bExp, mapper) ?? throw new GridifyFilteringException("Invalid expression"); - } - catch (GridifyMapperException) - { - if (mapper.Configuration.IgnoreNotMappedFields) - return (_ => true, false); - - throw; - } - } - - (Expression> exp, bool isNested) leftQuery; - (Expression> exp, bool isNested) rightQuery; - - if (bExp.Left is ParenthesizedExpressionSyntax lpExp) - { - leftQuery = GenerateQuery(lpExp.Expression, mapper, true); - } - else - leftQuery = GenerateQuery(bExp.Left, mapper); - - - if (bExp.Right is ParenthesizedExpressionSyntax rpExp) - rightQuery = GenerateQuery(rpExp.Expression, mapper, true); - else - rightQuery = GenerateQuery(bExp.Right, mapper); - - // check for nested collections - if (isParenthesisOpen && - CheckIfCanMerge(leftQuery, rightQuery, bExp.OperatorToken.Kind) is Expression> mergedResult) - return (mergedResult, true); - - var result = bExp.OperatorToken.Kind switch - { - SyntaxKind.And => leftQuery.exp.And(rightQuery.exp), - SyntaxKind.Or => leftQuery.exp.Or(rightQuery.exp), - _ => throw new GridifyFilteringException($"Invalid expression Operator '{bExp.OperatorToken.Kind}'") - }; - return (result, false); - } - case SyntaxKind.ParenthesizedExpression: // first entrypoint only - { - var pExp = expression as ParenthesizedExpressionSyntax; - return GenerateQuery(pExp!.Expression, mapper, true); - } - default: - throw new GridifyFilteringException($"Invalid expression format '{expression.Kind}'."); - } - } - - private static LambdaExpression? CheckIfCanMerge((Expression> exp, bool isNested) leftQuery, - (Expression> exp, bool isNested) rightQuery, SyntaxKind op) - { - if (leftQuery.isNested && rightQuery.isNested) - { - var leftExp = ParseNestedExpression(leftQuery.exp.Body); - var rightExp = ParseNestedExpression(rightQuery.exp.Body); - - if (leftExp.Arguments.First() is MemberExpression leftMember && - rightExp.Arguments.First() is MemberExpression rightMember && - leftMember.Type == rightMember.Type) - { - // we can merge - var leftLambda = leftExp.Arguments.Last() as LambdaExpression; - var rightLambda = rightExp.Arguments.Last() as LambdaExpression; - - if (leftLambda is null || rightLambda is null) - return null; - - var visitedRight = new ReplaceExpressionVisitor(rightLambda.Parameters[0], leftLambda.Parameters[0]) - .Visit(rightLambda.Body); - - var mergedExpression = op switch - { - SyntaxKind.And => Expression.AndAlso(leftLambda.Body, visitedRight), - SyntaxKind.Or => Expression.OrElse(leftLambda.Body, visitedRight), - _ => throw new InvalidOperationException() - }; - - var mergedLambda = Expression.Lambda(mergedExpression, leftLambda.Parameters); - var newLambda = GetAnyExpression(leftMember, mergedLambda) as Expression>; - return newLambda; - } - } - - return null; - } + private MethodInfo GetToStringMethod() => typeof(object).GetMethod("ToString")!; - private static MethodCallExpression ParseNestedExpression(Expression exp) + private MethodCallExpression ParseNestedExpression(Expression exp) { return exp switch { diff --git a/src/Gridify/QueryBuilders/LinqSortingQueryBuilder.cs b/src/Gridify/QueryBuilders/LinqSortingQueryBuilder.cs new file mode 100644 index 00000000..4a8e124d --- /dev/null +++ b/src/Gridify/QueryBuilders/LinqSortingQueryBuilder.cs @@ -0,0 +1,66 @@ +using System; +using System.Linq; +using System.Linq.Expressions; +using Gridify.Syntax; + +namespace Gridify.QueryBuilders; + +internal class LinqSortingQueryBuilder : BaseSortingQueryBuilder, T> +{ + public LinqSortingQueryBuilder(IGridifyMapper? mapper = null) : base(mapper) + { + } + + protected override IQueryable ApplySorting(IQueryable query, ParsedOrdering ordering) + { + return query.OrderByMember(GetOrderExpression(ordering), ordering.IsAscending); + } + + protected override IQueryable ApplyAnotherSorting(IQueryable query, ParsedOrdering ordering) + { + return query.ThenByMember(GetOrderExpression(ordering), ordering.IsAscending); + } + + private Expression> GetOrderExpression(ParsedOrdering ordering) + { + var exp = mapper!.GetExpression(ordering.MemberName); + switch (ordering.OrderingType) + { + case OrderingType.Normal: + return exp; + case OrderingType.NullCheck: + case OrderingType.NotNullCheck: + default: + { + // member should be nullable + if (exp.Body is not UnaryExpression unary || Nullable.GetUnderlyingType(unary.Operand.Type) == null) + { + throw new GridifyOrderingException($"'{ordering.MemberName}' is not nullable type"); + } + + var prop = Expression.Property(exp.Parameters[0], ordering.MemberName); + var hasValue = Expression.PropertyOrField(prop, "HasValue"); + + switch (ordering.OrderingType) + { + case OrderingType.NullCheck: + { + var boxedExpression = Expression.Convert(hasValue, typeof(object)); + return Expression.Lambda>(boxedExpression, exp.Parameters); + } + case OrderingType.NotNullCheck: + { + var notHasValue = Expression.Not(hasValue); + var boxedExpression = Expression.Convert(notHasValue, typeof(object)); + return Expression.Lambda>(boxedExpression, exp.Parameters); + } + // should never reach here + case OrderingType.Normal: + return exp; + default: + throw new ArgumentOutOfRangeException(); + } + } + } + } +} From 9ef3c81d3956b5d1026f2157ffc45321cc8d0ede Mon Sep 17 00:00:00 2001 From: Dzmitry Koush Date: Fri, 20 Oct 2023 16:48:07 +0200 Subject: [PATCH 05/13] Add more Gridify extension for Gridify.Elasticsearch in order to have the same API as original Gridify --- .../GridifyExtensions.cs | 96 +++++++++ src/Gridify/GridifyExtensions.cs | 6 +- .../GridifyExtensionsTests.cs | 190 ++++++++++++++++++ 3 files changed, 287 insertions(+), 5 deletions(-) diff --git a/src/Gridify.Elasticsearch/GridifyExtensions.cs b/src/Gridify.Elasticsearch/GridifyExtensions.cs index abc4d8c7..b56ac275 100644 --- a/src/Gridify.Elasticsearch/GridifyExtensions.cs +++ b/src/Gridify.Elasticsearch/GridifyExtensions.cs @@ -31,4 +31,100 @@ public static ICollection ToElasticsearchSortOptions(this string var sortOptions = new ElasticsearchSortOptionsBuilder(mapper).Build(ordering!); return sortOptions; } + + public static SearchRequestDescriptor ApplyFiltering( + this SearchRequestDescriptor descriptor, string? filter, IGridifyMapper? mapper = null) + { + var query = filter.ToElasticsearchQuery(mapper); + descriptor.Query(query); + return descriptor; + } + + public static SearchRequestDescriptor ApplyFiltering( + this SearchRequestDescriptor descriptor, IGridifyQuery gridifyQuery, IGridifyMapper? mapper = null) + { + return descriptor.ApplyFiltering(gridifyQuery.Filter, mapper); + } + + public static SearchRequestDescriptor ApplyOrdering( + this SearchRequestDescriptor descriptor, string? ordering, IGridifyMapper? mapper = null) + { + var sortOptions = ordering.ToElasticsearchSortOptions(mapper); + descriptor.Sort(sortOptions); + return descriptor; + } + + public static SearchRequestDescriptor ApplyOrdering( + this SearchRequestDescriptor descriptor, IGridifyQuery gridifyQuery, IGridifyMapper? mapper = null) + { + return descriptor.ApplyOrdering(gridifyQuery.OrderBy, mapper); + } + + public static SearchRequestDescriptor ApplyPaging( + this SearchRequestDescriptor descriptor, int page, int pageSize) + { + var gridifyQuery = new GridifyQuery { Page = page, PageSize = pageSize }; + return descriptor.ApplyPaging((IGridifyPagination)gridifyQuery); + } + + public static SearchRequestDescriptor ApplyPaging( + this SearchRequestDescriptor descriptor, IGridifyQuery gridifyQuery) + { + return descriptor.ApplyPaging((IGridifyPagination)gridifyQuery); + } + + public static SearchRequestDescriptor ApplyPaging( + this SearchRequestDescriptor descriptor, IGridifyPagination gridifyPagination) + { + gridifyPagination.FixPagingData(); + return descriptor + .From((gridifyPagination.Page - 1) * gridifyPagination.PageSize) + .Size(gridifyPagination.PageSize); + } + + public static SearchRequestDescriptor ApplyFilteringAndOrdering( + this SearchRequestDescriptor descriptor, string? filter, string? ordering, IGridifyMapper? mapper = null) + { + return descriptor + .ApplyFiltering(filter, mapper) + .ApplyOrdering(ordering, mapper); + } + + public static SearchRequestDescriptor ApplyFilteringAndOrdering( + this SearchRequestDescriptor descriptor, IGridifyQuery gridifyQuery, IGridifyMapper? mapper = null) + { + return descriptor.ApplyFilteringAndOrdering(gridifyQuery.Filter, gridifyQuery.OrderBy, mapper); + } + + public static SearchRequestDescriptor ApplyFilteringOrderingPaging( + this SearchRequestDescriptor descriptor, string? filter, string? ordering, int page, int pageSize, IGridifyMapper? mapper = null) + { + return descriptor + .ApplyFilteringAndOrdering(filter, ordering, mapper) + .ApplyPaging(page, pageSize); + } + + public static SearchRequestDescriptor ApplyFilteringOrderingPaging( + this SearchRequestDescriptor descriptor, IGridifyQuery gridifyQuery, IGridifyMapper? mapper = null) + { + return descriptor + .ApplyFilteringAndOrdering(gridifyQuery.Filter, gridifyQuery.OrderBy, mapper) + .ApplyPaging(gridifyQuery.Page, gridifyQuery.PageSize); + } + + public static SearchRequestDescriptor ApplyOrderingAndPaging( + this SearchRequestDescriptor descriptor, string? ordering, int page, int pageSize, IGridifyMapper? mapper = null) + { + return descriptor + .ApplyOrdering(ordering, mapper) + .ApplyPaging(page, pageSize); + } + + public static SearchRequestDescriptor ApplyOrderingAndPaging( + this SearchRequestDescriptor descriptor, IGridifyQuery gridifyQuery, IGridifyMapper? mapper = null) + { + return descriptor + .ApplyOrdering(gridifyQuery.OrderBy, mapper) + .ApplyPaging(gridifyQuery.Page, gridifyQuery.PageSize); + } } diff --git a/src/Gridify/GridifyExtensions.cs b/src/Gridify/GridifyExtensions.cs index dcaad13a..f6602a31 100644 --- a/src/Gridify/GridifyExtensions.cs +++ b/src/Gridify/GridifyExtensions.cs @@ -9,14 +9,12 @@ namespace Gridify; public static partial class GridifyExtensions { - #region "Private" - /// /// Set default Page number and PageSize if its not already set in gridifyQuery /// /// query and paging configuration /// returns a IGridifyPagination with valid PageSize and Page - private static IGridifyPagination FixPagingData(this IGridifyPagination gridifyPagination) + internal static IGridifyPagination FixPagingData(this IGridifyPagination gridifyPagination) { // set default for page number if (gridifyPagination.Page <= 0) @@ -29,8 +27,6 @@ private static IGridifyPagination FixPagingData(this IGridifyPagination gridifyP return gridifyPagination; } - #endregion - public static Expression> GetFilteringExpression(this IGridifyFiltering gridifyFiltering, IGridifyMapper? mapper = null) { if (string.IsNullOrWhiteSpace(gridifyFiltering.Filter)) diff --git a/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs b/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs index 8d6e67a0..71088ccc 100644 --- a/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs +++ b/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs @@ -1,4 +1,5 @@ using System; +using System.Globalization; using Elastic.Clients.Elasticsearch; using Elastic.Transport.Extensions; using Xunit; @@ -13,6 +14,10 @@ public GridifyExtensionsTests() { // Disable Elasticsearch naming policy and use property names as they are GridifyGlobalConfiguration.CustomElasticsearchNamingAction = p => p; + + // Make the tests work on any culture + // TODO: Add CultureInfo support to Gridify + CultureInfo.CurrentCulture = CultureInfo.InvariantCulture; } [Theory] @@ -579,6 +584,185 @@ public void ToElasticsearchSortOptions_WhenCalledWithIgnoreElasticsearchProperty AssertOrdering(ordering, expected, mapper); } + [Fact] + public void ApplyFiltering_WhenCalledWithStringFilter_ShouldBuildCorrectQuery() + { + var descriptor = new SearchRequestDescriptor(); + const string filter = "Id=1"; + const string expected = """{"query":{"term":{"Id":{"value":1}}}}"""; + + descriptor.ApplyFiltering(filter); + + AssertDescriptor(descriptor, expected); + } + + [Fact] + public void ApplyFiltering_WhenCalledWithGridifyQuery_ShouldBuildCorrectQuery() + { + var descriptor = new SearchRequestDescriptor(); + var gridifyQuery = new GridifyQuery { Filter = "Id=1" }; + const string expected = """{"query":{"term":{"Id":{"value":1}}}}"""; + + descriptor.ApplyFiltering(gridifyQuery); + + AssertDescriptor(descriptor, expected); + } + + [Fact] + public void ApplyOrdering_WhenCalledWithStringOrdering_ShouldBuildCorrectQuery() + { + var descriptor = new SearchRequestDescriptor(); + const string ordering = "Id asc"; + const string expected = """{"sort":{"Id":{"order":"asc"}}}"""; + + descriptor.ApplyOrdering(ordering); + + AssertDescriptor(descriptor, expected); + } + + [Fact] + public void ApplyOrdering_WhenCalledWithGridifyQuery_ShouldBuildCorrectQuery() + { + var descriptor = new SearchRequestDescriptor(); + var gridifyQuery = new GridifyQuery { OrderBy = "Id asc" }; + const string expected = """{"sort":{"Id":{"order":"asc"}}}"""; + + descriptor.ApplyOrdering(gridifyQuery); + + AssertDescriptor(descriptor, expected); + } + + [Theory] + [InlineData(1, 10, """{"from":0,"size":10}""")] + [InlineData(2, 10, """{"from":10,"size":10}""")] + [InlineData(3, 10, """{"from":20,"size":10}""")] + [InlineData(0, 10, """{"from":0,"size":10}""")] + [InlineData(-1, 10, """{"from":0,"size":10}""")] + [InlineData(1, 0, """{"from":0,"size":20}""")] + [InlineData(1, -1, """{"from":0,"size":20}""")] + public void ApplyPaging_WhenCalledWithPageAndPageSize_ShouldBuildCorrectQuery(int page, int pageSize, string expected) + { + var descriptor = new SearchRequestDescriptor(); + + descriptor.ApplyPaging(page, pageSize); + + AssertDescriptor(descriptor, expected); + } + + [Theory] + [InlineData(1, 10, """{"from":0,"size":10}""")] + [InlineData(2, 10, """{"from":10,"size":10}""")] + [InlineData(3, 10, """{"from":20,"size":10}""")] + [InlineData(0, 10, """{"from":0,"size":10}""")] + [InlineData(-1, 10, """{"from":0,"size":10}""")] + [InlineData(1, 0, """{"from":0,"size":20}""")] + [InlineData(1, -1, """{"from":0,"size":20}""")] + public void ApplyPaging_WhenCalledWithGridifyQuery_ShouldBuildCorrectQuery(int page, int pageSize, string expected) + { + var descriptor = new SearchRequestDescriptor(); + var gridifyQuery = new GridifyQuery { Page = page, PageSize = pageSize }; + + descriptor.ApplyPaging(gridifyQuery); + + AssertDescriptor(descriptor, expected); + } + + [Theory] + [InlineData(1, 10, """{"from":0,"size":10}""")] + [InlineData(2, 10, """{"from":10,"size":10}""")] + [InlineData(3, 10, """{"from":20,"size":10}""")] + [InlineData(0, 10, """{"from":0,"size":10}""")] + [InlineData(-1, 10, """{"from":0,"size":10}""")] + [InlineData(1, 0, """{"from":0,"size":20}""")] + [InlineData(1, -1, """{"from":0,"size":20}""")] + public void ApplyPaging_WhenCalledWithGridifyPagination_ShouldBuildCorrectQuery(int page, int pageSize, string expected) + { + var descriptor = new SearchRequestDescriptor(); + var gridifyPagination = (IGridifyPagination)new GridifyQuery { Page = page, PageSize = pageSize }; + + descriptor.ApplyPaging(gridifyPagination); + + AssertDescriptor(descriptor, expected); + } + + [Fact] + public void ApplyFilteringAndOrdering_WhenCalledWithStringFilterAndOrdering_ShouldBuildCorrectQuery() + { + var descriptor = new SearchRequestDescriptor(); + const string filter = "Id=1"; + const string ordering = "Id asc"; + const string expected = """{"query":{"term":{"Id":{"value":1}}},"sort":{"Id":{"order":"asc"}}}"""; + + descriptor.ApplyFilteringAndOrdering(filter, ordering); + + AssertDescriptor(descriptor, expected); + } + + [Fact] + public void ApplyFilteringAndOrdering_WhenCalledWithGridifyQuery_ShouldBuildCorrectQuery() + { + var descriptor = new SearchRequestDescriptor(); + var gridifyQuery = new GridifyQuery { Filter = "Id=1", OrderBy = "Id asc" }; + const string expected = """{"query":{"term":{"Id":{"value":1}}},"sort":{"Id":{"order":"asc"}}}"""; + + descriptor.ApplyFilteringAndOrdering(gridifyQuery); + + AssertDescriptor(descriptor, expected); + } + + [Fact] + public void ApplyFilteringOrderingPaging_WhenCalledWithStringFilterOrderingPageAndPageSize_ShouldBuildCorrectQuery() + { + var descriptor = new SearchRequestDescriptor(); + const string filter = "Id=1"; + const string ordering = "Id asc"; + const int page = 1; + const int pageSize = 10; + const string expected = """{"query":{"term":{"Id":{"value":1}}},"sort":{"Id":{"order":"asc"}},"from":0,"size":10}"""; + + descriptor.ApplyFilteringOrderingPaging(filter, ordering, page, pageSize); + + AssertDescriptor(descriptor, expected); + } + + [Fact] + public void ApplyFilteringOrderingPaging_WhenCalledWithGridifyQuery_ShouldBuildCorrectQuery() + { + var descriptor = new SearchRequestDescriptor(); + var gridifyQuery = new GridifyQuery { Filter = "Id=1", OrderBy = "Id asc", Page = 1, PageSize = 10 }; + const string expected = """{"query":{"term":{"Id":{"value":1}}},"sort":{"Id":{"order":"asc"}},"from":0,"size":10}"""; + + descriptor.ApplyFilteringOrderingPaging(gridifyQuery); + + AssertDescriptor(descriptor, expected); + } + + [Fact] + public void ApplyOrderingAndPaging_WhenCalledWithStringOrderingPageAndPageSize_ShouldBuildCorrectQuery() + { + var descriptor = new SearchRequestDescriptor(); + const string ordering = "Id asc"; + const int page = 1; + const int pageSize = 10; + const string expected = """{"sort":{"Id":{"order":"asc"}},"from":0,"size":10}"""; + + descriptor.ApplyOrderingAndPaging(ordering, page, pageSize); + + AssertDescriptor(descriptor, expected); + } + + [Fact] + public void ApplyOrderingAndPaging_WhenCalledWithGridifyQuery_ShouldBuildCorrectQuery() + { + var descriptor = new SearchRequestDescriptor(); + var gridifyQuery = new GridifyQuery { OrderBy = "Id asc", Page = 1, PageSize = 10 }; + const string expected = """{"sort":{"Id":{"order":"asc"}},"from":0,"size":10}"""; + + descriptor.ApplyOrderingAndPaging(gridifyQuery); + + AssertDescriptor(descriptor, expected); + } + private void AssertFilter(string filter, string expected, IGridifyMapper? mapper = null) { var result = filter.ToElasticsearchQuery(mapper); @@ -594,4 +778,10 @@ private void AssertOrdering(string ordering, string expected, IGridifyMapper(SearchRequestDescriptor descriptor, string expected) + { + var jsonQuery = _client.RequestResponseSerializer.SerializeToString(descriptor); + Assert.Equal(expected, jsonQuery); + } } From a62f440fa4dee4737f2a606df1f8e6802e3dceac Mon Sep 17 00:00:00 2001 From: Dzmitry Koush Date: Sat, 21 Oct 2023 16:40:31 +0200 Subject: [PATCH 06/13] feat: check for unsupported features --- .../ElasticsearchQueryBuilder.cs | 7 +- .../ElasticsearchSortOptionsBuilder.cs | 8 +- .../GridifyExtensionsTests.cs | 74 ++++++++++++------- 3 files changed, 59 insertions(+), 30 deletions(-) diff --git a/src/Gridify.Elasticsearch/ElasticsearchQueryBuilder.cs b/src/Gridify.Elasticsearch/ElasticsearchQueryBuilder.cs index 78ea4246..768afb3a 100644 --- a/src/Gridify.Elasticsearch/ElasticsearchQueryBuilder.cs +++ b/src/Gridify.Elasticsearch/ElasticsearchQueryBuilder.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Collections.Generic; using System.Linq.Expressions; using Elastic.Clients.Elasticsearch; @@ -46,6 +46,11 @@ protected override object BuildQueryAccordingToValueType( ValueExpressionSyntax valueExpression, bool isConvertable) { + if (valueExpression.IsCaseInsensitive) + { + throw new NotSupportedException("Case insensitive filtering is not supported by Gridify.Elasticsearch"); + } + bool isStringValue = false, isNumberExceptDecimalValue = false; if (IsString(value)) { diff --git a/src/Gridify.Elasticsearch/ElasticsearchSortOptionsBuilder.cs b/src/Gridify.Elasticsearch/ElasticsearchSortOptionsBuilder.cs index d19b685a..4420be54 100644 --- a/src/Gridify.Elasticsearch/ElasticsearchSortOptionsBuilder.cs +++ b/src/Gridify.Elasticsearch/ElasticsearchSortOptionsBuilder.cs @@ -1,4 +1,5 @@ -using System.Collections.Generic; +using System; +using System.Collections.Generic; using Elastic.Clients.Elasticsearch; using Gridify.QueryBuilders; using Gridify.Syntax; @@ -18,6 +19,11 @@ internal ICollection Build(string ordering) protected override ICollection ApplySorting(ICollection sortOptions, ParsedOrdering ordering) { + if (ordering.OrderingType != OrderingType.Normal) + { + throw new NotSupportedException($"Gridify.Elasticsearch does not support '{ordering.OrderingType}' ordering"); + } + var propExpression = mapper!.GetExpression(ordering.MemberName); var isStringValue = propExpression.GetRealType() == typeof(string); var fieldName = propExpression.Body.BuildFieldName(isStringValue, mapper); diff --git a/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs b/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs index 71088ccc..70803cae 100644 --- a/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs +++ b/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Globalization; using Elastic.Clients.Elasticsearch; using Elastic.Transport.Extensions; @@ -268,17 +268,17 @@ public void ToElasticsearchQuery_WhenCalledWithDecimalValue_ReturnsElasticsearch // string does not equal [InlineData("Name!=Dzmitry", """{"bool":{"must_not":{"term":{"Name.keyword":{"value":"Dzmitry"}}}}}""")] // string contains - [InlineData("Name=*itr", """{"wildcard":{"Name.keyword":{"value":"*itr*"}}}""")] + [InlineData("Name=*itr", """{"wildcard":{"Name.keyword":{"value":" * itr * "}}}""")] // string does not contain - [InlineData("Name!*itr", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"*itr*"}}}}}""")] + [InlineData("Name!*itr", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":" * itr * "}}}}}""")] // string starts with - [InlineData("Name^Dzm", """{"wildcard":{"Name.keyword":{"value":"Dzm*"}}}""")] + [InlineData("Name^Dzm", """{"wildcard":{"Name.keyword":{"value":"Dzm * "}}}""")] // string does not start with - [InlineData("Name!^Dzm", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"Dzm*"}}}}}""")] + [InlineData("Name!^Dzm", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"Dzm * "}}}}}""")] // string ends with - [InlineData("Name$try", """{"wildcard":{"Name.keyword":{"value":"*try"}}}""")] + [InlineData("Name$try", """{"wildcard":{"Name.keyword":{"value":" *try"}}}""")] // string does not end with - [InlineData("Name!$try", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"*try"}}}}}""")] + [InlineData("Name!$try", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":" *try"}}}}}""")] // string is null [InlineData("Name=null", """{"bool":{"must_not":{"exists":{"field":"Name"}}}}""")] // string is not null @@ -298,23 +298,23 @@ public void ToElasticsearchQuery_WhenCalledWithStringValue_ReturnsElasticsearchQ [Theory] // date equals - [InlineData("MyDateTime=2021-09-01", """{"term":{"MyDateTime":{"value":"2021-09-01T00:00:00"}}}""")] + [InlineData("MyDateTime=2021-09-01", """{"term":{"MyDateTime":{"value":"2021 - 09 - 01T00: 00:00"}}}""")] // date and time equals - [InlineData("MyDateTime=2021-09-01T00:00:00", """{"term":{"MyDateTime":{"value":"2021-09-01T00:00:00"}}}""")] - [InlineData("MyDateTime=2021-09-01 00:00:00", """{"term":{"MyDateTime":{"value":"2021-09-01T00:00:00"}}}""")] - [InlineData("MyDateTime=2021-09-01 23:15:11", """{"term":{"MyDateTime":{"value":"2021-09-01T23:15:11"}}}""")] + [InlineData("MyDateTime=2021-09-01T00:00:00", """{"term":{"MyDateTime":{"value":"2021 - 09 - 01T00: 00:00"}}}""")] + [InlineData("MyDateTime=2021-09-01 00:00:00", """{"term":{"MyDateTime":{"value":"2021 - 09 - 01T00: 00:00"}}}""")] + [InlineData("MyDateTime=2021-09-01 23:15:11", """{"term":{"MyDateTime":{"value":"2021 - 09 - 01T23: 15:11"}}}""")] // date is null [InlineData("MyDateTime=null", """{"bool":{"must_not":{"exists":{"field":"MyDateTime"}}}}""")] // date is not null [InlineData("MyDateTime!=null", """{"exists":{"field":"MyDateTime"}}""")] // date greater than - [InlineData("MyDateTime>2021-09-01", """{"range":{"MyDateTime":{"gt":"2021-09-01T00:00:00"}}}""")] + [InlineData("MyDateTime>2021-09-01", """{"range":{"MyDateTime":{"gt":"2021 - 09 - 01T00: 00:00"}}}""")] // date greater than or equal - [InlineData("MyDateTime>=2021-09-01", """{"range":{"MyDateTime":{"gte":"2021-09-01T00:00:00"}}}""")] + [InlineData("MyDateTime>=2021-09-01", """{"range":{"MyDateTime":{"gte":"2021 - 09 - 01T00: 00:00"}}}""")] // date less than - [InlineData("MyDateTime<2021-09-01", """{"range":{"MyDateTime":{"lt":"2021-09-01T00:00:00"}}}""")] + [InlineData("MyDateTime<2021-09-01", """{"range":{"MyDateTime":{"lt":"2021 - 09 - 01T00: 00:00"}}}""")] // date less than or equal - [InlineData("MyDateTime<=2021-09-01", """{"range":{"MyDateTime":{"lte":"2021-09-01T00:00:00"}}}""")] + [InlineData("MyDateTime<=2021-09-01", """{"range":{"MyDateTime":{"lte":"2021 - 09 - 01T00: 00:00"}}}""")] public void ToElasticsearchQuery_WhenCalledWithDateTimeValue_ReturnsElasticsearchQuery(string filter, string expected) { AssertFilter(filter, expected); @@ -322,19 +322,19 @@ public void ToElasticsearchQuery_WhenCalledWithDateTimeValue_ReturnsElasticsearc [Theory] // date only equals - [InlineData("MyDateOnly=2021-09-01", """{"term":{"MyDateOnly":{"value":"2021-09-01"}}}""")] + [InlineData("MyDateOnly=2021-09-01", """{"term":{"MyDateOnly":{"value":"2021 - 09 - 01"}}}""")] // date only is null [InlineData("MyDateOnly=null", """{"bool":{"must_not":{"exists":{"field":"MyDateOnly"}}}}""")] // date only is not null [InlineData("MyDateOnly!=null", """{"exists":{"field":"MyDateOnly"}}""")] // date only greater than - [InlineData("MyDateOnly>2021-09-01", """{"range":{"MyDateOnly":{"gt":"2021-09-01"}}}""")] + [InlineData("MyDateOnly>2021-09-01", """{"range":{"MyDateOnly":{"gt":"2021 - 09 - 01"}}}""")] // date only greater than or equal - [InlineData("MyDateOnly>=2021-09-01", """{"range":{"MyDateOnly":{"gte":"2021-09-01"}}}""")] + [InlineData("MyDateOnly>=2021-09-01", """{"range":{"MyDateOnly":{"gte":"2021 - 09 - 01"}}}""")] // date only less than - [InlineData("MyDateOnly<2021-09-01", """{"range":{"MyDateOnly":{"lt":"2021-09-01"}}}""")] + [InlineData("MyDateOnly<2021-09-01", """{"range":{"MyDateOnly":{"lt":"2021 - 09 - 01"}}}""")] // date only less than or equal - [InlineData("MyDateOnly<=2021-09-01", """{"range":{"MyDateOnly":{"lte":"2021-09-01"}}}""")] + [InlineData("MyDateOnly<=2021-09-01", """{"range":{"MyDateOnly":{"lte":"2021 - 09 - 01"}}}""")] public void ToElasticsearchQuery_WhenCalledWithDateOnlyValue_ReturnsElasticsearchQuery(string filter, string expected) { AssertFilter(filter, expected); @@ -356,7 +356,7 @@ public void ToElasticsearchQuery_WhenCalledWithBoolValue_ReturnsElasticsearchQue [Theory] // guid equals - [InlineData("MyGuid=69C3BB3A-3A85-4750-BA03-1F916FA5C0B1", """{"term":{"MyGuid.keyword":{"value":"69C3BB3A-3A85-4750-BA03-1F916FA5C0B1"}}}""")] + [InlineData("MyGuid=69C3BB3A-3A85-4750-BA03-1F916FA5C0B1", """{"term":{"MyGuid.keyword":{"value":"69C3BB3A - 3A85 - 4750 - BA03 - 1F916FA5C0B1"}}}""")] // guid is null [InlineData("MyGuid=null", """{"bool":{"must_not":{"exists":{"field":"MyGuid"}}}}""")] // guid is not null @@ -376,7 +376,7 @@ public void ToElasticsearchQuery_WhenCalledWithGuidValue_ReturnsElasticsearchQue // , | operators [InlineData("Id=1,Name=Dzmitry|Name=John", """{"bool":{"should":[{"bool":{"must":[{"term":{"Id":{"value":1}}},{"term":{"Name.keyword":{"value":"Dzmitry"}}}]}},{"term":{"Name.keyword":{"value":"John"}}}]}}""")] // , , , operators - [InlineData("Id=1,Name=Dzmitry,MyDateTime=2021-09-01", """{"bool":{"must":[{"term":{"Id":{"value":1}}},{"term":{"Name.keyword":{"value":"Dzmitry"}}},{"term":{"MyDateTime":{"value":"2021-09-01T00:00:00"}}}]}}""")] + [InlineData("Id=1,Name=Dzmitry,MyDateTime=2021-09-01", """{"bool":{"must":[{"term":{"Id":{"value":1}}},{"term":{"Name.keyword":{"value":"Dzmitry"}}},{"term":{"MyDateTime":{"value":"2021 - 09 - 01T00: 00:00"}}}]}}""")] // | | | operators [InlineData("Id=1|Id=2|Id=3", """{"bool":{"should":[{"term":{"Id":{"value":1}}},{"term":{"Id":{"value":2}}},{"term":{"Id":{"value":3}}}]}}""")] // ( | ) operators @@ -412,13 +412,13 @@ public void ToElasticsearchQuery_WhenCalledWithNestedClass_ReturnsElasticsearchQ // empty query [InlineData("", """{"match_all":{}}""")] // string starts with empty - [InlineData("Name^", """{"wildcard":{"Name.keyword":{"value":"*"}}}""")] + [InlineData("Name^", """{"wildcard":{"Name.keyword":{"value":" * "}}}""")] // string ends with empty - [InlineData("Name$", """{"wildcard":{"Name.keyword":{"value":"*"}}}""")] + [InlineData("Name$", """{"wildcard":{"Name.keyword":{"value":" * "}}}""")] // string does not start with empty - [InlineData("Name!^", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"*"}}}}}""")] + [InlineData("Name!^", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":" * "}}}}}""")] // string does not end with empty - [InlineData("Name!$", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"*"}}}}}""")] + [InlineData("Name!$", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":" * "}}}}}""")] public void ToElasticsearchQuery_WhenCalledWithEmptyValue_ReturnsElasticsearchQuery(string filter, string expected) { AssertFilter(filter, expected); @@ -471,7 +471,7 @@ public void ToElasticsearchQuery_WhenCalledWithAllowNullSearchSetting_ShouldAppl [InlineData("NotMappedField=Homer,MappedField=Dzmitry", false, "")] public void ToElasticsearchQuery_WhenCalledWithIgnoreNotMappedFieldsSetting_ShouldApplyTheSetting(string filter, bool ignoreNotMappedFields, string expected) { - var mapper = new GridifyMapper { Configuration = { IgnoreNotMappedFields = ignoreNotMappedFields }} + var mapper = new GridifyMapper { Configuration = { IgnoreNotMappedFields = ignoreNotMappedFields } } .AddMap("MappedField", x => x.Name); if (!ignoreNotMappedFields) @@ -502,6 +502,15 @@ public void ToElasticsearchQuery_WhenCalledWithCustomElasticsearchNamingActionSe AssertFilter(filter, expected, mapper); } + [Fact] + public void ToElasticsearchQuery_WhenCalledWithCaseInsensitiveOperator_ThrowsNotSupportedException() + { + const string filter = "Name=John/i"; + + var ex = Assert.Throws(() => filter.ToElasticsearchQuery()); + Assert.Equal("Case insensitive filtering is not supported by Gridify.Elasticsearch", ex.Message); + } + [Theory] [InlineData("Id asc", """[{"Id":{"order":"asc"}}]""")] [InlineData("Id desc", """[{"Id":{"order":"desc"}}]""")] @@ -553,7 +562,7 @@ public void ToElasticsearchSortOptions_WhenCalledWithCaseSensitiveMapperSetting_ public void ToElasticsearchSortOptions_WhenCalledWithIgnoreNotMappedFieldsSetting_ShouldApplyTheSetting( string ordering, bool ignoreNotMappedFields, string expected) { - var mapper = new GridifyMapper { Configuration = { IgnoreNotMappedFields = ignoreNotMappedFields }} + var mapper = new GridifyMapper { Configuration = { IgnoreNotMappedFields = ignoreNotMappedFields } } .AddMap("MappedField", x => x.Name); if (!ignoreNotMappedFields) @@ -584,6 +593,15 @@ public void ToElasticsearchSortOptions_WhenCalledWithIgnoreElasticsearchProperty AssertOrdering(ordering, expected, mapper); } + [Theory] + [InlineData("Tag?", "NullCheck")] + [InlineData("Tag!", "NotNullCheck")] + public void ToElasticsearchSortOptions_WhenCalledWithNullableTypesOperator(string ordering, string orderingType) + { + var ex = Assert.Throws(() => ordering.ToElasticsearchSortOptions()); + Assert.Equal($"Gridify.Elasticsearch does not support '{orderingType}' ordering", ex.Message); + } + [Fact] public void ApplyFiltering_WhenCalledWithStringFilter_ShouldBuildCorrectQuery() { From 2bfc505873bfe61884136bb273ce05092092b980 Mon Sep 17 00:00:00 2001 From: Dzmitry Koush Date: Sat, 21 Oct 2023 16:43:00 +0200 Subject: [PATCH 07/13] docs: extend the documentation with information about Gridify.Elasticsearch --- docs/.vuepress/configs/sidebar.ts | 1 + docs/README.md | 2 +- docs/contribution/README.md | 1 + docs/guide/README.md | 30 +- docs/guide/autoMapper.md | 25 +- docs/guide/compile.md | 34 ++- docs/guide/elasticsearch.md | 254 +++++++++++++++++ docs/guide/extensions.md | 262 +++++++++++++++++- docs/guide/filtering.md | 79 ++++-- docs/guide/getting-started.md | 17 +- docs/guide/gridifyGlobalConfiguration.md | 23 +- docs/guide/gridifyMapper.md | 82 +++--- docs/guide/gridifyQuery.md | 8 +- docs/guide/ordering.md | 37 ++- docs/guide/queryBuilder.md | 48 ++-- .../GridifyExtensions.cs | 128 ++++++++- src/Gridify/GridifyExtensions.cs | 10 +- src/Gridify/GridifyGlobalConfiguration.cs | 4 +- src/Gridify/GridifyMapperConfiguration.cs | 4 +- 19 files changed, 901 insertions(+), 148 deletions(-) create mode 100644 docs/guide/elasticsearch.md diff --git a/docs/.vuepress/configs/sidebar.ts b/docs/.vuepress/configs/sidebar.ts index 0708d95d..f24c3b45 100644 --- a/docs/.vuepress/configs/sidebar.ts +++ b/docs/.vuepress/configs/sidebar.ts @@ -32,6 +32,7 @@ export const sidebar: SidebarConfig = { '/guide/dependency-injection.md', '/guide/compile.md', '/guide/entity-framework.md', + '/guide/elasticsearch.md', '/guide/autoMapper.md', ] } diff --git a/docs/README.md b/docs/README.md index 9ce1e41d..cd13f00e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -17,6 +17,6 @@ features: - title: Exceptional Performance details: Gridify has been designed with performance in mind and can outperform other dynamic LINQ libraries. - title: Compatibility - details: Gridify can be used in any .NET Core or .NET Framework project, making it highly compatible. It can also be used alongside Entity Framework and other ORMs. + details: Gridify can be used in any .NET Core or .NET Framework project, making it highly compatible. It can also be used alongside Entity Framework and other ORMs. Gridify.Elasticsearch extension provides and ability to build Elasticsearch DSL queries from Gridify filters. footer: MIT Licensed | © 2021-present AliReZa Sabouri --- diff --git a/docs/contribution/README.md b/docs/contribution/README.md index a762f3ac..ba0426ed 100644 --- a/docs/contribution/README.md +++ b/docs/contribution/README.md @@ -46,4 +46,5 @@ check out the [github contributing guide](https://git-scm.com/book/en/v2/GitHub- - [Alireza Arabshahi](https://github.com/AlirezaArabshahi) - [sunyuliang](https://github.com/sunyuliang) - [TSrgy](https://github.com/TSrgy) +- [Dzmitry Koush](https://github.com/ne4ta) - Add your name diff --git a/docs/guide/README.md b/docs/guide/README.md index 5fc609e4..a92a490d 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -1,6 +1,10 @@ # Introduction -Gridify is a dynamic LINQ library that simplifies the process of converting strings to LINQ queries. With exceptional performance and ease-of-use, Gridify makes it effortless to apply filtering, sorting, and pagination using text-based data. +Gridify is a dynamic LINQ library that simplifies the process of converting strings to LINQ queries. With exceptional +performance and ease-of-use, Gridify makes it effortless to apply filtering, sorting, and pagination using text-based +data. + +Gridify.Elasticsearch is an extension of Gridify, that provides an ability to generate Elasticsearch DSL queries. ## Features @@ -13,10 +17,12 @@ Gridify is a dynamic LINQ library that simplifies the process of converting stri - Supports collection indexes - Custom Operators - Compatible with ORMs, especially Entity Framework +- Compatible with + Elasticsearch ([Elastic.Clients.Elasticsearch 8.*](https://www.nuget.org/packages/Elastic.Clients.Elasticsearch) is a + dependency) - Can be used on every collection that LINQ supports - Compatible with object-mappers like AutoMapper - ## Examples To better illustrate how Gridify works, we've prepared a few examples: @@ -24,13 +30,18 @@ To better illustrate how Gridify works, we've prepared a few examples: - [Using Gridify in API Controllers](../example/api-controller.md) - Coming soon ... - ## Performance -Filtering can be the most expensive feature in Gridify. The following benchmark compares filtering in the most well-known dynamic LINQ libraries. As you can see, Gridify has the closest result to native LINQ: +::: warning +For now, there are no benchmarks for Gridify.Elasticsearch because it builds non-LINQ query. But it uses Gridify lib as +a basis. +::: + +Filtering can be the most expensive feature in Gridify. The following benchmark compares filtering in the most +well-known dynamic LINQ libraries. As you can see, Gridify has the closest result to native LINQ: -| Method | Mean | Error | StdDev | RatioSD | Allocated | Alloc Ratio | -|----------------- |-------------:|------------:|------------:|--------:|------------:|------------:| +| Method | Mean | Error | StdDev | RatioSD | Allocated | Alloc Ratio | +|------------------|-------------:|------------:|------------:|--------:|------------:|------------:| | Native LINQ | 651.2 us | 6.89 us | 6.45 us | 0.00 | 32.74 KB | 1.00 | | Gridify | 689.1 us | 10.70 us | 11.45 us | 0.02 | 36.85 KB | 1.13 | | DynamicLinq | 829.3 us | 10.98 us | 9.17 us | 0.01 | 119.29 KB | 3.64 | @@ -42,10 +53,11 @@ Filtering can be the most expensive feature in Gridify. The following benchmark BenchmarkDotNet v0.13.8, Windows 11 (10.0.22621.2283/22H2/2022Update/SunValley2) 12th Gen Intel Core i7-12800H, 1 CPU, 20 logical and 14 physical cores .NET SDK 8.0.100-preview.1.23115.2 - [Host] : .NET 7.0.11 (7.0.1123.42427), X64 RyuJIT AVX2 - DefaultJob : .NET 7.0.11 (7.0.1123.42427), X64 RyuJIT AVX2 +[Host] : .NET 7.0.11 (7.0.1123.42427), X64 RyuJIT AVX2 +DefaultJob : .NET 7.0.11 (7.0.1123.42427), X64 RyuJIT AVX2 -This Benchmark is available [Here](https://github.com/alirezanet/Gridify/blob/master/benchmark/LibraryComparisionFilteringBenchmark.cs) +This Benchmark is +available [Here](https://github.com/alirezanet/Gridify/blob/master/benchmark/LibraryComparisionFilteringBenchmark.cs) ::: diff --git a/docs/guide/autoMapper.md b/docs/guide/autoMapper.md index a24d0bc3..45b207ec 100644 --- a/docs/guide/autoMapper.md +++ b/docs/guide/autoMapper.md @@ -1,6 +1,10 @@ # AutoMapper -Gridify is complitely compatible with AutoMapper. also, these two packages can help each other nicely. we can use Gridify for filtering, sorting, and paging and AutoMapper for the projection. +::: warning +Is not supported by Gridify.Elasticsearch. +::: + +Gridify is completely compatible with AutoMapper. Also, these two packages can help each other nicely. We can use Gridify for filtering, sorting, and paging and AutoMapper for the projection. ``` csharp // AutoMapper ProjectTo + Filtering Only, example @@ -11,29 +15,30 @@ var result = query.ProjectTo().ToList(); ``` csharp // AutoMapper ProjectTo + Filtering + Ordering + Paging, example QueryablePaging qp = personRepo.GridifyQueryable(gridifyQuery); -var result = new Paging (qp.Count, qp.Query.ProjectTo().ToList ()); +var result = new Paging(qp.Count, qp.Query.ProjectTo().ToList()); ``` ## GridifyTo! -Filtering, Ordering, Paging, and Projection are all done with GridifyTo. -Gridify library does not have a built-in GridifyTo extension method because we don't want to have AutoMapper dependency. but if you are using AutoMapper in your project, I recommend you to add the bellow extension method to your project. +Filtering, Ordering, Paging, and Projection are all done with `GridifyTo`. + +Gridify library does not have a built-in `GridifyTo` extension method because we don't want to have AutoMapper dependency. but if you are using AutoMapper in your project, I recommend you to add the bellow extension method to your project. ``` csharp -public static Paging GridifyTo(this IQueryable query, - IMapper autoMapper, IGridifyQuery gridifyQuery, IGridifyMapper mapper = null) +public static Paging GridifyTo( + this IQueryable query, IMapper autoMapper, IGridifyQuery gridifyQuery, IGridifyMapper mapper = null) { var res = query.GridifyQueryable(gridifyQuery, mapper); - return new Paging (res.Count , res.Query.ProjectTo(autoMapper.ConfigurationProvider).ToList()); + return new Paging(res.Count, res.Query.ProjectTo(autoMapper.ConfigurationProvider).ToList()); } ``` ``` csharp // only if you have Gridify.EntityFramework package installed. -public static async Task> GridifyToAsync(this IQueryable query, - IMapper autoMapper, IGridifyQuery gridifyQuery, IGridifyMapper mapper = null) +public static async Task> GridifyToAsync( + this IQueryable query, IMapper autoMapper, IGridifyQuery gridifyQuery, IGridifyMapper mapper = null) { var res = await query.GridifyQueryableAsync(gridifyQuery, mapper); - return new Paging (res.Count , await res.Query.ProjectTo(autoMapper.ConfigurationProvider).ToListAsync()); + return new Paging(res.Count, await res.Query.ProjectTo(autoMapper.ConfigurationProvider).ToListAsync()); } ``` diff --git a/docs/guide/compile.md b/docs/guide/compile.md index 4d0584b2..17d96477 100644 --- a/docs/guide/compile.md +++ b/docs/guide/compile.md @@ -1,15 +1,17 @@ # Compile and Reuse -You can access Gridify generated expressions using the `GetFilteringExpression` of [GridifyQuery](./gridifyQuery.md) or `BuildCompiled` methods of [QueryBuilder](./queryBuilder.md) class, +You can access Gridify generated expressions using the `GetFilteringExpression` of [GridifyQuery](./gridifyQuery.md) +or `BuildCompiled` methods of [QueryBuilder](./queryBuilder.md) class, by storing an expression you can use it multiple times without having any overheads, also if you store a compiled expression you get a massive performance boost. ::: warning -you should only use a **compiled** expression (delegate) if you are **not** using Gridify alongside an ORM like Entity-Framework. +You should only use a **compiled** expression (delegate) if you are **not** using Gridify alongside an ORM like +Entity-Framework. ::: ``` csharp -// eg.1 - using GridifyQuery - Compield - where only +// eg.1 - using GridifyQuery - Compiled - where only var gq = new GridifyQuery() { Filter = "name=John" }; var expression = gq.GetFilteringExpression(); var compiledExpression = expression.Compile(); @@ -17,7 +19,7 @@ var result = persons.Where(compiledExpression); ``` ``` csharp -// eg.2 - using QueryBuilder - Compield - where only +// eg.2 - using QueryBuilder - Compiled - where only var compiledExpression = new QueryBuilder() .AddCondition("name=John") .BuildFilteringExpression() @@ -34,11 +36,23 @@ var result = func(persons); ``` +::: tip +You can use a similar approach with Gridify.Elasticsearch. +Using [`ToElasticsearchQuery`](./extensions.md/#ToElasticsearchQuery) +and [`ToElasticsearchSortOptions`](./extensions.md/#ToElasticsearchSortOptions) +::: + ## Performance -This is the performance improvement example when you use a compiled expression -| Method | Mean | Ratio | RatioSD | Gen 0 | Gen 1 | Allocated | -|---------------- |-------------:|------:|--------:|---------:|--------:|----------:| -| GridifyCompiled | 1.008 us | 0.001 | 0.00 | 0.1564 | - | 984 B | -| NativeLINQ | 724.329 us | 1.000 | 0.00 | 5.8594 | 2.9297 | 37,392 B | -| Gridify | 736.854 us | 1.018 | 0.01 | 5.8594 | 2.9297 | 39,924 B | +::: warning +For now, there are no benchmarks for Gridify.Elasticsearch because it builds non-LINQ query. But it uses Gridify lib as +a basis. +::: + +This is the performance improvement example when you use a compiled expression. + +| Method | Mean | Ratio | RatioSD | Gen 0 | Gen 1 | Allocated | +|-----------------|-----------:|------:|--------:|-------:|-------:|----------:| +| GridifyCompiled | 1.008 us | 0.001 | 0.00 | 0.1564 | - | 984 B | +| NativeLINQ | 724.329 us | 1.000 | 0.00 | 5.8594 | 2.9297 | 37,392 B | +| Gridify | 736.854 us | 1.018 | 0.01 | 5.8594 | 2.9297 | 39,924 B | diff --git a/docs/guide/elasticsearch.md b/docs/guide/elasticsearch.md new file mode 100644 index 00000000..28477381 --- /dev/null +++ b/docs/guide/elasticsearch.md @@ -0,0 +1,254 @@ +# Elasticsearch + +## Gridify.Elasticsearch Package + +The `Gridify.Elasticsearch` package has a bunch of [extension methods](./extensions.md) that allow to convert Gridify filters and sortings to Elasticsearch DSL queries using Elastic.Clients.Elasticsearch .NET client. + +## CustomElasticsearchNamingAction + +Specifies how field names are inferred from CLR property names. By default, **Elastic.Clients.Elasticsearch** uses camel-case property names. + +- If `null` (default behavior) CLR property `EmailAddress` will be inferred as `emailAddress` Elasticsearch document field name. +- If, e.g., `p => p`, the CLR property `EmailAddress` will be inferred as `EmailAddress` Elasticsearch document field name. + +:::: code-group +::: code-group-item Default + +``` csharp +await client.SearchAsync(s => s + .Index("users") + .ApplyFiltering("emailAddress = John")); +``` + +this will make the next Elasticsearch query: + +``` json +GET users/_search +{ + "query": { + "term": { + "emailAddress.keyword": { + "value": "test@test.com" + } + } + } +} +``` + +::: + +::: code-group-item Customized + +``` csharp +GridifyGlobalConfiguration.CustomElasticsearchNamingAction = p => $"_{p}_"; + +await client.SearchAsync(s => s + .Index("users") + .ApplyFiltering("emailAddress = John")); +``` + +this will make the next Elasticsearch query: + +``` json +GET users/_search +{ + "query": { + "term": { + "_EmailAddress_.keyword": { + "value": "test@test.com" + } + } + } +} +``` + +::: + +:::: + +## Examples of usage + +### Without pre-initialized mapper + +:::: code-group +::: code-group-item C# + +``` csharp +var gq = new GridifyQuery() +{ + Filter = "FirstName=John", + Page = 1, + PageSize = 20, + OrderBy = "Age" +}; + +var response = await client.SearchAsync(s => s + .Index("users") + .ApplyPaging(gq) + .ApplyFiltering(gp) + .ApplyOrdering(gp)); + +return response.Documents; +``` + +::: + +::: code-group-item JSON + +``` json +GET users/_search +{ + "query": { + "term": { + "firstName.keyword": { + "value": "John" + } + } + }, + "from": 0, + "size": 20, + "sort": [{ + "age": { + "order": "asc" + } + }] +} +``` + +::: +:::: + +### With custom mapping + +:::: code-group +::: code-group-item C# + +``` csharp +var gq = new GridifyQuery() +{ + Filter = "name=John, surname=Smith, age=30, totalOrderPrice=45", + Page = 1, + PageSize = 20, + OrderBy = "Age" +}; + +var mapper = new GridifyMapper() + .AddMap("name", x => x.FirstName) + .AddMap("surname", x => x.LastName) + .AddMap("age", x => x.Age) + .AddMap("totalOrderPrice", x => x.Order.TotalSum); + +var response = await client.SearchAsync(s => s + .Index("users") + .ApplyFilteringOrderingPaging(gq)); + +return response.Documents; +``` + +::: + +::: code-group-item JSON + +``` json +GET users/_search +{ + "query": { + "bool": { + "must": [ + { + "term": { + "firstName.keyword": { + "value": "John" + } + } + }, + { + "term": { + "lastName.keyword": { + "value": "Smith" + } + } + }, + { + "term": { + "age": { + "value": 30 + } + } + }, + { + "term": { + "order.totalSum": { + "value": 45 + } + } + } + ] + } + }, + "from": 0, + "size": 20, + "sort": [{ + "age": { + "order": "asc" + } + }] +} +``` + +::: +:::: + +### With CustomElasticsearchNamingAction initialized + +By default, Elasticsearch converts property names to camel-case for document fields. That's Gridify.Elasticsearch extensions work by default. But if it's necessary to apply a custom naming policy, it can also be customized. + +:::: code-group +::: code-group-item C# + +``` csharp +Func? namingAction = p => $"_{p}_"; +var mapper = new GridifyMapper(autoGenerateMappings: true) +{ + Configuration = { CustomElasticsearchNamingAction = namingAction } +}; + +var gq = new GridifyQuery() +{ + Filter = "FirstName=John", + Page = 1, + PageSize = 20, + OrderBy = "Age" +}; + +var response = await client.SearchAsync(s => s + .Index("users") + .ApplyFilteringOrderingPaging(gq)); +``` + +::: + +::: code-group-item JSON + +``` json +GET users/_search +{ + "query": { + "term": { + "_FirstName_.keyword": { + "value": "John" + } + } + }, + "from": 0, + "size": 20, + "sort": [{ + "_Age_": { + "order": "asc" + } + }] +} +``` + +::: +:::: diff --git a/docs/guide/extensions.md b/docs/guide/extensions.md index 66dda000..d8296e44 100644 --- a/docs/guide/extensions.md +++ b/docs/guide/extensions.md @@ -1,6 +1,13 @@ # Extensions + +::: warning +Some of the extensions are not provided by Gridify.Elasticsearch. +::: + The Gridify library adds below extension methods to `IQueryable` objects. +The Gridify.Elasticsearch library adds almost the same extensions methods as Gridify with some small differences. + All Gridify extension methods can accept [GridifyQuery](./gridifyQuery.md) and [GridifyMapper](./gridifyMapper.md) as parameter. make sure to checkout the documentation of these classes for more information. @@ -9,57 +16,292 @@ If you want to use Gridify extension methods on an `IEnumerable` object, use `.A ::: ## ApplyFiltering -You can use this method if you want to only apply **filtering** on a IQueriable or DbSet. + +You can use this method if you want to only apply **filtering** on a `IQueriable`, `DbSet` or `SearchRequestDescriptor`. + +:::: code-group +::: code-group-item LINQ ``` csharp var query = personsRepo.ApplyFiltering("name = John"); ``` + this is completely equivalent to the bellow LINQ query: + ``` csharp var query = personsRepo.Where(p => p.Name == "John"); ``` the main difference is in the first example, we are using a string to filter, that can be dynamicly generated or passed from end-user but in the second example, we should hard code the query for supported fields. +::: + +::: code-group-item Elasticsearch + +``` csharp +await client.SearchAsync(s => s + .Index("users") + .ApplyFiltering("name = John")); +``` -checkout the [Filtering Operators](./filtering.md) section for more information. +this will make the next Elasticsearch query: + +``` json +GET users/_search +{ + "query": { + "term": { + "name.keyword": { + "value": "John" + } + } + } +} +``` + +::: +:::: + +Checkout the [Filtering Operators](./filtering.md) section for more information. ## ApplyOrdering -You can use this method if you want to only apply **ordering** on an `IQueriable` collection or `DbSet`. + +You can use this method if you want to only apply **ordering** on an `IQueriable` collection, `DbSet`, or `SearchRequestDescriptor`. + +:::: code-group +::: code-group-item LINQ ``` csharp var query = personsRepo.ApplyOrdering("name, age desc"); ``` + this is completely equivalent to the bellow LINQ query: + ``` csharp var query = personsRepo.OrderBy(x => x.Name).ThenByDescending(x => x.Age); ``` -checkout the [Ordering](./ordering.md) section for more information. + +::: + +::: code-group-item Elasticsearch + +``` csharp +await client.SearchAsync(s => s + .Index("users") + .ApplyOrdering("name, age desc")); +``` + +this will make the next Elasticsearch query: + +``` json +GET users/_search +{ + "sort": [ + { + "name.keyword": { + "order": "asc" + } + }, + { + "age": { + "order": "desc" + } + } + ] +} +``` + +::: +:::: + +Checkout the [Ordering](./ordering.md) section for more information. ## ApplyPaging -You can use this method if you want to only apply **paging** on an `IQueryable` collection or `DbSet`. + +You can use this method if you want to only apply **paging** on an `IQueryable` collection, `DbSet` or `SearchRequestDescriptor`. + +:::: code-group +::: code-group-item LINQ ``` csharp -var query = personsRepo.ApplyPaging(3 , 20); +var query = personsRepo.ApplyPaging(3, 20); ``` + this is completely equivalent to the bellow LINQ query: + ``` csharp var query = personsRepo.Skip((3-1) * 20).Take(20); ``` +::: + +::: code-group-item Elasticsearch + +``` csharp +await client.SearchAsync(s => s + .Index("users") + .ApplyPaging(3, 20)); +``` + +this will make the next Elasticsearch query: + +``` json +GET users/_search +{ + "from": 40, + "size": 20 +} +``` + +::: +:::: + ## ApplyFilteringAndOrdering -You can use this method if you want to apply **filtering** and **ordering** on an `IQueryable` collection or `DbSet`. this method accepts `IGridifyQuery`. + +You can use this method if you want to apply **filtering** and **ordering** on an `IQueryable` collection, `DbSet` or `SearchRequestDescriptor`. This method accepts `IGridifyQuery`. ## ApplyOrderingAndPaging -You can use this method if you want to apply **ordering** and **paging** on an `IQueryable` collection or `DbSet`. this method accepts `IGridifyQuery`. + +You can use this method if you want to apply **ordering** and **paging** on an `IQueryable` collection, `DbSet` or `SearchRequestDescriptor`. This method accepts `IGridifyQuery`. ## ApplyFilteringOrderingPaging -You can use this method if you want to apply **filtering** and **ordering** and **paging** on a `IQueryable` collection or `DbSet`. this method accepts `IGridifyQuery`. + +You can use this method if you want to apply **filtering** and **ordering** and **paging** on a `IQueryable` collection, `DbSet` or `SearchRequestDescriptor`. This method accepts `IGridifyQuery`. ## GridifyQueryable + +::: warning +Is not supported by Gridify.Elasticsearch. +::: + Like [ApplyFilteringOrderingPaging](#ApplyFilteringOrderingPaging) but it returns a `QueryablePaging` that have an extra `int Count` value that can be used for pagination. ## Gridify + +::: warning +Is not supported by Gridify.Elasticsearch. +::: + This is an ALL-IN-ONE package, it accepts `IGridifyQuery`, applies filtering, ordering, and paging, and returns a `Paging` object. -this method is completely optimized to be used with any **Grid** component. +This method is completely optimized to be used with any **Grid** component. + +## ToElasticsearchQuery + +This extension can be useful if you need to apply additional filters to the query. + +:::: code-group +::: code-group-item C# + +``` csharp +var query = "name = John".ToElasticsearchQuery(); + +await client.SearchAsync(s => s + .Index("users") + .Query(query)); +``` + +::: + +::: code-group-item JSON + +``` json +GET users/_search +{ + "query": { + "term": { + "name.keyword": { + "value": "John" + } + } + } +} +``` + +::: +:::: + +The ability to build `Query` object can be useful, if you need to apply additional filtering to it. E.g. you need to restrict data according to the organization. +:::: code-group +::: code-group-item C# +``` csharp +var query = "name = John".ToElasticsearchQuery(); +query &= new TermQuery("organizationId") { Value = 123 }; + +await client.SearchAsync(s => s + .Index("users") + .Query(query)); +``` + +::: + +::: code-group-item JSON + +``` json +GET users/_search +{ + "query": { + "bool": { + "must": [ + { + "term": { + "Name.keyword": { + "value": "John" + } + } + }, + { + "term": { + "organizationId": { + "value": 123 + } + } + } + ] + } + } +} +``` + +::: +:::: + +## ToElasticsearchSortOptions + +This extension can be useful if you need to apply additional sorting to the query. + +:::: code-group +::: code-group-item C# + +``` csharp +var sort = "name, age desc".ToElasticsearchSortOptions(); + +await client.SearchAsync(s => s + .Index("users") + .Sort(sort)); +``` + +::: + +::: code-group-item JSON + +``` json +GET users/_search +{ + "sort": [ + { + "name.keyword": { + "order": "asc" + } + }, + { + "age": { + "order": "desc" + } + } + ] +} +``` + +::: +:::: diff --git a/docs/guide/filtering.md b/docs/guide/filtering.md index 230e8011..e397a03c 100644 --- a/docs/guide/filtering.md +++ b/docs/guide/filtering.md @@ -1,26 +1,30 @@ # Filtering +::: warning +Not all features described here are supported by Gridify.Elasticsearch. +::: + Gridify supports the following filtering operators: ## Conditional Operators -| Name | Operator | Usage example | -| --------------------- | -------- | --------------------------------------------------------- | -| Equal | `=` | `"FieldName = Value"` | -| NotEqual | `!=` | `"FieldName !=Value"` | -| LessThan | `<` | `"FieldName < Value"` | -| GreaterThan | `>` | `"FieldName > Value"` | -| GreaterThanOrEqual | `>=` | `"FieldName >=Value"` | -| LessThanOrEqual | `<=` | `"FieldName <=Value"` | -| Contains - Like | `=*` | `"FieldName =*Value"` | -| NotContains - NotLike | `!*` | `"FieldName !*Value"` | -| StartsWith | `^` | `"FieldName ^ Value"` | -| NotStartsWith | `!^` | `"FieldName !^ Value"` | -| EndsWith | `$` | `"FieldName $ Value"` | -| NotEndsWith | `!$` | `"FieldName !$ Value"` | +| Name | Operator | Usage example | +|-----------------------|----------|------------------------| +| Equal | `=` | `"FieldName = Value"` | +| NotEqual | `!=` | `"FieldName !=Value"` | +| LessThan | `<` | `"FieldName < Value"` | +| GreaterThan | `>` | `"FieldName > Value"` | +| GreaterThanOrEqual | `>=` | `"FieldName >=Value"` | +| LessThanOrEqual | `<=` | `"FieldName <=Value"` | +| Contains - Like | `=*` | `"FieldName =*Value"` | +| NotContains - NotLike | `!*` | `"FieldName !*Value"` | +| StartsWith | `^` | `"FieldName ^ Value"` | +| NotStartsWith | `!^` | `"FieldName !^ Value"` | +| EndsWith | `$` | `"FieldName $ Value"` | +| NotEndsWith | `!$` | `"FieldName !$ Value"` | ::: tip -If you don't specify any value after `=` or `!=` operators, gridify search for the `default` and `null` values. +If you don't specify any value after `=` or `!=` operators, Gridify search for the `default` and `null` values. ``` csharp var x = personsRepo.ApplyFiltering("name="); @@ -36,20 +40,29 @@ var x = personsRepo.Where(p => ::: +::: warning +For now, it works a bit differently in Gridify.Elasticsearch compared to Gridify. It will be fixed in the future to be +consistent. +::: + ## Special Operators ### Logical Operators Using logical operators we easily can create complex queries. -| Name | Operator | Usage example | -| --------------------- | -------- | --------------------------------------------------------- | -| AND | `,` | `"FirstName = Value, LastName = Value2"` | -| OR | | | "FirstName=Value|LastName=Value2" -| Parenthesis | `()` | "(FirstName=*Jo,Age<30)|(FirstName!=Hn,Age>30)" | +| Name | Operator | Usage example | +|-------------|---------------------|-------------------------------------------------------------------| +| AND | `,` | `"FirstName = Value, LastName = Value2"` | +| OR | | | "FirstName=Value|LastName=Value2" | +| Parenthesis | `()` | "(FirstName=*Jo,Age<30)|(FirstName!=Hn,Age>30)" | ### Case Insensitive Operator - /i +::: warning +Is not supported by Gridify.Elasticsearch. +::: + The **'/i'** operator can be use after string values for case insensitive searches. You should only use this operator after the search value. @@ -63,7 +76,9 @@ this query matches with JOHN - john - John - jOHn ... ## Escaping -Gridify have five special operators `, | ( ) /i` to handle complex queries and case-insensitive searches. If you want to use these characters in your query values (after conditional operator), you should add a backslash \ before them. having this regex could be helpfull `([(),|]|\/i)`. +Gridify have five special operators `, | ( ) /i` to handle complex queries and case-insensitive searches. If you want +to use these characters in your query values (after conditional operator), you should add a backslash \ +before them. having this regex could be helpful `([(),|]|\/i)`. JavaScript escape example: @@ -81,12 +96,17 @@ var esc = Regex.Replace(value, "([(),|]|\/i)", "\\$1" ); ## Passing Indexes -Since version `v2.3.0`, Gridify support passing indexes to the sub collections. We can pass the index using the `[ ]` brackets. +::: warning +Is not supported by Gridify.Elasticsearch. +::: + +Since version `v2.3.0`, Gridify support passing indexes to the sub collections. We can pass the index using the `[ ]` +brackets. In the bellow example we want to filter data using `8th` index of our SubCollection. ``` csharp{6} var gm = new GridifyMapper() - .AddMap("prop", (x , index) => x.SubCollection[index].SomeProp); + .AddMap("prop", (x , index) => x.SubCollection[index].SomeProp); var gq = new GridifyQuery { @@ -94,13 +114,20 @@ var gq = new GridifyQuery }; ``` -checkout [Use Indexes on Sub-Collections](./gridifyMapper.md#use-indexes-on-sub-collections) for more information. +Checkout [Use Indexes on Sub-Collections](./gridifyMapper.md#use-indexes-on-sub-collections) for more information. ## Custom Operators -Sometimes the default Gridify operators are not enough, For example, if you need an operator for regex matching or when you are using the EntityFramework, you may want to use `EF.Functions.FreeText` rather than a LIKE with wildcards. In this case, you can define your own operators. (added in `v2.6.0`) +::: warning +Is not supported by Gridify.Elasticsearch. +::: + +Sometimes the default Gridify operators are not enough, For example, if you need an operator for regex matching or when +you are using the EntityFramework, you may want to use `EF.Functions.FreeText` rather than a LIKE with wildcards. In +this case, you can define your own operators. (added in `v2.6.0`) -To define a custom operator, you need to create a class that implements the `IGridifyOperator` interface. then you need to register it through the global [CustomOperators](./gridifyGlobalConfiguration.md#customoperators) configuration. +To define a custom operator, you need to create a class that implements the `IGridifyOperator` interface. then you need +to register it through the global [CustomOperators](./gridifyGlobalConfiguration.md#customoperators) configuration. ::: tip Custom operators must be start with the `#` character. diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 3b6c74a7..dc9e295f 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -1,17 +1,19 @@ # Getting Started -There are two packages available for gridify in the nuget repository. +There are three packages available for gridify in the nuget repository. - [Gridify](https://www.nuget.org/packages/Gridify/) -- [Gridify.EntityFrmamework](https://www.nuget.org/packages/Gridify.EntityFramework/) +- [Gridify.EntityFramework](https://www.nuget.org/packages/Gridify.EntityFramework/) +- [Gridify.Elasticsearch](https://TBD) ::: tip If you are using the Entity framework in your project, you should install the `Gridify.EntityFramework` package instead of `Gridify`. This package has the same functionality as the `Gridify` package, but it is designed to be more compatible with [Entity Framework](./entity-framework.md). -::: +In order to use Gridify with Elasticsearch it's necessary to install `Gridify.Elasticsearch`. +::: ## Installation @@ -24,6 +26,10 @@ Install-Package Gridify -Version {{ $version }} Install-Package Gridify.EntityFramework -Version {{ $version }} ``` +``` pm:no-line-numbers:no-v-pre +Install-Package Gridify.Elasticsearch -Version {{ $version }} +``` + ### .NET CLI ``` cmd:no-line-numbers:no-v-pre dotnet add package Gridify --version {{ $version }} @@ -31,6 +37,9 @@ dotnet add package Gridify --version {{ $version }} ``` cmd:no-line-numbers:no-v-pre dotnet add package Gridify.EntityFramework --version {{ $version }} ``` +``` cmd:no-line-numbers:no-v-pre +dotnet add package Gridify.Elasticsearch --version {{ $version }} +``` ## Namespace After installing the package, you can use the `Gridify` namespace to access the package classes and static Extension methods. @@ -45,4 +54,4 @@ using Gridify; There are two ways to use Gridify: - Using the [Extension](./extensions.md) methods -- Using [QueryBuilder](./querybuilder.md) +- Using [QueryBuilder](./queryBuilder.md) diff --git a/docs/guide/gridifyGlobalConfiguration.md b/docs/guide/gridifyGlobalConfiguration.md index 58a47300..dd66fb23 100644 --- a/docs/guide/gridifyGlobalConfiguration.md +++ b/docs/guide/gridifyGlobalConfiguration.md @@ -1,6 +1,10 @@ # GridifyGlobalConfiguration -Using this class you can change the default behavior and configuration of the gridify library. +::: warning +Not all features described here are implemented in Gridify.Elasticsearch. +::: + +Using this class you can change the default behavior and configuration of the Gridify library. ## General configurations @@ -13,7 +17,7 @@ The default page size for the paging methods when no page size is specified. ### CaseSensitiveMapper -By default mappings are case insensitive. for example, `name=John` and `Name=John` are considered equal. +By default mappings are case insensitive. For example, `name=John` and `Name=John` are considered equal. You can change this behavior by setting this property to `true`. - type: `bool` @@ -22,7 +26,7 @@ You can change this behavior by setting this property to `true`. ### AllowNullSearch -This option enables the 'null' keyword in filtering operations, for example, `name=null` searches for all records with a null value for the `name` field not the string `"null"`. if you need to search for the string `"null"` you can disable this option. +This option enables the `null` keyword in filtering operations, for example, `name=null` searches for all records with a null value for the `name` field not the string `"null"`. if you need to search for the string `"null"` you can disable this option. - type: `bool` - default: `true` @@ -49,6 +53,10 @@ some ORMs like NHibernate don't support this. you can disable this behavior by s ## CustomOperators +::: warning +CustomOperators feature is not implemented in Gridify.Elasticsearch yet. +::: + Using the `Register` method of this property you can add your own custom operators. ``` csharp @@ -78,3 +86,12 @@ Simply sets the [EntityFrameworkCompatibilityLayer](#entityframeworkcompatibilit Simply sets the [EntityFrameworkCompatibilityLayer](#entityframeworkcompatibilitylayer) property to `false`. +## Elasticsearch + +### CustomElasticsearchNamingAction + +Specifies how field names are inferred from CLR property names. By default, **Elastic.Clients.Elasticsearch** uses camel-case property names. + +::: tip +Learn more in the [Elasticsearch section](./elasticsearch.md#customelasticsearchnamingaction) of the documentation. +::: diff --git a/docs/guide/gridifyMapper.md b/docs/guide/gridifyMapper.md index 08c3006e..20fcfe97 100644 --- a/docs/guide/gridifyMapper.md +++ b/docs/guide/gridifyMapper.md @@ -1,27 +1,29 @@ # GridifyMapper -Internally Gridify is using a auto generated mapper that maps your string base field names to actual properties in your entities, but sometimes we don't want to support filtering or sorting on a specific field. If you want to controll what field names are mapped to what properties, you can create a custom mapper. +::: warning +Not all features described here are compatible with Gridify.Elasticsearch. +::: +Internally Gridify is using an auto generated mapper that maps your string base field names to actual properties in your entities, but sometimes we don't want to support filtering or sorting on a specific field. If you want to control what field names are mapped to what properties, you can create a custom mapper. -to get a better understanding of how this works, consider the following example: +To get a better understanding of how this works, consider the following example: ``` csharp // sample Entities public class Person { - public string UserName {get;set;} - public string FirstName {get;set;} - public string LastName {get;set;} - public string Password {get;set;} - public Contact Contact {get;set;} - + public string UserName { get; set; } + public string FirstName { get; set; } + public string LastName { get; set; } + public string Password { get; set; } + public Contact Contact { get; set; } } + public class Contact { - public string Address {get;set;} - public int PhoneNumber {get;set;} + public string Address { get; set; } + public int PhoneNumber { get; set; } } - ``` In this example we want to: @@ -42,7 +44,6 @@ var mapper = new GridifyMapper() In the following, we will become more familiar with the above methods - ## GenerateMappings This method generates mappings for the properties of the entity, including top-level public properties and properties of nested classes up to the specified nesting depth. @@ -54,7 +55,7 @@ var mapper = new GridifyMapper() .GenerateMappings(); ``` -- To generate mappings with **control over nesting depth**, you can specify the maxNestingDepth parameter. This parameter limits how deep the mappings will be generated for nested classes. Set it to 0 for no nesting or a positive value to control the depth `(added in v2.11.0)`: +- To generate mappings with **control over nesting depth**, you can specify the `maxNestingDepth` parameter. This parameter limits how deep the mappings will be generated for nested classes. Set it to 0 for no nesting or a positive value to control the depth `(added in v2.11.0)`: ```csharp var mapper = new GridifyMapper() @@ -73,45 +74,47 @@ var mapper = new GridifyMapper(true); ## RemoveMap -This method removes mapping from the mapper. Usually you will use this method after you have generated the mappings to ignore some properties that you don't want to be supported by gridify filtering or ordering actions. +This method removes mapping from the mapper. Usually you will use this method after you have generated the mappings to ignore some properties that you don't want to be supported by Gridify filtering or ordering actions. ## AddMap + This method adds a mapping to the mapper. -- the first parameter is the name of the field you want to use in the string query. -- the second parameter is a property selector expression. -- the third parameter is an optional [value convertor](#value-convertor) expression that you can use to convert user inputs to anything you want. + +- the first parameter is the name of the field you want to use in the string query +- the second parameter is a property selector expression +- the third parameter is an optional [value convertor](#value-convertor) expression that you can use to convert user inputs to anything you want ### Value convertor + If you need to change your search values before the filtering operation you can use this feature, the third parameter of the GridifyMapper AddMap method accepts a function that you can use to convert the input values. in the above example we want to convert the userName value to lowercase before the filtering operation. + ``` csharp mapper = mapper.AddMap("userName", p => p.UserName, v => v.ToLower()); ``` ## HasMap -This method checks if the mapper has a mapping for the given field name. -## RemoveMap -This method removes a mapping from the mapper. +This method checks if the mapper has a mapping for the given field name. ## GetCurrentMaps + This method returns list of current mappings. ## GridifyMapperConfiguration ``` csharp var mapperConfig = new GridifyMapperConfiguration() - { - CaseSensitive = false, - AllowNullSearch = true, - IgnoreNotMappedFields = false - }; +{ + CaseSensitive = false, + AllowNullSearch = true, + IgnoreNotMappedFields = false +}; var mapper = new GridifyMapper(mapperConfig); ``` - ### CaseSensitive By default mapper is `Case-insensitive` but you can change this behavior if you need `Case-Sensitive` mappings. @@ -119,43 +122,49 @@ By default mapper is `Case-insensitive` but you can change this behavior if you - Type: `bool` - Default: `false` - ``` csharp -var mapper = new GridifyMapper( q => q.CaseSensitive = true ); +var mapper = new GridifyMapper(q => q.CaseSensitive = true); ``` ### IgnoreNotMappedFields + By setting this to `true` Gridify don't throw an exception when a field name is not mapped. for instance, in the above example, searching for `password` will not throw an exception. - Type: `bool` - Default: `false` ``` csharp -var mapper = new GridifyMapper( q => q.IgnoreNotMappedFields = true ); +var mapper = new GridifyMapper(q => q.IgnoreNotMappedFields = true); ``` - ### AllowNullSearch + By setting this to `false`, Gridify don't allow searching on null values using the `null` keyword for values. - Type: `bool` - Default: `true` - ``` csharp -var mapper = new GridifyMapper( q => q.AllowNullSearch = false ); +var mapper = new GridifyMapper(q => q.AllowNullSearch = false); ``` ## Sub Collections + +::: warning +Sub Collections are not supported by Gridify.Elasticsearch. +::: + ### Filtering on Nested Collections + You can use LINQ `Select` and `SelectMany` methods to filter your data using its nested collections. In this example, we have 3 nested collections, but filtering will apply to the `Property1` of the third level. + ``` csharp var mapper = new GridifyMapper() .AddMap("prop1", l1 => l1.Level2List .SelectMany(l2 => l2.Level3List) - .Select(l3 => l3.Property1); + .Select(l3 => l3.Property1)); // ... var query = level1List.ApplyFiltering("prop1 = 123", mapper); ``` @@ -171,18 +180,23 @@ In the bellow example we want to filter data using `8th` index of our SubCollect var gq = new GridifyQuery { Filter = "prop[8] > 10" }; var gm = new GridifyMapper() - .AddMap("prop", (x , index) => x.SubCollection[index].SomeProp); + .AddMap("prop", (x , index) => x.SubCollection[index].SomeProp); ``` checkout [Passing Indexes](./filtering.md#passing-indexes) for more information. ## GetExpression + This method returns the selector expression that you can use it in LINQ queries. + ``` csharp Expression> selector = mapper.GetExpression(nameof(Person.Name)); ``` + ## GetLambdaExpression + This method returns the selector expression that you can use it in LINQ queries. + ``` csharp LambdaExpression selector = mapper.GetLambdaExpression(nameof(Person.Name)); ``` diff --git a/docs/guide/gridifyQuery.md b/docs/guide/gridifyQuery.md index 4575ad33..55974f66 100644 --- a/docs/guide/gridifyQuery.md +++ b/docs/guide/gridifyQuery.md @@ -1,6 +1,6 @@ # GridifyQuery -GridifyQuery is a simple class for configuring Filtering, Ordering and Paging. +`GridifyQuery` is a simple class for configuring Filtering, Ordering and Paging. ``` csharp var gq = new GridifyQuery() @@ -17,7 +17,7 @@ Paging result = personsRepo.Gridify(gq); ## IsValid -This extension method, checks if the GridifyQuery (Filter, OrderBy) is valid to use with a custom mapper or the auto generated mapper and returns true or false. +This extension method, checks if the `GridifyQuery` (`Filter`, `OrderBy`) is valid to use with a custom mapper or the auto generated mapper and returns true or false. ``` csharp var gq = new GridifyQuery() { Filter = "name=John" , OrderBy = "Age" }; @@ -37,7 +37,7 @@ var gq = new GridifyQuery() { Filter = "@name=!" , OrderBy = "Age" }; bool isValid = gq.IsValid(); ``` -Optionally you can pass a custom mapper to check if the GridifyQuery is valid for that mapper. +Optionally you can pass a custom mapper to check if the `GridifyQuery` is valid for that mapper. ``` csharp var mapper = new GridifyMapper() @@ -57,5 +57,3 @@ var gq = new GridifyQuery() { Filter = "name=John" }; Expression> expression = gq.GetFilteringExpression(); var result = personsRepo.Where(expression); ``` - - diff --git a/docs/guide/ordering.md b/docs/guide/ordering.md index 9c93234f..42d1fc7d 100644 --- a/docs/guide/ordering.md +++ b/docs/guide/ordering.md @@ -1,13 +1,18 @@ # Ordering +::: warning +Not all features described here are supported by Gridify.Elasticsearch. +::: + The ordering query expression can be built with a comma-delimited ordered list of field/property names followed by **`asc`** or **`desc`** keywords. -by default, if you don't add these keywords, gridify assumes you need Ascending ordering. +By default, if you don't add these keywords, Gridify assumes you need Ascending ordering. ascending and descending :::: code-group -::: code-group-item Extensions +::: code-group-item LINQ Extensions + ``` csharp // asc - desc var x = personsRepo.ApplyOrdering("Id"); // default ascending its equal to "Id asc" @@ -16,9 +21,30 @@ var x = personsRepo.ApplyOrdering("Id desc"); // use descending ordering // multiple orderings example var x = personsRepo.ApplyOrdering("Id desc, FirstName asc, LastName"); ``` + +::: + +::: code-group-item Elasticsearch Extensions + +``` csharp +// asc - desc +var response = await client.SearchAsync(s => s + .Index("users") + .ApplyOrdering("Id")); // default ascending its equal to "Id asc" +var response = await client.SearchAsync(s => s + .Index("users") + .ApplyOrdering("Id desc")); // use descending ordering + +// multiple orderings example +var response = await client.SearchAsync(s => s + .Index("users") + .ApplyOrdering("Id desc, FirstName asc, LastName")); +``` + ::: ::: code-group-item GridifyQuery + ``` csharp // asc - desc var gq = new GridifyQuery() { OrderBy = "Id" }; // default ascending its equal to "Id asc" @@ -27,9 +53,11 @@ var gq = new GridifyQuery() { OrderBy = "Id desc" }; // use descending ordering // multiple orderings example var gq = new GridifyQuery() { OrderBy = "Id desc, FirstName asc, LastName" }; ``` + ::: ::: code-group-item QueryBuilder + ``` csharp var builder = new QueryBuilder(); // asc - desc @@ -39,11 +67,16 @@ builder.AddOrderBy("Id desc"); // use descending ordering // multiple orderings example builder.AddOrderBy("Id desc, FirstName asc, LastName"); ``` + ::: :::: ## Order By Nullable types +::: warning +Is not supported by Gridify.Elasticsearch. +::: + Sometimes we need to order by nullable types, for example: ``` csharp diff --git a/docs/guide/queryBuilder.md b/docs/guide/queryBuilder.md index b235f1fa..da4b18cc 100644 --- a/docs/guide/queryBuilder.md +++ b/docs/guide/queryBuilder.md @@ -1,34 +1,34 @@ # QueryBuilder -The QueryBuilder class is really useful if you want to manually build your query Or when you don't want to use the extension methods. - -| Method | Description | -| ------------------------ | ----------------------------------------------------------------------------------------------------------------------------- | -| AddCondition | Adds a string base Filtering query | -| AddOrderBy | Adds a string base Ordering query | -| ConfigurePaging | Configure Page and PageSize | -| AddQuery | Accepts a GridifyQuery object to configure filtering,ordering and paging | -| UseCustomMapper | Accepts a GridifyMapper to use in build methods | -| UseEmptyMapper | Setup an Empty new GridifyMapper without auto generated mappings | -| AddMap | Add a single Map to existing mapper | -| RemoveMap | Remove a single Map from existing mapper | -| ConfigureDefaultMapper | Configuring default mapper when we didn't use AddMapper method | -| IsValid | Validates Condition, OrderBy, Query , Mapper and returns a bool | -| Build | Applies filtering ordering and paging to a queryable context | -| BuildCompiled | Compiles the expressions and returns a delegate for applying filtering ordering and paging to a enumerable collection | -| BuildFilteringExpression | Returns filtering expression that can be compiled for later use for enumerable collections | -| BuildEvaluator | Returns an evaluator delegate that can be use to evaluate an queryable context | -| BuildCompiledEvaluator | Returns an compiled evaluator delegate that can be use to evaluate an enumerable collection | -| BuildWithPaging | Applies filtering ordering and paging to a context, and returns paging result | -| BuildWithPagingCompiled | Compiles the expressions and returns a delegate for applying filtering ordering and paging to a enumerable collection, that returns paging result | -| BuildWithQueryablePaging | Applies filtering ordering and paging to a context, and returns queryable paging result | -| Evaluate | Directly Evaluate a context to check if all conditions are valid or not | +The `QueryBuilder` class is really useful if you want to manually build your query Or when you don't want to use the +extension methods. +| Method | Description | +|--------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------| +| AddCondition | Adds a string base Filtering query | +| AddOrderBy | Adds a string base Ordering query | +| ConfigurePaging | Configure Page and PageSize | +| AddQuery | Accepts a GridifyQuery object to configure filtering,ordering and paging | +| UseCustomMapper | Accepts a GridifyMapper to use in build methods | +| UseEmptyMapper | Setup an Empty new GridifyMapper without auto generated mappings | +| AddMap | Add a single Map to existing mapper | +| RemoveMap | Remove a single Map from existing mapper | +| ConfigureDefaultMapper | Configuring default mapper when we didn't use AddMapper method | +| IsValid | Validates Condition, OrderBy, Query , Mapper and returns a bool | +| Build | Applies filtering ordering and paging to a queryable context | +| BuildCompiled | Compiles the expressions and returns a delegate for applying filtering ordering and paging to a enumerable collection | +| BuildFilteringExpression | Returns filtering expression that can be compiled for later use for enumerable collections | +| BuildEvaluator | Returns an evaluator delegate that can be use to evaluate an queryable context | +| BuildCompiledEvaluator | Returns an compiled evaluator delegate that can be use to evaluate an enumerable collection | +| BuildWithPaging | Applies filtering ordering and paging to a context, and returns paging result | +| BuildWithPagingCompiled | Compiles the expressions and returns a delegate for applying filtering ordering and paging to a enumerable collection, that returns paging result | +| BuildWithQueryablePaging | Applies filtering ordering and paging to a context, and returns queryable paging result | +| Evaluate | Directly Evaluate a context to check if all conditions are valid or not | ``` csharp var builder = new QueryBuilder() .AddCondition("name=John") .AddOrderBy("age, id"); - var query = builder.build(persons); +var query = builder.Build(persons); ``` diff --git a/src/Gridify.Elasticsearch/GridifyExtensions.cs b/src/Gridify.Elasticsearch/GridifyExtensions.cs index b56ac275..0463ec80 100644 --- a/src/Gridify.Elasticsearch/GridifyExtensions.cs +++ b/src/Gridify.Elasticsearch/GridifyExtensions.cs @@ -1,4 +1,4 @@ -using System.Collections.Generic; +using System.Collections.Generic; using System.Linq; using Elastic.Clients.Elasticsearch; using Elastic.Clients.Elasticsearch.QueryDsl; @@ -8,6 +8,14 @@ namespace Gridify.Elasticsearch; public static class GridifyExtensions { + /// + /// Converts a Gridify filter string to an Elasticsearch DSL object. + /// + /// Gridify filter string + /// Gridify mapper + /// Entity type + /// Elasticsearch DSL object + /// Throws when the filter string is invalid public static Query ToElasticsearchQuery(this string? filter, IGridifyMapper? mapper = null) { if (string.IsNullOrWhiteSpace(filter)) @@ -23,6 +31,13 @@ public static Query ToElasticsearchQuery(this string? filter, IGridifyMapper< return queryExpression; } + /// + /// Converts a Gridify filter string to an Elasticsearch sort options. + /// + /// Gridify ordering string + /// Gridify mapper + /// Entity type + /// Elasticsearch sort options public static ICollection ToElasticsearchSortOptions(this string? ordering, IGridifyMapper? mapper = null) { if (string.IsNullOrWhiteSpace(ordering)) @@ -32,6 +47,14 @@ public static ICollection ToElasticsearchSortOptions(this string return sortOptions; } + /// + /// Applies Gridify filter string to an Elasticsearch descriptor. + /// + /// Elasticsearch descriptor + /// Gridify filter string + /// Gridify mapper + /// Entity type + /// Elasticsearch descriptor public static SearchRequestDescriptor ApplyFiltering( this SearchRequestDescriptor descriptor, string? filter, IGridifyMapper? mapper = null) { @@ -40,12 +63,28 @@ public static SearchRequestDescriptor ApplyFiltering( return descriptor; } + /// + /// Applies to an Elasticsearch descriptor. + /// + /// Elasticsearch descriptor + /// Gridify query + /// Gridify mapper + /// Entity type + /// Elasticsearch descriptor public static SearchRequestDescriptor ApplyFiltering( this SearchRequestDescriptor descriptor, IGridifyQuery gridifyQuery, IGridifyMapper? mapper = null) { return descriptor.ApplyFiltering(gridifyQuery.Filter, mapper); } + /// + /// Applies Gridify ordering string to an Elasticsearch descriptor. + /// + /// Elasticsearch descriptor + /// Gridify ordering string + /// Gridify mapper + /// Entity type + /// Elasticsearch descriptor public static SearchRequestDescriptor ApplyOrdering( this SearchRequestDescriptor descriptor, string? ordering, IGridifyMapper? mapper = null) { @@ -54,12 +93,29 @@ public static SearchRequestDescriptor ApplyOrdering( return descriptor; } + /// + /// Applies ordering to an Elasticsearch descriptor. + /// + /// Elasticsearch descriptor + /// Gridify query + /// Gridify mapper + /// Entity type + /// Elasticsearch descriptor public static SearchRequestDescriptor ApplyOrdering( this SearchRequestDescriptor descriptor, IGridifyQuery gridifyQuery, IGridifyMapper? mapper = null) { return descriptor.ApplyOrdering(gridifyQuery.OrderBy, mapper); } + /// + /// Applies paging to an Elasticsearch descriptor. + /// Fixes paging data if it is invalid. + /// + /// Elasticsearch descriptor + /// Page number + /// Page size + /// Entity type + /// Elasticsearch descriptor public static SearchRequestDescriptor ApplyPaging( this SearchRequestDescriptor descriptor, int page, int pageSize) { @@ -67,12 +123,28 @@ public static SearchRequestDescriptor ApplyPaging( return descriptor.ApplyPaging((IGridifyPagination)gridifyQuery); } + /// + /// Applies paging to an Elasticsearch descriptor. + /// Fixes paging data if it is invalid. + /// + /// Elasticsearch descriptor + /// Gridify query + /// Entity type + /// Elasticsearch descriptor public static SearchRequestDescriptor ApplyPaging( this SearchRequestDescriptor descriptor, IGridifyQuery gridifyQuery) { return descriptor.ApplyPaging((IGridifyPagination)gridifyQuery); } + /// + /// Applies paging to an Elasticsearch descriptor. + /// Fixes paging data if it is invalid. + /// + /// Elasticsearch descriptor + /// Gridify pagination + /// Entity type + /// Elasticsearch descriptor public static SearchRequestDescriptor ApplyPaging( this SearchRequestDescriptor descriptor, IGridifyPagination gridifyPagination) { @@ -82,6 +154,15 @@ public static SearchRequestDescriptor ApplyPaging( .Size(gridifyPagination.PageSize); } + /// + /// Applies filtering, ordering and paging to an Elasticsearch descriptor. + /// + /// Elasticsearch descriptor + /// Gridify filter string + /// Gridify ordering string + /// Gridify mapper + /// Entity type + /// Elasticsearch descriptor public static SearchRequestDescriptor ApplyFilteringAndOrdering( this SearchRequestDescriptor descriptor, string? filter, string? ordering, IGridifyMapper? mapper = null) { @@ -90,12 +171,31 @@ public static SearchRequestDescriptor ApplyFilteringAndOrdering( .ApplyOrdering(ordering, mapper); } + /// + /// Applies filtering, ordering and paging to an Elasticsearch descriptor. + /// + /// Elasticsearch descriptor + /// Gridify query + /// Gridify mapper + /// Entity type + /// Elasticsearch descriptor public static SearchRequestDescriptor ApplyFilteringAndOrdering( this SearchRequestDescriptor descriptor, IGridifyQuery gridifyQuery, IGridifyMapper? mapper = null) { return descriptor.ApplyFilteringAndOrdering(gridifyQuery.Filter, gridifyQuery.OrderBy, mapper); } + /// + /// Applies filtering, ordering and paging to an Elasticsearch descriptor. + /// + /// Elasticsearch descriptor + /// Gridify filter string + /// Gridify ordering string + /// Page number + /// Page size + /// Gridify mapper + /// Entity type + /// Elasticsearch descriptor public static SearchRequestDescriptor ApplyFilteringOrderingPaging( this SearchRequestDescriptor descriptor, string? filter, string? ordering, int page, int pageSize, IGridifyMapper? mapper = null) { @@ -104,6 +204,14 @@ public static SearchRequestDescriptor ApplyFilteringOrderingPaging( .ApplyPaging(page, pageSize); } + /// + /// Applies filtering, ordering and paging to an Elasticsearch descriptor. + /// + /// Elasticsearch descriptor + /// Gridify query + /// Gridify mapper + /// Entity type + /// Elasticsearch descriptor public static SearchRequestDescriptor ApplyFilteringOrderingPaging( this SearchRequestDescriptor descriptor, IGridifyQuery gridifyQuery, IGridifyMapper? mapper = null) { @@ -112,6 +220,16 @@ public static SearchRequestDescriptor ApplyFilteringOrderingPaging( .ApplyPaging(gridifyQuery.Page, gridifyQuery.PageSize); } + /// + /// Applies ordering and paging to an Elasticsearch descriptor. + /// + /// Elasticsearch descriptor + /// Gridify ordering string + /// Page number + /// Page size + /// Gridify mapper + /// Entity type + /// Elasticsearch descriptor public static SearchRequestDescriptor ApplyOrderingAndPaging( this SearchRequestDescriptor descriptor, string? ordering, int page, int pageSize, IGridifyMapper? mapper = null) { @@ -120,6 +238,14 @@ public static SearchRequestDescriptor ApplyOrderingAndPaging( .ApplyPaging(page, pageSize); } + /// + /// Applies ordering and paging to an Elasticsearch descriptor. + /// + /// Elasticsearch descriptor + /// Gridify query + /// Gridify mapper + /// Entity type + /// Elasticsearch descriptor public static SearchRequestDescriptor ApplyOrderingAndPaging( this SearchRequestDescriptor descriptor, IGridifyQuery gridifyQuery, IGridifyMapper? mapper = null) { diff --git a/src/Gridify/GridifyExtensions.cs b/src/Gridify/GridifyExtensions.cs index f6602a31..b751a969 100644 --- a/src/Gridify/GridifyExtensions.cs +++ b/src/Gridify/GridifyExtensions.cs @@ -10,7 +10,7 @@ namespace Gridify; public static partial class GridifyExtensions { /// - /// Set default Page number and PageSize if its not already set in gridifyQuery + /// Sets default Page number and PageSize if its not already set in gridifyQuery /// /// query and paging configuration /// returns a IGridifyPagination with valid PageSize and Page @@ -168,7 +168,7 @@ private static IGridifyMapper GetDefaultMapper() /// /// if given mapper was null this function creates default generated mapper /// - /// a GridifyMapper that can be null + /// a GridifyMapper that can be null /// optional syntaxTree to Lazy mapping generation /// type to set mappings /// return back mapper or new generated mapper if it was null @@ -205,7 +205,7 @@ private static IEnumerable Descendants(this SyntaxNode root) } /// - /// adds Filtering,Ordering And Paging to the query + /// Adds Filtering, Ordering And Paging to the query /// /// the original(target) queryable object /// the configuration to apply paging, filtering and ordering @@ -358,7 +358,7 @@ internal static IEnumerable ParseOrderings(this string orderings _ => throw new GridifyOrderingException("Invalid keyword. expected 'desc' or 'asc'") }; var member = spliced.First(); - yield return new ParsedOrdering() + yield return new ParsedOrdering { MemberName = member.ReplaceAll(nullableChars, ' ').TrimEnd(), IsAscending = isAsc, @@ -369,7 +369,7 @@ internal static IEnumerable ParseOrderings(this string orderings } else { - yield return new ParsedOrdering() + yield return new ParsedOrdering { MemberName = orderingExp.ReplaceAll(nullableChars, ' ').TrimEnd(), IsAscending = true, diff --git a/src/Gridify/GridifyGlobalConfiguration.cs b/src/Gridify/GridifyGlobalConfiguration.cs index c93bd541..23a91350 100644 --- a/src/Gridify/GridifyGlobalConfiguration.cs +++ b/src/Gridify/GridifyGlobalConfiguration.cs @@ -52,8 +52,8 @@ public static class GridifyGlobalConfiguration /// By default, Elastic.Clients.Elasticsearch uses camel-case property names. /// /// - /// If false CLR property EmailAddress will be inferred as "emailAddress" Elasticsearch document field name - /// If true, the CLR property EmailAddress will be inferred as "EmailAddress" Elasticsearch document field name + /// If null (default behavior) CLR property EmailAddress will be inferred as "emailAddress" Elasticsearch document field name. + /// If, e.g., p => p, the CLR property EmailAddress will be inferred as "EmailAddress" Elasticsearch document field name. /// public static Func? CustomElasticsearchNamingAction { get; set; } diff --git a/src/Gridify/GridifyMapperConfiguration.cs b/src/Gridify/GridifyMapperConfiguration.cs index 1f681e2b..45dfed77 100644 --- a/src/Gridify/GridifyMapperConfiguration.cs +++ b/src/Gridify/GridifyMapperConfiguration.cs @@ -29,8 +29,8 @@ public record GridifyMapperConfiguration /// By default, Elastic.Clients.Elasticsearch uses camel-case property names. /// /// - /// If false CLR property EmailAddress will be inferred as "emailAddress" Elasticsearch document field name - /// If true, the CLR property EmailAddress will be inferred as "EmailAddress" Elasticsearch document field name + /// If null (default behavior) CLR property EmailAddress will be inferred as "emailAddress" Elasticsearch document field name. + /// If, e.g., p => p, the CLR property EmailAddress will be inferred as "EmailAddress" Elasticsearch document field name. /// public Func? CustomElasticsearchNamingAction { get; set; } = GridifyGlobalConfiguration.CustomElasticsearchNamingAction; } From 4aad3cb5111618d3398cd0c488133af205b9986b Mon Sep 17 00:00:00 2001 From: Dzmitry Koush Date: Sat, 21 Oct 2023 16:54:21 +0200 Subject: [PATCH 08/13] fix: remove extra spaces from expected results in tests --- .husky/task-runner.json | 5 +- .../GridifyExtensionsTests.cs | 50 +++++++++---------- 2 files changed, 29 insertions(+), 26 deletions(-) diff --git a/.husky/task-runner.json b/.husky/task-runner.json index 21250b88..360129bd 100644 --- a/.husky/task-runner.json +++ b/.husky/task-runner.json @@ -6,10 +6,13 @@ "args": [ "dotnet-format", "--include", - "${staged}" + "${staged}", + "--exclude", + "**/*.Tests/**/*.cs" ], "include": [ "**/*.cs", + "!**/*.Tests/**/*.cs", "**/*.vb" ] }, diff --git a/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs b/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs index 70803cae..637842fa 100644 --- a/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs +++ b/test/Gridify.Elasticsearch.Tests/GridifyExtensionsTests.cs @@ -268,17 +268,17 @@ public void ToElasticsearchQuery_WhenCalledWithDecimalValue_ReturnsElasticsearch // string does not equal [InlineData("Name!=Dzmitry", """{"bool":{"must_not":{"term":{"Name.keyword":{"value":"Dzmitry"}}}}}""")] // string contains - [InlineData("Name=*itr", """{"wildcard":{"Name.keyword":{"value":" * itr * "}}}""")] + [InlineData("Name=*itr", """{"wildcard":{"Name.keyword":{"value":"*itr*"}}}""")] // string does not contain - [InlineData("Name!*itr", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":" * itr * "}}}}}""")] + [InlineData("Name!*itr", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"*itr*"}}}}}""")] // string starts with - [InlineData("Name^Dzm", """{"wildcard":{"Name.keyword":{"value":"Dzm * "}}}""")] + [InlineData("Name^Dzm", """{"wildcard":{"Name.keyword":{"value":"Dzm*"}}}""")] // string does not start with - [InlineData("Name!^Dzm", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"Dzm * "}}}}}""")] + [InlineData("Name!^Dzm", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"Dzm*"}}}}}""")] // string ends with - [InlineData("Name$try", """{"wildcard":{"Name.keyword":{"value":" *try"}}}""")] + [InlineData("Name$try", """{"wildcard":{"Name.keyword":{"value":"*try"}}}""")] // string does not end with - [InlineData("Name!$try", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":" *try"}}}}}""")] + [InlineData("Name!$try", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"*try"}}}}}""")] // string is null [InlineData("Name=null", """{"bool":{"must_not":{"exists":{"field":"Name"}}}}""")] // string is not null @@ -298,23 +298,23 @@ public void ToElasticsearchQuery_WhenCalledWithStringValue_ReturnsElasticsearchQ [Theory] // date equals - [InlineData("MyDateTime=2021-09-01", """{"term":{"MyDateTime":{"value":"2021 - 09 - 01T00: 00:00"}}}""")] + [InlineData("MyDateTime=2021-09-01", """{"term":{"MyDateTime":{"value":"2021-09-01T00:00:00"}}}""")] // date and time equals - [InlineData("MyDateTime=2021-09-01T00:00:00", """{"term":{"MyDateTime":{"value":"2021 - 09 - 01T00: 00:00"}}}""")] - [InlineData("MyDateTime=2021-09-01 00:00:00", """{"term":{"MyDateTime":{"value":"2021 - 09 - 01T00: 00:00"}}}""")] - [InlineData("MyDateTime=2021-09-01 23:15:11", """{"term":{"MyDateTime":{"value":"2021 - 09 - 01T23: 15:11"}}}""")] + [InlineData("MyDateTime=2021-09-01T00:00:00", """{"term":{"MyDateTime":{"value":"2021-09-01T00:00:00"}}}""")] + [InlineData("MyDateTime=2021-09-01 00:00:00", """{"term":{"MyDateTime":{"value":"2021-09-01T00:00:00"}}}""")] + [InlineData("MyDateTime=2021-09-01 23:15:11", """{"term":{"MyDateTime":{"value":"2021-09-01T23:15:11"}}}""")] // date is null [InlineData("MyDateTime=null", """{"bool":{"must_not":{"exists":{"field":"MyDateTime"}}}}""")] // date is not null [InlineData("MyDateTime!=null", """{"exists":{"field":"MyDateTime"}}""")] // date greater than - [InlineData("MyDateTime>2021-09-01", """{"range":{"MyDateTime":{"gt":"2021 - 09 - 01T00: 00:00"}}}""")] + [InlineData("MyDateTime>2021-09-01", """{"range":{"MyDateTime":{"gt":"2021-09-01T00:00:00"}}}""")] // date greater than or equal - [InlineData("MyDateTime>=2021-09-01", """{"range":{"MyDateTime":{"gte":"2021 - 09 - 01T00: 00:00"}}}""")] + [InlineData("MyDateTime>=2021-09-01", """{"range":{"MyDateTime":{"gte":"2021-09-01T00:00:00"}}}""")] // date less than - [InlineData("MyDateTime<2021-09-01", """{"range":{"MyDateTime":{"lt":"2021 - 09 - 01T00: 00:00"}}}""")] + [InlineData("MyDateTime<2021-09-01", """{"range":{"MyDateTime":{"lt":"2021-09-01T00:00:00"}}}""")] // date less than or equal - [InlineData("MyDateTime<=2021-09-01", """{"range":{"MyDateTime":{"lte":"2021 - 09 - 01T00: 00:00"}}}""")] + [InlineData("MyDateTime<=2021-09-01", """{"range":{"MyDateTime":{"lte":"2021-09-01T00:00:00"}}}""")] public void ToElasticsearchQuery_WhenCalledWithDateTimeValue_ReturnsElasticsearchQuery(string filter, string expected) { AssertFilter(filter, expected); @@ -322,19 +322,19 @@ public void ToElasticsearchQuery_WhenCalledWithDateTimeValue_ReturnsElasticsearc [Theory] // date only equals - [InlineData("MyDateOnly=2021-09-01", """{"term":{"MyDateOnly":{"value":"2021 - 09 - 01"}}}""")] + [InlineData("MyDateOnly=2021-09-01", """{"term":{"MyDateOnly":{"value":"2021-09-01"}}}""")] // date only is null [InlineData("MyDateOnly=null", """{"bool":{"must_not":{"exists":{"field":"MyDateOnly"}}}}""")] // date only is not null [InlineData("MyDateOnly!=null", """{"exists":{"field":"MyDateOnly"}}""")] // date only greater than - [InlineData("MyDateOnly>2021-09-01", """{"range":{"MyDateOnly":{"gt":"2021 - 09 - 01"}}}""")] + [InlineData("MyDateOnly>2021-09-01", """{"range":{"MyDateOnly":{"gt":"2021-09-01"}}}""")] // date only greater than or equal - [InlineData("MyDateOnly>=2021-09-01", """{"range":{"MyDateOnly":{"gte":"2021 - 09 - 01"}}}""")] + [InlineData("MyDateOnly>=2021-09-01", """{"range":{"MyDateOnly":{"gte":"2021-09-01"}}}""")] // date only less than - [InlineData("MyDateOnly<2021-09-01", """{"range":{"MyDateOnly":{"lt":"2021 - 09 - 01"}}}""")] + [InlineData("MyDateOnly<2021-09-01", """{"range":{"MyDateOnly":{"lt":"2021-09-01"}}}""")] // date only less than or equal - [InlineData("MyDateOnly<=2021-09-01", """{"range":{"MyDateOnly":{"lte":"2021 - 09 - 01"}}}""")] + [InlineData("MyDateOnly<=2021-09-01", """{"range":{"MyDateOnly":{"lte":"2021-09-01"}}}""")] public void ToElasticsearchQuery_WhenCalledWithDateOnlyValue_ReturnsElasticsearchQuery(string filter, string expected) { AssertFilter(filter, expected); @@ -356,7 +356,7 @@ public void ToElasticsearchQuery_WhenCalledWithBoolValue_ReturnsElasticsearchQue [Theory] // guid equals - [InlineData("MyGuid=69C3BB3A-3A85-4750-BA03-1F916FA5C0B1", """{"term":{"MyGuid.keyword":{"value":"69C3BB3A - 3A85 - 4750 - BA03 - 1F916FA5C0B1"}}}""")] + [InlineData("MyGuid=69C3BB3A-3A85-4750-BA03-1F916FA5C0B1", """{"term":{"MyGuid.keyword":{"value":"69C3BB3A-3A85-4750-BA03-1F916FA5C0B1"}}}""")] // guid is null [InlineData("MyGuid=null", """{"bool":{"must_not":{"exists":{"field":"MyGuid"}}}}""")] // guid is not null @@ -376,7 +376,7 @@ public void ToElasticsearchQuery_WhenCalledWithGuidValue_ReturnsElasticsearchQue // , | operators [InlineData("Id=1,Name=Dzmitry|Name=John", """{"bool":{"should":[{"bool":{"must":[{"term":{"Id":{"value":1}}},{"term":{"Name.keyword":{"value":"Dzmitry"}}}]}},{"term":{"Name.keyword":{"value":"John"}}}]}}""")] // , , , operators - [InlineData("Id=1,Name=Dzmitry,MyDateTime=2021-09-01", """{"bool":{"must":[{"term":{"Id":{"value":1}}},{"term":{"Name.keyword":{"value":"Dzmitry"}}},{"term":{"MyDateTime":{"value":"2021 - 09 - 01T00: 00:00"}}}]}}""")] + [InlineData("Id=1,Name=Dzmitry,MyDateTime=2021-09-01", """{"bool":{"must":[{"term":{"Id":{"value":1}}},{"term":{"Name.keyword":{"value":"Dzmitry"}}},{"term":{"MyDateTime":{"value":"2021-09-01T00:00:00"}}}]}}""")] // | | | operators [InlineData("Id=1|Id=2|Id=3", """{"bool":{"should":[{"term":{"Id":{"value":1}}},{"term":{"Id":{"value":2}}},{"term":{"Id":{"value":3}}}]}}""")] // ( | ) operators @@ -412,13 +412,13 @@ public void ToElasticsearchQuery_WhenCalledWithNestedClass_ReturnsElasticsearchQ // empty query [InlineData("", """{"match_all":{}}""")] // string starts with empty - [InlineData("Name^", """{"wildcard":{"Name.keyword":{"value":" * "}}}""")] + [InlineData("Name^", """{"wildcard":{"Name.keyword":{"value":"*"}}}""")] // string ends with empty - [InlineData("Name$", """{"wildcard":{"Name.keyword":{"value":" * "}}}""")] + [InlineData("Name$", """{"wildcard":{"Name.keyword":{"value":"*"}}}""")] // string does not start with empty - [InlineData("Name!^", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":" * "}}}}}""")] + [InlineData("Name!^", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"*"}}}}}""")] // string does not end with empty - [InlineData("Name!$", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":" * "}}}}}""")] + [InlineData("Name!$", """{"bool":{"must_not":{"wildcard":{"Name.keyword":{"value":"*"}}}}}""")] public void ToElasticsearchQuery_WhenCalledWithEmptyValue_ReturnsElasticsearchQuery(string filter, string expected) { AssertFilter(filter, expected); From 6bf3bfbdf34a8196a145a353206ed7f888c89ad0 Mon Sep 17 00:00:00 2001 From: Dzmitry Koush Date: Mon, 23 Oct 2023 19:17:21 +0200 Subject: [PATCH 09/13] docs: move the documentation about Gridify.Elasticsearch to a separate thread --- docs/.vuepress/config.ts | 4 + docs/.vuepress/configs/navbar.ts | 13 +- docs/.vuepress/configs/sidebar.ts | 34 +- docs/guide/README.md | 11 +- docs/guide/autoMapper.md | 4 - docs/guide/compile.md | 11 - docs/guide/elasticsearch/README.md | 17 + .../elasticsearch/dependency-injection.md | 1 + .../{ => elasticsearch}/elasticsearch.md | 0 docs/guide/elasticsearch/extensions.md | 351 ++++++++++++++++++ docs/guide/elasticsearch/filtering.md | 52 +++ docs/guide/elasticsearch/getting-started.md | 34 ++ .../gridifyGlobalConfiguration.md | 11 + docs/guide/elasticsearch/gridifyMapper.md | 1 + docs/guide/elasticsearch/gridifyQuery.md | 51 +++ docs/guide/elasticsearch/ordering.md | 55 +++ docs/guide/elasticsearch/queryBuilder.md | 1 + docs/guide/extensions.md | 244 +----------- docs/guide/filtering.md | 21 -- docs/guide/getting-started.md | 15 +- docs/guide/gridifyGlobalConfiguration.md | 67 +--- docs/guide/gridifyMapper.md | 154 +------- docs/guide/gridifyQuery.md | 2 +- docs/guide/ordering.md | 29 +- .../snippets/gridifyGlobalConfiguration.md | 48 +++ docs/guide/snippets/gridifyMapper.md | 145 ++++++++ package.json | 5 +- yarn.lock | 5 + 28 files changed, 844 insertions(+), 542 deletions(-) create mode 100644 docs/guide/elasticsearch/README.md create mode 100644 docs/guide/elasticsearch/dependency-injection.md rename docs/guide/{ => elasticsearch}/elasticsearch.md (100%) create mode 100644 docs/guide/elasticsearch/extensions.md create mode 100644 docs/guide/elasticsearch/filtering.md create mode 100644 docs/guide/elasticsearch/getting-started.md create mode 100644 docs/guide/elasticsearch/gridifyGlobalConfiguration.md create mode 100644 docs/guide/elasticsearch/gridifyMapper.md create mode 100644 docs/guide/elasticsearch/gridifyQuery.md create mode 100644 docs/guide/elasticsearch/ordering.md create mode 100644 docs/guide/elasticsearch/queryBuilder.md create mode 100644 docs/guide/snippets/gridifyGlobalConfiguration.md create mode 100644 docs/guide/snippets/gridifyMapper.md diff --git a/docs/.vuepress/config.ts b/docs/.vuepress/config.ts index 7d8a2ee2..68303018 100644 --- a/docs/.vuepress/config.ts +++ b/docs/.vuepress/config.ts @@ -2,6 +2,7 @@ import { defineUserConfig } from 'vuepress-vite' import type { DefaultThemeOptions } from 'vuepress-vite' import type { ViteBundlerOptions } from '@vuepress/bundler-vite' import { plugin, themeConfig, head } from './configs' +import markdownItInclude from 'markdown-it-include' export default defineUserConfig({ lang: 'en-US', @@ -13,4 +14,7 @@ export default defineUserConfig({ port: 3000, base: '/Gridify/', head: head, + extendsMarkdown: (md) => { + md.use(markdownItInclude); + } }) diff --git a/docs/.vuepress/configs/navbar.ts b/docs/.vuepress/configs/navbar.ts index b8c1eef5..d30bc94e 100644 --- a/docs/.vuepress/configs/navbar.ts +++ b/docs/.vuepress/configs/navbar.ts @@ -7,7 +7,18 @@ export const navbar: NavbarConfig = [ }, { text: 'Guide', - link: '/guide/', + children: [ + { + text: 'LINQ / Entity Framework', + link: '/guide/', + activeMatch: '^((?!\/guide\/elasticsearch).)*$', + }, + { + text: 'Elasticsearch', + link: '/guide/elasticsearch/', + activeMatch: '^\/guide\/elasticsearch\/*.*$', + } + ], }, { text: 'Examples', diff --git a/docs/.vuepress/configs/sidebar.ts b/docs/.vuepress/configs/sidebar.ts index f24c3b45..584550e8 100644 --- a/docs/.vuepress/configs/sidebar.ts +++ b/docs/.vuepress/configs/sidebar.ts @@ -32,11 +32,43 @@ export const sidebar: SidebarConfig = { '/guide/dependency-injection.md', '/guide/compile.md', '/guide/entity-framework.md', - '/guide/elasticsearch.md', '/guide/autoMapper.md', ] } ], + '/guide/elasticsearch/': [ + { + text: 'Introduction', + children: [ + '/guide/elasticsearch/README.md', + '/guide/elasticsearch/getting-started.md', + '/guide/elasticsearch/extensions.md', + '/guide/elasticsearch/queryBuilder.md', + ], + }, + { + text: 'Configuration', + children: [ + '/guide/elasticsearch/gridifyQuery.md', + '/guide/elasticsearch/gridifyMapper.md', + '/guide/elasticsearch/gridifyGlobalConfiguration.md', + ] + }, + { + text: 'Syntax', + children: [ + '/guide/elasticsearch/filtering.md', + '/guide/elasticsearch/ordering.md', + ] + }, + { + text: 'Advanced', + children: [ + '/guide/elasticsearch/dependency-injection.md', + '/guide/elasticsearch/elasticsearch.md', + ] + } + ], '/contribution/': [ { text: 'Contribution', diff --git a/docs/guide/README.md b/docs/guide/README.md index a92a490d..71c85fec 100644 --- a/docs/guide/README.md +++ b/docs/guide/README.md @@ -4,7 +4,7 @@ Gridify is a dynamic LINQ library that simplifies the process of converting stri performance and ease-of-use, Gridify makes it effortless to apply filtering, sorting, and pagination using text-based data. -Gridify.Elasticsearch is an extension of Gridify, that provides an ability to generate Elasticsearch DSL queries. +Gridify.Elasticsearch is an extension of Gridify, that provides an ability to generate Elasticsearch DSL queries. Read more about Gridify.Elasticsearch in [a separate thread of the documentation](./elasticsearch/). ## Features @@ -17,9 +17,6 @@ Gridify.Elasticsearch is an extension of Gridify, that provides an ability to ge - Supports collection indexes - Custom Operators - Compatible with ORMs, especially Entity Framework -- Compatible with - Elasticsearch ([Elastic.Clients.Elasticsearch 8.*](https://www.nuget.org/packages/Elastic.Clients.Elasticsearch) is a - dependency) - Can be used on every collection that LINQ supports - Compatible with object-mappers like AutoMapper @@ -32,11 +29,6 @@ To better illustrate how Gridify works, we've prepared a few examples: ## Performance -::: warning -For now, there are no benchmarks for Gridify.Elasticsearch because it builds non-LINQ query. But it uses Gridify lib as -a basis. -::: - Filtering can be the most expensive feature in Gridify. The following benchmark compares filtering in the most well-known dynamic LINQ libraries. As you can see, Gridify has the closest result to native LINQ: @@ -60,7 +52,6 @@ This Benchmark is available [Here](https://github.com/alirezanet/Gridify/blob/master/benchmark/LibraryComparisionFilteringBenchmark.cs) ::: -