diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryService.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryService.cs index 888c34fb2..e51b09fa8 100644 --- a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryService.cs +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryService.cs @@ -46,6 +46,7 @@ public class ExpressionQueryService : IExpressionQueryService, ITransientDepende int skipCount = 0, Fields? sourceExcludes = null, Fields? sourceIncludes = null, + object[]? beginMarker = null, CancellationToken cancellationToken = default) where TDocument : class { var client = ClientFactory.Create(); @@ -79,7 +80,8 @@ public class ExpressionQueryService : IExpressionQueryService, ITransientDepende maxResultCount, skipCount, sourceExcludes, - sourceIncludes, + sourceIncludes, + beginMarker, cancellationToken) : await SearchFromSize( client, @@ -137,19 +139,28 @@ public class ExpressionQueryService : IExpressionQueryService, ITransientDepende string indexName, Query query, SortOptions[] sorts, - int maxResultCount = 50, - int skipCount = 0, + int maxResultCount, + int skipCount, Fields? sourceExcludes = null, Fields? sourceIncludes = null, + object[]? beginMarker = null, CancellationToken cancellationToken = default) { - var searchAfter = await GetSearchAfterValue( - client, - indexName, - query, - sorts, - skipCount, - cancellationToken); + List? searchAfter = null; + if (beginMarker != null) + { + searchAfter = beginMarker.Select(FieldValue.FromValue).ToList(); + } + else + { + searchAfter = await GetSearchAfterValue( + client, + indexName, + query, + sorts, + skipCount, + cancellationToken); + } if (searchAfter == null || !searchAfter.Any()) { diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Collection.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Collection.cs new file mode 100644 index 000000000..ee5272a5b --- /dev/null +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Collection.cs @@ -0,0 +1,98 @@ +using Elastic.Clients.Elasticsearch.QueryDsl; +using System; +using System.Collections; +using System.Linq; +using System.Linq.Expressions; + +namespace LINGYUN.Abp.Elasticsearch; + +public partial class ExpressionQueryTranslator +{ + /// + /// 翻译集合 Contains + /// + private Query TranslateCollectionContains(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + // list.Contains(value) 或 hashSet.Contains(value) + // 有两种情况: + // 1. 外部集合.Contains(字段) - 例如:new[]{"a","b"}.Contains(x.xxx) + // 2. 字段.Contains(值) - 例如:x.Y.Contains("B") + + Expression collectionExpr = node.Object!; + Expression valueExpr = node.Arguments[0]; + + // 判断哪个是字段,哪个是值 + var collectionIsField = IsFieldLike(collectionExpr); + var valueIsField = IsFieldLike(valueExpr); + + if (collectionIsField && !valueIsField) + { + // 情况 2:字段.Contains(值) + // 这通常用于集合字段,如 x.Tags.Contains("B") + var field = ResolveField(collectionExpr, prefix, mappingInfo); + var value = Evaluate(valueExpr); + + if (value == null) + { + return new MatchNoneQuery(); + } + + // 对于集合字段,使用 Terms 查询或 Term 查询 + // 如果字段是数组或集合类型,单个值的 Contains 实际上就是 Term 查询 + return BuildEquality(field, value); + } + else if (!collectionIsField && valueIsField) + { + // 情况 1:外部集合.Contains(字段) + var field = ResolveField(valueExpr, prefix, mappingInfo); + + // 尝试获取集合的值 + var collectionValue = Evaluate(collectionExpr); + + if (collectionValue is IEnumerable enumerable && collectionValue is not string) + { + var values = enumerable.Cast().Select(NormalizeValue).ToList(); + if (values.Count == 0) + { + return new MatchNoneQuery(); + } + if (values.Count == 1) + { + // 如果集合只有一个值,使用 TermQuery + return BuildEquality(field, values[0]); + } + // 多个值使用 TermsQuery + return new TermsQuery { Field = field.Path, Terms = new TermsQueryField(values) }; + } + + // 如果集合值无法获取,使用默认处理 + var defaultValue = Evaluate(valueExpr); + return BuildEquality(field, defaultValue!); + } + else if (collectionIsField && valueIsField) + { + // 两个都是字段,这种情况较少见 + throw new NotSupportedException($"Unsupported Contains with two field expressions: {node}"); + } + else + { + // 两个都不是字段,尝试直接求值 + var collectionValue = Evaluate(collectionExpr); + var value = Evaluate(valueExpr); + + if (collectionValue is IEnumerable enumerable && collectionValue is not string) + { + if (enumerable.Cast().Contains(value)) + { + return new MatchAllQuery(); + } + else + { + return new MatchNoneQuery(); + } + } + + throw new NotSupportedException($"Unsupported Contains expression: {node}"); + } + } +} diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Enum.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Enum.cs new file mode 100644 index 000000000..413d69b02 --- /dev/null +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Enum.cs @@ -0,0 +1,25 @@ +using Elastic.Clients.Elasticsearch.QueryDsl; +using System; +using System.Linq.Expressions; + +namespace LINGYUN.Abp.Elasticsearch; + +public partial class ExpressionQueryTranslator +{ + /// + /// 翻译 Enum.HasFlag + /// + private Query TranslateEnumHasFlag(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + var field = ResolveField(node.Object!, prefix, mappingInfo); + var flag = Evaluate(node.Arguments[0]); + + if (flag == null) + { + throw new NotSupportedException("Cannot use null flag in Enum.HasFlag"); + } + + var flagValue = Convert.ToInt64(flag); + return new TermQuery { Field = field.Path, Value = flagValue }; + } +} diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Enumerable.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Enumerable.cs new file mode 100644 index 000000000..b4aeca751 --- /dev/null +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Enumerable.cs @@ -0,0 +1,97 @@ +using Elastic.Clients.Elasticsearch.QueryDsl; +using System; +using System.Linq; +using System.Linq.Expressions; + +namespace LINGYUN.Abp.Elasticsearch; + +public partial class ExpressionQueryTranslator +{ + /// + /// 翻译 Enumerable 方法 + /// + private Query TranslateEnumerableMethod(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + switch (node.Method.Name) + { + case nameof(Enumerable.Any): + return TranslateEnumerableAny(node, prefix, mappingInfo); + + case nameof(Enumerable.Contains): + return TranslateEnumerableContains(node, prefix, mappingInfo); + + case nameof(Enumerable.All): + return TranslateEnumerableAll(node, prefix, mappingInfo); + + default: + throw new NotSupportedException($"Unsupported Enumerable method {node.Method.Name}"); + } + } + + /// + /// 翻译 Enumerable.Any + /// + private Query TranslateEnumerableAny(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + var collectionField = ResolveField(node.Arguments[0], prefix, mappingInfo); + + Query inner; + if (node.Arguments.Count == 1) + { + // .Any() 检查集合是否存在 + inner = new ExistsQuery { Field = collectionField.Path }; + } + else + { + // .Any(predicate) + var predicate = UnwrapLambda(node.Arguments[1]); + inner = TranslateNode(predicate.Body, prefix: collectionField.Path, mappingInfo); + } + + var shouldUseNested = collectionField.IsNested || (mappingInfo?.IsNested(collectionField.Path) ?? false); + + return shouldUseNested + ? new NestedQuery(collectionField.Path, inner) + : inner; + } + + /// + /// 翻译 Enumerable.Contains + /// + private Query TranslateEnumerableContains(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + Expression collectionExpr; + Expression valueExpr; + + if (node.Object != null) + { + // list.Contains(value) + collectionExpr = node.Object; + valueExpr = node.Arguments[0]; + } + else + { + // Enumerable.Contains(list, value) + collectionExpr = node.Arguments[0]; + valueExpr = node.Arguments[1]; + } + + var field = ResolveField(collectionExpr, prefix, mappingInfo); + var value = Evaluate(valueExpr); + + return BuildTermsQuery(field, value!); + } + + /// + /// 翻译 Enumerable.All + /// + private Query TranslateEnumerableAll(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + var collectionField = ResolveField(node.Arguments[0], prefix, mappingInfo); + var predicate = UnwrapLambda(node.Arguments[1]); + + var inner = TranslateNode(predicate.Body, prefix: collectionField.Path, mappingInfo); + + return new NestedQuery(collectionField.Path, inner); + } +} diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Field.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Field.cs new file mode 100644 index 000000000..db4167313 --- /dev/null +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Field.cs @@ -0,0 +1,114 @@ +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json.Serialization; + +namespace LINGYUN.Abp.Elasticsearch; + +public partial class ExpressionQueryTranslator +{ + /// + /// 解析字段 + /// + protected virtual FieldInfo ResolveField(Expression expression, string? prefix, IndexMappingInfo? mappingInfo) + { + var current = expression; + while (current is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } unary) + { + current = unary.Operand; + } + + var names = new Stack(); + Type? leafType = null; + string? leafName = null; + + // 收集成员路径 + while (current is MemberExpression member) + { + leafName ??= member.Member.Name; + leafType ??= GetMemberType(member.Member); + names.Push(ResolveFieldName(member.Member)); + current = member.Expression!; + } + + if (current is not ParameterExpression && current is not ConstantExpression) + { + throw new NotSupportedException($"Unable to parse as field path: {expression}"); + } + + var path = string.Join(".", names); + + // 应用前缀 + if (!string.IsNullOrEmpty(prefix) && path.Length > 0) + { + path = prefix + "." + path; + } + else if (path.Length == 0) + { + path = prefix ?? string.Empty; + } + + // 获取字段映射信息 + var finalMapping = mappingInfo?.GetField(path); + + // 如果是 text 类型且有 keyword 子字段,自动使用 .keyword + if (finalMapping?.IsText == true && finalMapping.Properties?.ContainsKey("keyword") == true) + { + path = $"{path}.keyword"; + finalMapping = mappingInfo?.GetField(path); + } + + // 如果 leafType 为 null,使用 expression.Type + var type = leafType ?? expression.Type; + var underlyingType = Nullable.GetUnderlyingType(type) ?? type; + + return new FieldInfo( + path, + underlyingType, + leafName ?? string.Empty, + finalMapping?.IsKeyword ?? false, + finalMapping?.IsText ?? false, + finalMapping?.IsWildcard ?? false, + finalMapping?.IsNested ?? false || (mappingInfo?.IsNested(path) ?? false), + finalMapping?.IsDate ?? false, + finalMapping?.IsNumeric ?? false, + finalMapping?.IsBoolean ?? false, + finalMapping?.IsRange ?? false, + finalMapping?.Format, + finalMapping?.HasMultiFields ?? false + ); + } + + /// + /// 获取成员的实际类型 + /// + private static Type GetMemberType(MemberInfo member) + { + return member switch + { + System.Reflection.FieldInfo field => field.FieldType, + PropertyInfo property => property.PropertyType, + MethodInfo method => method.ReturnType, + _ => typeof(object) + }; + } + + /// + /// 解析字段名称 + /// + private static string ResolveFieldName(MemberInfo member) + { + // 检查 JsonPropertyName 属性 + if (member is PropertyInfo property) + { + var jsonName = property.GetCustomAttribute(); + if (jsonName != null && !string.IsNullOrWhiteSpace(jsonName.Name)) + { + return jsonName.Name; + } + } + + return member.Name; + } +} diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.String.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.String.cs new file mode 100644 index 000000000..47d1ff6d2 --- /dev/null +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.String.cs @@ -0,0 +1,317 @@ +using Elastic.Clients.Elasticsearch.QueryDsl; +using System; +using System.Linq.Expressions; + +namespace LINGYUN.Abp.Elasticsearch; + +public partial class ExpressionQueryTranslator +{ + /// + /// 翻译字符串方法 + /// + private Query TranslateStringMethod(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + return node.Method.Name switch + { + // 实例方法 + nameof(string.Contains) => TranslateStringContains(node, prefix, mappingInfo), + nameof(string.StartsWith) => TranslateStringStartsWith(node, prefix, mappingInfo), + nameof(string.EndsWith) => TranslateStringEndsWith(node, prefix, mappingInfo), + nameof(string.Equals) => TranslateStringEquals(node, prefix, mappingInfo), + nameof(string.CompareTo) => TranslateStringCompareTo(node, prefix, mappingInfo), + nameof(string.IndexOf) => TranslateStringIndexOf(node, prefix, mappingInfo), + + // 静态方法 + nameof(string.IsNullOrEmpty) => TranslateStringIsNullOrEmpty(node, prefix, mappingInfo), + nameof(string.IsNullOrWhiteSpace) => TranslateStringIsNullOrWhiteSpace(node, prefix, mappingInfo), + + // 其他方法不支持 + _ => throw new NotSupportedException($"Unsupported string method {node.Method.Name}"), + }; + } + + /// + /// 翻译 Contains + /// + private Query TranslateStringContains(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + var (field, value) = GetStringMethodOperands(node, prefix, mappingInfo); + var fieldMapping = mappingInfo?.GetField(field.Path); + return TranslateStringContains(field, fieldMapping, value); + } + + /// + /// 翻译 StartsWith + /// + private Query TranslateStringStartsWith(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + var (field, value) = GetStringMethodOperands(node, prefix, mappingInfo); + var fieldMapping = mappingInfo?.GetField(field.Path); + return TranslateStartsWith(field, fieldMapping, value); + } + + /// + /// 翻译 EndsWith + /// + private Query TranslateStringEndsWith(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + var (field, value) = GetStringMethodOperands(node, prefix, mappingInfo); + var fieldMapping = mappingInfo?.GetField(field.Path); + return TranslateEndsWith(field, fieldMapping, value); + } + + /// + /// 翻译 StartsWith + /// + private Query TranslateStartsWith(FieldInfo field, FieldMappingInfo? fieldMapping, string value) + { + var pattern = EscapeWildcard(value) + "*"; + + // Wildcard 类型 + if (field.IsWildcard || fieldMapping?.IsWildcard == true) + { + return new WildcardQuery { Field = field.Path, Value = pattern }; + } + + // Keyword 类型或 Text 有 keyword 子字段 + if (field.IsKeyword || fieldMapping?.IsKeyword == true || + (fieldMapping?.IsText == true && fieldMapping?.Properties?.ContainsKey("keyword") == true)) + { + var fieldPath = fieldMapping?.IsText == true && fieldMapping?.Properties?.ContainsKey("keyword") == true + ? $"{field.Path}.keyword" + : field.Path; + return new WildcardQuery { Field = fieldPath, Value = pattern }; + } + + // Text 类型无 keyword 子字段 + if (field.IsText || fieldMapping?.IsText == true) + { + return new MatchPhrasePrefixQuery + { + Field = field.Path, + Query = value + }; + } + + return new WildcardQuery { Field = field.Path, Value = pattern }; + } + + /// + /// 翻译 EndsWith + /// + private Query TranslateEndsWith(FieldInfo field, FieldMappingInfo? fieldMapping, string value) + { + var pattern = "*" + EscapeWildcard(value); + + // Wildcard 类型 + if (field.IsWildcard || fieldMapping?.IsWildcard == true) + { + return new WildcardQuery { Field = field.Path, Value = pattern }; + } + + // Keyword 类型或 Text 有 keyword 子字段 + if (field.IsKeyword || fieldMapping?.IsKeyword == true || + (fieldMapping?.IsText == true && fieldMapping?.Properties?.ContainsKey("keyword") == true)) + { + var fieldPath = fieldMapping?.IsText == true && fieldMapping?.Properties?.ContainsKey("keyword") == true + ? $"{field.Path}.keyword" + : field.Path; + return new WildcardQuery { Field = fieldPath, Value = pattern }; + } + + // Text 类型无 keyword 子字段 + if (field.IsText || fieldMapping?.IsText == true) + { + return new MatchPhraseQuery + { + Field = field.Path, + Query = value + }; + } + + return new WildcardQuery { Field = field.Path, Value = pattern }; + } + + /// + /// 翻译 string.Equals + /// + private Query TranslateStringEquals(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + var (field, value) = GetStringMethodOperands(node, prefix, mappingInfo); + + if (string.IsNullOrEmpty(value)) + { + return new BoolQuery { MustNot = new Query[] { new ExistsQuery { Field = field.Path } } }; + } + + return BuildEquality(field, value); + } + + /// + /// 翻译 string.CompareTo + /// + private Query TranslateStringCompareTo(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + var (field, value) = GetStringMethodOperands(node, prefix, mappingInfo); + + // CompareTo 通常用于比较,这里简化为相等比较 + // 如果需要更复杂的比较逻辑,可以在这里扩展 + return BuildEquality(field, value); + } + + /// + /// 翻译 string.IndexOf + /// + private Query TranslateStringIndexOf(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + var (field, value) = GetStringMethodOperands(node, prefix, mappingInfo); + var fieldMapping = mappingInfo?.GetField(field.Path); + + // IndexOf >= 0 等价于 Contains + return TranslateStringContains(field, fieldMapping, value); + } + + /// + /// 翻译 Contains + /// + private Query TranslateStringContains(FieldInfo field, FieldMappingInfo? fieldMapping, string value) + { + // 1. Wildcard 类型 - 直接使用通配符查询(最优) + if (field.IsWildcard || fieldMapping?.IsWildcard == true) + { + return new WildcardQuery + { + Field = field.Path, + Value = "*" + EscapeWildcard(value) + "*" + }; + } + + // 2. Keyword 类型 - 使用通配符查询 + if (field.IsKeyword || fieldMapping?.IsKeyword == true) + { + return new WildcardQuery + { + Field = field.Path, + Value = "*" + EscapeWildcard(value) + "*" + }; + } + + // 3. Text 类型 + if (field.IsText || fieldMapping?.IsText == true) + { + // 3.1 如果有 keyword 子字段,使用 .keyword 进行通配符查询 + if (fieldMapping?.Properties?.ContainsKey("keyword") == true) + { + return new WildcardQuery + { + Field = $"{field.Path}.keyword", + Value = "*" + EscapeWildcard(value) + "*" + }; + } + + // 3.2 没有 keyword 子字段,使用 MatchPhrase 进行全文搜索 + // 注意:这不是精确的 Contains,而是分词后的短语匹配 + return new MatchPhraseQuery + { + Field = field.Path, + Query = value + }; + } + + // 4. 默认 - 尝试使用通配符 + return new WildcardQuery + { + Field = field.Path, + Value = "*" + EscapeWildcard(value) + "*" + }; + } + + /// + /// 翻译 string.IsNullOrEmpty + /// + private Query TranslateStringIsNullOrEmpty(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + var field = ResolveField(node.Arguments[0], prefix, mappingInfo); + + // field == null || field == "" + return new BoolQuery + { + Should = new Query[] + { + // null 或不存在 + new BoolQuery + { + MustNot = new Query[] { new ExistsQuery { Field = field.Path } } + }, + // 空字符串 + new TermQuery + { + Field = field.Path, + Value = string.Empty + } + }, + MinimumShouldMatch = 1 + }; + } + + /// + /// 翻译 string.IsNullOrWhiteSpace + /// + private Query TranslateStringIsNullOrWhiteSpace(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + var field = ResolveField(node.Arguments[0], prefix, mappingInfo); + + // field == null || field == "" || Regex.IsMatch(field, @"^\s*$") + return new BoolQuery + { + Should = new Query[] + { + // null 或不存在 + new BoolQuery + { + MustNot = new Query[] { new ExistsQuery { Field = field.Path } } + }, + // 空字符串 + new TermQuery + { + Field = field.Path, + Value = string.Empty + }, + // 使用正则表达式匹配只有空白字符的字符串 + new RegexpQuery + { + Field = field.Path, + Value = @"^\s*$" + } + }, + MinimumShouldMatch = 1 + }; + } + + /// + /// 翻译字符串实例方法(获取字段和值) + /// + private (FieldInfo Field, string Value) GetStringMethodOperands(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + Expression fieldExpression; + Expression valueExpression; + + if (node.Object != null) + { + // 实例方法:obj.Method(value) + fieldExpression = node.Object; + valueExpression = node.Arguments[0]; + } + else + { + // 静态方法:string.Method(field, value) + fieldExpression = node.Arguments[0]; + valueExpression = node.Arguments.Count > 1 ? node.Arguments[1] : node.Arguments[0]; + } + + var field = ResolveField(fieldExpression, prefix, mappingInfo); + var value = Evaluate(valueExpression)?.ToString() ?? string.Empty; + + return (field, value); + } +} diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.cs index 15731d7d2..a52687e52 100644 --- a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.cs +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.cs @@ -16,7 +16,7 @@ namespace LINGYUN.Abp.Elasticsearch; /// /// 表达式查询转换器 - 将 LINQ 表达式转换为 Elasticsearch Query /// -public class ExpressionQueryTranslator : IExpressionQueryTranslator, ISingletonDependency +public partial class ExpressionQueryTranslator : IExpressionQueryTranslator, ISingletonDependency { private readonly IIndexMappingProvider _indexMappingProvider; @@ -484,14 +484,8 @@ public class ExpressionQueryTranslator : IExpressionQueryTranslator, ISingletonD return TranslateEnumerableMethod(node, prefix, mappingInfo); } - // string.Equals 需放在其他方法前 - if (node.Method.Name == nameof(string.Equals)) - { - return TranslateStringEquals(node, prefix, mappingInfo); - } - // 字符串方法 - if (node.Method.DeclaringType == typeof(string) && node.Object != null) + if (node.Method.DeclaringType == typeof(string)) { return TranslateStringMethod(node, prefix, mappingInfo); } @@ -512,341 +506,6 @@ public class ExpressionQueryTranslator : IExpressionQueryTranslator, ISingletonD $"Unsupported method invocation {node.Method.DeclaringType?.Name}.{node.Method.Name}"); } - /// - /// 翻译 Enumerable 方法 - /// - private Query TranslateEnumerableMethod(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) - { - switch (node.Method.Name) - { - case nameof(Enumerable.Any): - return TranslateEnumerableAny(node, prefix, mappingInfo); - - case nameof(Enumerable.Contains): - return TranslateEnumerableContains(node, prefix, mappingInfo); - - case nameof(Enumerable.All): - return TranslateEnumerableAll(node, prefix, mappingInfo); - - default: - throw new NotSupportedException($"Unsupported Enumerable method {node.Method.Name}"); - } - } - - /// - /// 翻译 Enumerable.Any - /// - private Query TranslateEnumerableAny(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) - { - var collectionField = ResolveField(node.Arguments[0], prefix, mappingInfo); - - Query inner; - if (node.Arguments.Count == 1) - { - // .Any() 检查集合是否存在 - inner = new ExistsQuery { Field = collectionField.Path }; - } - else - { - // .Any(predicate) - var predicate = UnwrapLambda(node.Arguments[1]); - inner = TranslateNode(predicate.Body, prefix: collectionField.Path, mappingInfo); - } - - var shouldUseNested = collectionField.IsNested || (mappingInfo?.IsNested(collectionField.Path) ?? false); - - return shouldUseNested - ? new NestedQuery(collectionField.Path, inner) - : inner; - } - - /// - /// 翻译 Enumerable.Contains - /// - private Query TranslateEnumerableContains(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) - { - Expression collectionExpr; - Expression valueExpr; - - if (node.Object != null) - { - // list.Contains(value) - collectionExpr = node.Object; - valueExpr = node.Arguments[0]; - } - else - { - // Enumerable.Contains(list, value) - collectionExpr = node.Arguments[0]; - valueExpr = node.Arguments[1]; - } - - var field = ResolveField(collectionExpr, prefix, mappingInfo); - var value = Evaluate(valueExpr); - - return BuildTermsQuery(field, value!); - } - - /// - /// 翻译 Enumerable.All - /// - private Query TranslateEnumerableAll(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) - { - var collectionField = ResolveField(node.Arguments[0], prefix, mappingInfo); - var predicate = UnwrapLambda(node.Arguments[1]); - - var inner = TranslateNode(predicate.Body, prefix: collectionField.Path, mappingInfo); - - return new NestedQuery(collectionField.Path, inner); - } - - /// - /// 翻译字符串方法 - /// - private Query TranslateStringMethod(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) - { - var field = ResolveField(node.Object!, prefix, mappingInfo); - var value = (string)Evaluate(node.Arguments[0])!; - var fieldMapping = mappingInfo?.GetField(field.Path); - var escapedValue = EscapeWildcard(value); - - return node.Method.Name switch - { - nameof(string.Contains) => TranslateContains(field, fieldMapping, value), - nameof(string.StartsWith) => TranslateStartsWith(field, fieldMapping, value), - nameof(string.EndsWith) => TranslateEndsWith(field, fieldMapping, value), - _ => throw new NotSupportedException($"Unsupported string method {node.Method.Name}"), - }; - } - - /// - /// 翻译 Contains - /// - private Query TranslateContains(FieldInfo field, FieldMappingInfo? fieldMapping, string value) - { - // 1. Wildcard 类型 - 直接使用通配符查询(最优) - if (field.IsWildcard || fieldMapping?.IsWildcard == true) - { - return new WildcardQuery - { - Field = field.Path, - Value = "*" + EscapeWildcard(value) + "*" - }; - } - - // 2. Keyword 类型 - 使用通配符查询 - if (field.IsKeyword || fieldMapping?.IsKeyword == true) - { - return new WildcardQuery - { - Field = field.Path, - Value = "*" + EscapeWildcard(value) + "*" - }; - } - - // 3. Text 类型 - if (field.IsText || fieldMapping?.IsText == true) - { - // 3.1 如果有 keyword 子字段,使用 .keyword 进行通配符查询 - if (fieldMapping?.Properties?.ContainsKey("keyword") == true) - { - return new WildcardQuery - { - Field = $"{field.Path}.keyword", - Value = "*" + EscapeWildcard(value) + "*" - }; - } - - // 3.2 没有 keyword 子字段,使用 MatchPhrase 进行全文搜索 - // 注意:这不是精确的 Contains,而是分词后的短语匹配 - return new MatchPhraseQuery - { - Field = field.Path, - Query = value - }; - } - - // 4. 默认 - 尝试使用通配符 - return new WildcardQuery - { - Field = field.Path, - Value = "*" + EscapeWildcard(value) + "*" - }; - } - - /// - /// 翻译 StartsWith - /// - private Query TranslateStartsWith(FieldInfo field, FieldMappingInfo? fieldMapping, string value) - { - var pattern = EscapeWildcard(value) + "*"; - - // Wildcard 类型 - if (field.IsWildcard || fieldMapping?.IsWildcard == true) - { - return new WildcardQuery { Field = field.Path, Value = pattern }; - } - - // Keyword 类型或 Text 有 keyword 子字段 - if (field.IsKeyword || fieldMapping?.IsKeyword == true || - (fieldMapping?.IsText == true && fieldMapping?.Properties?.ContainsKey("keyword") == true)) - { - var fieldPath = fieldMapping?.IsText == true && fieldMapping?.Properties?.ContainsKey("keyword") == true - ? $"{field.Path}.keyword" - : field.Path; - return new WildcardQuery { Field = fieldPath, Value = pattern }; - } - - // Text 类型无 keyword 子字段 - if (field.IsText || fieldMapping?.IsText == true) - { - return new MatchPhrasePrefixQuery - { - Field = field.Path, - Query = value - }; - } - - return new WildcardQuery { Field = field.Path, Value = pattern }; - } - - /// - /// 翻译 EndsWith - /// - private Query TranslateEndsWith(FieldInfo field, FieldMappingInfo? fieldMapping, string value) - { - var pattern = "*" + EscapeWildcard(value); - - // Wildcard 类型 - if (field.IsWildcard || fieldMapping?.IsWildcard == true) - { - return new WildcardQuery { Field = field.Path, Value = pattern }; - } - - // Keyword 类型或 Text 有 keyword 子字段 - if (field.IsKeyword || fieldMapping?.IsKeyword == true || - (fieldMapping?.IsText == true && fieldMapping?.Properties?.ContainsKey("keyword") == true)) - { - var fieldPath = fieldMapping?.IsText == true && fieldMapping?.Properties?.ContainsKey("keyword") == true - ? $"{field.Path}.keyword" - : field.Path; - return new WildcardQuery { Field = fieldPath, Value = pattern }; - } - - // Text 类型无 keyword 子字段 - if (field.IsText || fieldMapping?.IsText == true) - { - return new MatchPhraseQuery - { - Field = field.Path, - Query = value - }; - } - - return new WildcardQuery { Field = field.Path, Value = pattern }; - } - - /// - /// 翻译 string.Equals - /// - private Query TranslateStringEquals(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) - { - Expression fieldExpression; - Expression valueExpression; - - if (node.Object != null) - { - fieldExpression = node.Object; - valueExpression = node.Arguments[0]; - } - else - { - fieldExpression = node.Arguments[0]; - valueExpression = node.Arguments[1]; - } - - var field = ResolveField(fieldExpression, prefix, mappingInfo); - var value = Evaluate(valueExpression); - - if (value == null) - { - return new BoolQuery { MustNot = new Query[] { new ExistsQuery { Field = field.Path } } }; - } - - return BuildEquality(field, value); - } - - /// - /// 翻译 Enum.HasFlag - /// - private Query TranslateEnumHasFlag(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) - { - var field = ResolveField(node.Object!, prefix, mappingInfo); - var flag = Evaluate(node.Arguments[0]); - - if (flag == null) - { - throw new NotSupportedException("Cannot use null flag in Enum.HasFlag"); - } - - var flagValue = Convert.ToInt64(flag); - return new TermQuery { Field = field.Path, Value = flagValue }; - } - - /// - /// 翻译集合 Contains - /// - private Query TranslateCollectionContains(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) - { - // list.Contains(value) 或 hashSet.Contains(value) - // node.Object = 集合实例 (可能是一个变量或常量) - // node.Arguments[0] = value (要检查的值) - - // 尝试获取集合的值 - var collectionValue = Evaluate(node.Object!); - - // 如果集合是常量且可枚举,构建 TermsQuery - if (collectionValue is IEnumerable enumerable && collectionValue is not string) - { - var values = enumerable.Cast().Select(NormalizeValue).ToList(); - if (values.Count == 0) - { - return new MatchNoneQuery(); - } - if (values.Count == 1) - { - // 如果集合只有一个值,使用 TermQuery - return BuildEquality(ResolveField(node.Arguments[0], prefix, mappingInfo), values[0]); - } - // 多个值使用 TermsQuery - var field = ResolveField(node.Arguments[0], prefix, mappingInfo); - return new TermsQuery { Field = field.Path, Terms = new TermsQueryField(values) }; - } - - // 如果值是可枚举集合 - var value = Evaluate(node.Arguments[0]); - if (value is IEnumerable enumerableValue && value is not string) - { - var values = enumerableValue.Cast().Select(NormalizeValue).ToList(); - if (values.Count == 0) - { - return new MatchNoneQuery(); - } - if (values.Count == 1) - { - return BuildEquality(ResolveField(node.Object!, prefix, mappingInfo), values[0]); - } - var field = ResolveField(node.Object!, prefix, mappingInfo); - return new TermsQuery { Field = field.Path, Terms = new TermsQueryField(values) }; - } - - // 默认:检查字段是否在集合中 - // 这种情况下,我们使用 TermsQuery 但需要从外部获取集合值 - // 由于无法在编译时确定,使用 TermQuery 进行单值匹配 - var defaultField = ResolveField(node.Arguments[0], prefix, mappingInfo); - return BuildEquality(defaultField, value!); - } - #endregion #region 查询构建 @@ -1057,27 +716,79 @@ public class ExpressionQueryTranslator : IExpressionQueryTranslator, ISingletonD /// private static object? Evaluate(Expression expression) { + // 如果是常量表达式,直接返回值 if (expression is ConstantExpression constant) { return constant.Value; } + // 如果是参数表达式,无法求值 + if (expression is ParameterExpression) + { + throw new InvalidOperationException($"Cannot evaluate parameter expression: {expression}"); + } + // 处理成员访问(捕获外部变量) if (expression is MemberExpression memberExpr) { - object? obj = null; - if (memberExpr.Expression != null) - { - obj = Evaluate(memberExpr.Expression); - } - + // 检查是否是字段或属性访问 if (memberExpr.Member is System.Reflection.FieldInfo fieldInfo) { - return fieldInfo.GetValue(obj); + // 如果是静态字段 + if (fieldInfo.IsStatic) + { + return fieldInfo.GetValue(null); + } + + // 如果是实例字段,需要先求值实例 + if (memberExpr.Expression != null) + { + var obj = Evaluate(memberExpr.Expression); + if (obj != null) + { + return fieldInfo.GetValue(obj); + } + } + + // 如果无法求值,尝试编译整个表达式 + try + { + return Expression.Lambda(memberExpr).Compile().DynamicInvoke(); + } + catch + { + throw new InvalidOperationException($"Cannot evaluate member expression: {memberExpr}"); + } } + if (memberExpr.Member is PropertyInfo propertyInfo) { - return propertyInfo.GetValue(obj); + // 如果是静态属性 + var getMethod = propertyInfo.GetGetMethod(); + if (getMethod != null && getMethod.IsStatic) + { + return propertyInfo.GetValue(null); + } + + // 如果是实例属性,需要先求值实例 + if (memberExpr.Expression != null) + { + var obj = Evaluate(memberExpr.Expression); + if (obj != null) + { + return propertyInfo.GetValue(obj); + } + } + + // 如果无法求值,尝试编译整个表达式 + try + { + return Expression.Lambda(memberExpr).Compile().DynamicInvoke(); + } + catch + { + throw new InvalidOperationException($"Cannot evaluate member expression: {memberExpr}"); + } } } @@ -1097,11 +808,18 @@ public class ExpressionQueryTranslator : IExpressionQueryTranslator, ISingletonD { try { - return Expression.Lambda(methodCall).Compile().DynamicInvoke(); + // 尝试编译并执行 + var lambda = Expression.Lambda(methodCall); + return lambda.Compile().DynamicInvoke(); } - catch + catch (InvalidOperationException) { - return null; + // 如果包含参数引用,无法编译 + throw; + } + catch (Exception ex) + { + throw new InvalidOperationException($"Cannot evaluate method call: {methodCall}", ex); } } @@ -1112,6 +830,8 @@ public class ExpressionQueryTranslator : IExpressionQueryTranslator, ISingletonD return values; } + // 默认尝试编译执行 + return Expression.Lambda(expression).Compile().DynamicInvoke(); } @@ -1241,112 +961,4 @@ public class ExpressionQueryTranslator : IExpressionQueryTranslator, ISingletonD } #endregion - - #region 字段解析 - - /// - /// 解析字段 - /// - protected virtual FieldInfo ResolveField(Expression expression, string? prefix, IndexMappingInfo? mappingInfo) - { - var current = expression; - while (current is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } unary) - { - current = unary.Operand; - } - - var names = new Stack(); - Type? leafType = null; - string? leafName = null; - - // 收集成员路径 - while (current is MemberExpression member) - { - leafName ??= member.Member.Name; - leafType ??= GetMemberType(member.Member); - names.Push(ResolveFieldName(member.Member)); - current = member.Expression!; - } - - if (current is not ParameterExpression && current is not ConstantExpression) - { - throw new NotSupportedException($"Unable to parse as field path: {expression}"); - } - - var path = string.Join(".", names); - - // 应用前缀 - if (!string.IsNullOrEmpty(prefix) && path.Length > 0) - { - path = prefix + "." + path; - } - else if (path.Length == 0) - { - path = prefix ?? string.Empty; - } - - // 获取字段映射信息 - var finalMapping = mappingInfo?.GetField(path); - - // 如果是 text 类型且有 keyword 子字段,自动使用 .keyword - if (finalMapping?.IsText == true && finalMapping.Properties?.ContainsKey("keyword") == true) - { - path = $"{path}.keyword"; - finalMapping = mappingInfo?.GetField(path); - } - - // 如果 leafType 为 null,使用 expression.Type - var type = leafType ?? expression.Type; - var underlyingType = Nullable.GetUnderlyingType(type) ?? type; - - return new FieldInfo( - path, - underlyingType, - leafName ?? string.Empty, - finalMapping?.IsKeyword ?? false, - finalMapping?.IsText ?? false, - finalMapping?.IsWildcard ?? false, - finalMapping?.IsNested ?? false || (mappingInfo?.IsNested(path) ?? false), - finalMapping?.IsDate ?? false, - finalMapping?.IsNumeric ?? false, - finalMapping?.IsBoolean ?? false, - finalMapping?.IsRange ?? false, - finalMapping?.Format, - finalMapping?.HasMultiFields ?? false - ); - } - - /// - /// 获取成员的实际类型 - /// - private static Type GetMemberType(MemberInfo member) - { - return member switch - { - System.Reflection.FieldInfo field => field.FieldType, - PropertyInfo property => property.PropertyType, - MethodInfo method => method.ReturnType, - _ => typeof(object) - }; - } - - /// - /// 解析字段名称 - /// - private static string ResolveFieldName(MemberInfo member) - { - // 检查 JsonPropertyName 属性 - if (member is PropertyInfo property) - { - var jsonName = property.GetCustomAttribute(); - if (jsonName != null && !string.IsNullOrWhiteSpace(jsonName.Name)) - { - return jsonName.Name; - } - } - - return member.Name; - } - - #endregion } \ No newline at end of file diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IExpressionQueryService.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IExpressionQueryService.cs index 65e24c803..6a125fd03 100644 --- a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IExpressionQueryService.cs +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IExpressionQueryService.cs @@ -32,6 +32,7 @@ public interface IExpressionQueryService /// 跳过数据大小 /// 包含字段 /// 忽略字段 + /// 排序起始字段 /// /// Task> GetListAsync( @@ -42,5 +43,6 @@ public interface IExpressionQueryService int skipCount = 0, Fields? sourceExcludes = null, Fields? sourceIncludes = null, + object[]? beginMarker = null, CancellationToken cancellationToken = default) where TDocument : class; } diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/README.md b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/README.md index fdaa94153..2bac08b33 100644 --- a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/README.md +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/README.md @@ -6,7 +6,6 @@ Abp Elasticsearch集成,提供全局唯一IElasticClient访问接口 ## 模块引用 - ```csharp [DependsOn(typeof(AbpElasticsearchModule))] public class YouProjectModule : AbpModule @@ -15,6 +14,13 @@ public class YouProjectModule : AbpModule } ``` +## 接口定义 + +* [IIndexMappingProvider](./LINGYUN/Abp/Elasticsearch/IIndexMappingProvider.cs) 获取索引映射属性 +* [IExpressionQueryTranslator](./LINGYUN/Abp/Elasticsearch/IExpressionQueryTranslator.cs) 表达式树翻译为ES查询类 +* [IExpressionQueryService](./LINGYUN/Abp/Elasticsearch/IExpressionQueryService.cs) 表达式树数据查询 +* [IElasticsearchClientFactory](./LINGYUN/Abp/Elasticsearch/IElasticsearchClientFactory.cs) ES客户端管理 + ## 配置项 * AbpElasticsearchOptions.FieldCamelCase 字段是否采用 camelCase 格式, 默认false diff --git a/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/AbpElasticsearchTestModule.cs b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/AbpElasticsearchTestModule.cs index 7cc2a584b..9c248857a 100644 --- a/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/AbpElasticsearchTestModule.cs +++ b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/AbpElasticsearchTestModule.cs @@ -1,14 +1,7 @@ -using Elastic.Clients.Elasticsearch.IndexManagement; -using Elastic.Clients.Elasticsearch.Mapping; -using Elastic.Transport.Diagnostics.Auditing; -using LINGYUN.Abp.Tests; +using LINGYUN.Abp.Tests; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; -using System.Threading; -using System.Threading.Tasks; -using Volo.Abp; using Volo.Abp.Modularity; -using Volo.Abp.Threading; namespace LINGYUN.Abp.Elasticsearch; @@ -17,7 +10,6 @@ namespace LINGYUN.Abp.Elasticsearch; typeof(AbpElasticsearchModule))] public class AbpElasticsearchTestModule : AbpModule { - private readonly CancellationTokenSource _cancellationTokenSource = new(); private const string UserSecretsId = "D4327320-718E-4A7F-A987-85838EDD8675"; public override void PreConfigureServices(ServiceConfigurationContext context) @@ -27,116 +19,4 @@ public class AbpElasticsearchTestModule : AbpModule builder.AddUserSecrets(UserSecretsId); })); } - - public override void OnPostApplicationInitialization(ApplicationInitializationContext context) - { - AsyncHelper.RunSync(async () => await OnPostApplicationInitializationAsync(context)); - } - - public async override Task OnPostApplicationInitializationAsync(ApplicationInitializationContext context) - { - var clientFactory = context.ServiceProvider.GetRequiredService(); - var client = clientFactory.Create(); - var indexPatterns = new[] { TestDocumentIndexNames.Index + "*" }; - var indexTemplateName = TestDocumentIndexNames.Index + "-generic"; - var dateTimeFormat = "yyyy-MM-dd HH:mm:ss||strict_date_optional_time||epoch_millis"; - - var indexTemplateExists = await client.Indices.ExistsIndexTemplateAsync(indexTemplateName, _cancellationTokenSource.Token); - if (indexTemplateExists.Exists) - { - await client.Indices.DeleteIndexTemplateAsync(indexTemplateName, _cancellationTokenSource.Token); - } - var putTemplateResponse = await client.Indices.PutIndexTemplateAsync(indexTemplateName, setup => - { - setup.IndexPatterns(indexPatterns); - setup.Priority(100); - setup.Version(1); - setup.Template(template => - { - template.Settings(new IndexSettings() - { - NumberOfReplicas = 1, - NumberOfShards = 3, - Mapping = new MappingLimitSettings - { - TotalFields = new MappingLimitSettingsTotalFields - { - Limit = 1000, - }, - NestedFields = new MappingLimitSettingsNestedFields - { - Limit = 50, - }, - Depth = new MappingLimitSettingsDepth - { - Limit = 10, - }, - } - }); - template.Mappings(mp => mp - .Dynamic(DynamicMapping.False) - .Properties(pd => - { - pd.IntegerNumber(k => k.Id); - pd.Text(k => k.Name, p => p.Fields(f => f.Keyword("keyword", k => k.IgnoreAbove(100)))); - pd.Text(t => t.Description); - pd.IntegerNumber(k => k.Age); - pd.DoubleNumber(k => k.Salary); - pd.Boolean(k => k.IsActive); - pd.Date(k => k.CreatedTime, d => d.Format(dateTimeFormat)); - pd.Date(k => k.UpdatedTime, d => d.Format(dateTimeFormat)); - pd.ByteNumber(k => k.Status); - pd.ByteNumber(k => k.NullableStatus); - pd.Text(k => k.StringValueStatus, p => p.Fields(f => f.Keyword("keyword", k => k.IgnoreAbove(100)))); - pd.Keyword(k => k.Tags); - pd.Wildcard(k => k.Exceptions); - pd.Nested(n => n.Items, np => - { - np.Dynamic(DynamicMapping.False); - np.Properties(npd => - { - npd.IntegerNumber(nameof(SubDocument.Id)); - npd.Text(nameof(SubDocument.Name), p => p.Fields(f => f.Keyword("keyword", k => k.IgnoreAbove(100)))); - npd.DoubleNumber(nameof(SubDocument.Price)); - }); - }); - pd.Nested(n => n.Address, np => - { - np.Dynamic(DynamicMapping.False); - np.Properties(npd => - { - npd.Text(nameof(Address.City), p => p.Fields(f => f.Keyword("keyword", k => k.IgnoreAbove(255)))); - npd.Text(nameof(Address.Street)); - }); - }); - })); - }); - }, _cancellationTokenSource.Token); - - await client.Indices.DeleteAsync(TestDocumentIndexNames.Index, _cancellationTokenSource.Token); - await client.IndexAsync( - new TestDocument(), - dsl => dsl.Index(TestDocumentIndexNames.Index), - _cancellationTokenSource.Token); - } - - public override void OnApplicationShutdown(ApplicationShutdownContext context) - { - AsyncHelper.RunSync(async () => await OnApplicationShutdownAsync(context)); - } - - public async override Task OnApplicationShutdownAsync(ApplicationShutdownContext context) - { - var clientFactory = context.ServiceProvider.GetRequiredService(); - var client = clientFactory.Create(); - var indexTemplateName = TestDocumentIndexNames.Index + "-generic"; - var indexTemplateExists = await client.Indices.ExistsIndexTemplateAsync(indexTemplateName, _cancellationTokenSource.Token); - if (indexTemplateExists.Exists) - { - await client.Indices.DeleteIndexTemplateAsync(indexTemplateName, _cancellationTokenSource.Token); - } - await client.Indices.DeleteAsync(TestDocumentIndexNames.Index, _cancellationTokenSource.Token); - - _cancellationTokenSource.Cancel(); - } } diff --git a/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/DefaultExpressionQueryService_Tests.cs b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/DefaultExpressionQueryService_Tests.cs new file mode 100644 index 000000000..05166bf42 --- /dev/null +++ b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/DefaultExpressionQueryService_Tests.cs @@ -0,0 +1,5 @@ +namespace LINGYUN.Abp.Elasticsearch; + +public class DefaultExpressionQueryService_Tests : ExpressionQueryService_Tests +{ +} diff --git a/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/ExpressionQueryService_Tests.cs b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/ExpressionQueryService_Tests.cs new file mode 100644 index 000000000..31aabc5f7 --- /dev/null +++ b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/ExpressionQueryService_Tests.cs @@ -0,0 +1,169 @@ +using Elastic.Clients.Elasticsearch; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using Volo.Abp.Modularity; +using Volo.Abp.Testing; +using Volo.Abp.Threading; +using Xunit; + +namespace LINGYUN.Abp.Elasticsearch; + +public abstract class ExpressionQueryService_Tests : AbpIntegratedTest + where TStartupModule : IAbpModule +{ + private List _documents = new List(); + protected IExpressionQueryService ExpressionQueryService { get; } + protected ExpressionQueryService_Tests() + { + ExpressionQueryService = GetRequiredService(); + } + + #region 索引初始化 + + protected override void AfterInitialize() + { + AsyncHelper.RunSync(async () => await ApplicationInitializationAsync()); + } + + public override void Dispose() + { + AsyncHelper.RunSync(async () => await ApplicationShutdownAsync()); + } + + protected async virtual Task ApplicationInitializationAsync() + { + var clientFactory = GetRequiredService(); + var client = clientFactory.Create(); + + _documents.AddRange(new[] + { + new TestDocument + { + Name = "Name1", + Age = 20, + Address = new Address + { + City = "HANGZHOU", + }, + CreatedTime = new DateTime(2026, 8, 1, 0, 0, 0), + Status = TestEnum.Active, + StringValueStatus = TestEnum.Pending, + IsActive = true, + Id = 1, + Salary = 3m, + }, + new TestDocument + { + Name = "Name2", + Age = 10, + Address = new Address + { + City = "GUANGZHOU", + }, + CreatedTime = new DateTime(2026, 5, 1, 0, 0, 0), + Status = TestEnum.Inactive, + StringValueStatus = TestEnum.Active, + IsActive = false, + Id = 2, + Salary = 7m, + Tags = new List{ "B" }, + }, + new TestDocument + { + Name = "Test1", + Age = 30, + Address = new Address + { + City = "BEIJING", + }, + CreatedTime = new DateTime(2026, 3, 1, 0, 0, 0), + Status = TestEnum.Pending, + StringValueStatus = TestEnum.Inactive, + IsActive = true, + Id = 3, + Salary = 10m, + Tags = new List { "B", "C" }, + }, + }); + + await client.BulkAsync(b => + b.Index(TestDocumentIndexNames.Index) + .Refresh(Refresh.WaitFor) + .IndexMany(_documents)); + } + + protected async virtual Task ApplicationShutdownAsync() + { + var clientFactory = GetRequiredService(); + var client = clientFactory.Create(); + + await client.Indices.DeleteAsync(TestDocumentIndexNames.Index); + } + + #endregion + + [Fact] + public async Task Should_Get_Count() + { + (await ExpressionQueryService.GetCountAsync( + TestDocumentIndexNames.Index, + x => x.Name!.StartsWith("Name") && x.Salary <= 10m)).ShouldBe(2); + + (await ExpressionQueryService.GetCountAsync( + TestDocumentIndexNames.Index, + x => !string.IsNullOrWhiteSpace(x.Name) && x.Tags != null && x.Tags.Contains("B"))).ShouldBe(2); + + (await ExpressionQueryService.GetCountAsync( + TestDocumentIndexNames.Index, + x => + x.Name != null && x.Name.Contains("1") && + (x.Status == TestEnum.Active || x.Status == TestEnum.Pending))).ShouldBe(2); + } + + [Fact] + public async Task Should_Get_List() + { + var list1 = await ExpressionQueryService.GetListAsync( + TestDocumentIndexNames.Index, + x => x.Name!.StartsWith("Name") && x.Salary <= 10m); + list1.Count.ShouldBe(2); + list1[0].Name.ShouldBe("Name1"); + list1[0].Age.ShouldBe(20); + list1[0].Status.ShouldBe(TestEnum.Active); + list1[0].StringValueStatus.ShouldBe(TestEnum.Pending); + list1[0].IsActive.ShouldBeTrue(); + list1[0].Tags.ShouldBeNull(); + list1[0].Address.ShouldNotBeNull(); + list1[0].Address!.City.ShouldBe("HANGZHOU"); + + var list2 = await ExpressionQueryService.GetListAsync( + TestDocumentIndexNames.Index, + x => !string.IsNullOrWhiteSpace(x.Name) && x.Tags != null && x.Tags.Contains("B")); + list2.Count.ShouldBe(2); + list2[0].Name.ShouldBe("Name2"); + list2[0].Age.ShouldBe(10); + list2[0].Status.ShouldBe(TestEnum.Inactive); + list2[0].StringValueStatus.ShouldBe(TestEnum.Active); + list2[0].IsActive.ShouldBeFalse(); + list2[0].Tags.ShouldNotBeEmpty(); + list2[0].Tags!.ShouldContain("B"); + list2[0].Address!.City.ShouldBe("GUANGZHOU"); + + var list3 = await ExpressionQueryService.GetListAsync( + TestDocumentIndexNames.Index, + x => + x.Name != null && x.Name.Contains("1") && + (x.Status == TestEnum.Active || x.Status == TestEnum.Pending)); + list3.Count.ShouldBe(2); + list3[1].Name.ShouldBe("Test1"); + list3[1].Age.ShouldBe(30); + list3[1].Status.ShouldBe(TestEnum.Pending); + list3[1].StringValueStatus.ShouldBe(TestEnum.Inactive); + list3[1].IsActive.ShouldBeTrue(); + list3[1].Tags.ShouldNotBeEmpty(); + list3[1].Tags!.ShouldContain("C"); + list3[1].Address!.City.ShouldBe("BEIJING"); + } +} diff --git a/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator_Tests.cs b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator_Tests.cs index 412c67eb3..1d279f720 100644 --- a/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator_Tests.cs +++ b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator_Tests.cs @@ -1,11 +1,15 @@ -using Elastic.Clients.Elasticsearch.QueryDsl; +using Elastic.Clients.Elasticsearch.Mapping; +using Elastic.Clients.Elasticsearch.QueryDsl; +using Microsoft.Extensions.DependencyInjection; using Shouldly; using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Volo.Abp.Modularity; using Volo.Abp.Testing; +using Volo.Abp.Threading; using Xunit; namespace LINGYUN.Abp.Elasticsearch.Tests; @@ -13,6 +17,7 @@ namespace LINGYUN.Abp.Elasticsearch.Tests; public abstract class ExpressionQueryTranslatorTests : AbpIntegratedTest where TStartupModule : IAbpModule { + private readonly CancellationTokenSource _cancellationTokenSource = new(); private readonly IExpressionQueryTranslator _expressionQueryTranslator; public ExpressionQueryTranslatorTests() @@ -20,6 +25,102 @@ public abstract class ExpressionQueryTranslatorTests : AbpIntegr _expressionQueryTranslator = GetRequiredService(); } + #region 索引初始化 + + protected override void AfterInitialize() + { + AsyncHelper.RunSync(async () => await ApplicationInitializationAsync()); + } + + public override void Dispose() + { + AsyncHelper.RunSync(async () => await ApplicationShutdownAsync()); + } + + protected async virtual Task ApplicationInitializationAsync() + { + var clientFactory = GetRequiredService(); + var client = clientFactory.Create(); + var indexPatterns = new[] { TestDocumentIndexNames.Index + "*" }; + var indexTemplateName = TestDocumentIndexNames.Index + "-generic"; + var dateTimeFormat = "yyyy-MM-dd HH:mm:ss||strict_date_optional_time||epoch_millis"; + + var indexTemplateExists = await client.Indices.ExistsIndexTemplateAsync(indexTemplateName, _cancellationTokenSource.Token); + if (indexTemplateExists.Exists) + { + await client.Indices.DeleteIndexTemplateAsync(indexTemplateName, _cancellationTokenSource.Token); + } + var putTemplateResponse = await client.Indices.PutIndexTemplateAsync(indexTemplateName, setup => + { + setup.IndexPatterns(indexPatterns); + setup.Priority(100); + setup.Version(1); + setup.Template(template => + { + template.Mappings(mp => mp + .Dynamic(DynamicMapping.False) + .Properties(pd => + { + pd.IntegerNumber(k => k.Id); + pd.Text(k => k.Name, p => p.Fields(f => f.Keyword("keyword", k => k.IgnoreAbove(100)))); + pd.Text(t => t.Description); + pd.IntegerNumber(k => k.Age); + pd.DoubleNumber(k => k.Salary); + pd.Boolean(k => k.IsActive); + pd.Date(k => k.CreatedTime, d => d.Format(dateTimeFormat)); + pd.Date(k => k.UpdatedTime, d => d.Format(dateTimeFormat)); + pd.ByteNumber(k => k.Status); + pd.ByteNumber(k => k.NullableStatus); + pd.Text(k => k.StringValueStatus, p => p.Fields(f => f.Keyword("keyword", k => k.IgnoreAbove(100)))); + pd.Keyword(k => k.Tags); + pd.Wildcard(k => k.Exceptions); + pd.Nested(n => n.Items, np => + { + np.Dynamic(DynamicMapping.False); + np.Properties(npd => + { + npd.IntegerNumber(nameof(SubDocument.Id)); + npd.Text(nameof(SubDocument.Name), p => p.Fields(f => f.Keyword("keyword", k => k.IgnoreAbove(100)))); + npd.DoubleNumber(nameof(SubDocument.Price)); + }); + }); + pd.Nested(n => n.Address, np => + { + np.Dynamic(DynamicMapping.False); + np.Properties(npd => + { + npd.Text(nameof(Address.City), p => p.Fields(f => f.Keyword("keyword", k => k.IgnoreAbove(255)))); + npd.Text(nameof(Address.Street)); + }); + }); + })); + }); + }, _cancellationTokenSource.Token); + + await client.Indices.DeleteAsync(TestDocumentIndexNames.Index, _cancellationTokenSource.Token); + await client.IndexAsync( + new TestDocument(), + dsl => dsl.Index(TestDocumentIndexNames.Index), + _cancellationTokenSource.Token); + } + + protected async virtual Task ApplicationShutdownAsync() + { + var clientFactory = GetRequiredService(); + var client = clientFactory.Create(); + var indexTemplateName = TestDocumentIndexNames.Index + "-generic"; + var indexTemplateExists = await client.Indices.ExistsIndexTemplateAsync(indexTemplateName, _cancellationTokenSource.Token); + if (indexTemplateExists.Exists) + { + await client.Indices.DeleteIndexTemplateAsync(indexTemplateName, _cancellationTokenSource.Token); + } + await client.Indices.DeleteAsync(TestDocumentIndexNames.Index, _cancellationTokenSource.Token); + + _cancellationTokenSource.Cancel(); + } + + #endregion + #region 基础查询测试 [Fact]