From a0558aaab335b00f67b68e9a9d384c4234d4d742 Mon Sep 17 00:00:00 2001 From: colin Date: Thu, 20 Aug 2026 14:46:21 +0800 Subject: [PATCH] feat: Add an ES expression tree translator --- aspnet-core/LINGYUN.MicroService.All.slnx | 1 + .../LINGYUN.Abp.Elasticsearch.csproj | 2 +- .../Elasticsearch/AbpElasticsearchModule.cs | 15 +- .../ElasticsearchIndexMappingProvider.cs | 387 +++++ .../ExpressionQueryTranslator.cs | 1356 +++++++++++++++++ .../LINGYUN/Abp/Elasticsearch/FieldInfo.cs | 52 + .../Abp/Elasticsearch/FieldMappingInfo.cs | 47 + .../Elasticsearch/IIndexMappingProvider.cs | 9 + .../Abp/Elasticsearch/IndexMappingInfo.cs | 139 ++ .../Abp/Elasticsearch/IsExternalInit.cs | 4 + .../Abp/Elasticsearch/NestedMappingInfo.cs | 89 ++ .../LINGYUN.Abp.Elasticsearch.Tests.csproj | 20 + .../Elasticsearch/AbpElasticsearchTestBase.cs | 7 + .../AbpElasticsearchTestModule.cs | 142 ++ .../ExpressionQueryTranslator_Tests.cs | 1173 ++++++++++++++ .../LINGYUN/Abp/Elasticsearch/TestDocument.cs | 43 + .../Elasticsearch/TestDocumentIndexNames.cs | 6 + 17 files changed, 3484 insertions(+), 8 deletions(-) create mode 100644 aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ElasticsearchIndexMappingProvider.cs create mode 100644 aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.cs create mode 100644 aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/FieldInfo.cs create mode 100644 aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/FieldMappingInfo.cs create mode 100644 aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IIndexMappingProvider.cs create mode 100644 aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IndexMappingInfo.cs create mode 100644 aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IsExternalInit.cs create mode 100644 aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/NestedMappingInfo.cs create mode 100644 aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN.Abp.Elasticsearch.Tests.csproj create mode 100644 aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/AbpElasticsearchTestBase.cs create mode 100644 aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/AbpElasticsearchTestModule.cs create mode 100644 aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator_Tests.cs create mode 100644 aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/TestDocument.cs create mode 100644 aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/TestDocumentIndexNames.cs diff --git a/aspnet-core/LINGYUN.MicroService.All.slnx b/aspnet-core/LINGYUN.MicroService.All.slnx index 19b0fa78a..b531257ce 100644 --- a/aspnet-core/LINGYUN.MicroService.All.slnx +++ b/aspnet-core/LINGYUN.MicroService.All.slnx @@ -528,6 +528,7 @@ + diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN.Abp.Elasticsearch.csproj b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN.Abp.Elasticsearch.csproj index 92d366e88..539a0f42f 100644 --- a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN.Abp.Elasticsearch.csproj +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN.Abp.Elasticsearch.csproj @@ -17,7 +17,7 @@ - + diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/AbpElasticsearchModule.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/AbpElasticsearchModule.cs index 64dd9b46f..6389a318d 100644 --- a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/AbpElasticsearchModule.cs +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/AbpElasticsearchModule.cs @@ -1,14 +1,15 @@ using Microsoft.Extensions.DependencyInjection; +using Volo.Abp.Caching; using Volo.Abp.Modularity; -namespace LINGYUN.Abp.Elasticsearch +namespace LINGYUN.Abp.Elasticsearch; + +[DependsOn(typeof(AbpCachingModule))] +public class AbpElasticsearchModule : AbpModule { - public class AbpElasticsearchModule : AbpModule + public override void ConfigureServices(ServiceConfigurationContext context) { - public override void ConfigureServices(ServiceConfigurationContext context) - { - var configuration = context.Services.GetConfiguration(); - Configure(configuration.GetSection("Elasticsearch")); - } + var configuration = context.Services.GetConfiguration(); + Configure(configuration.GetSection("Elasticsearch")); } } diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ElasticsearchIndexMappingProvider.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ElasticsearchIndexMappingProvider.cs new file mode 100644 index 000000000..38000d182 --- /dev/null +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ElasticsearchIndexMappingProvider.cs @@ -0,0 +1,387 @@ +using Elastic.Clients.Elasticsearch; +using Elastic.Clients.Elasticsearch.Mapping; +using Elastic.Transport.Products.Elasticsearch; +using Microsoft.Extensions.Caching.Memory; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Volo.Abp.DependencyInjection; + +namespace LINGYUN.Abp.Elasticsearch; + +public class ElasticsearchIndexMappingProvider : IIndexMappingProvider, ITransientDependency +{ + private readonly IMemoryCache _cache; + private readonly IElasticsearchClientFactory _clientFactory; + private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(10); + + public ElasticsearchIndexMappingProvider( + IElasticsearchClientFactory clientFactory, + IMemoryCache cache) + { + _clientFactory = clientFactory; + _cache = cache; + } + + public async Task GetMappingAsync(string indexName, CancellationToken cancellationToken = default) + { + var cacheKey = $"es_mapping_{indexName}"; + + var cacheItem = _cache.Get(cacheKey); + if (cacheItem == null) + { + var client = _clientFactory.Create(); + var response = await client.Indices.GetMappingAsync(indexName, cancellationToken); + + if (!response.IsSuccess()) + { + var errorBuilder = new StringBuilder(); + if (response.TryGetOriginalException(out var ex) && ex != null) + { + errorBuilder.AppendLine(ex.Message); + } + else if (response.TryGetElasticsearchServerError(out var error) && error != null) + { + errorBuilder.AppendLine(error.ToString()); + } + else + { + errorBuilder.AppendLine(response.DebugInformation); + } + throw new Exception($"Failed to get mapping for index {indexName}: {errorBuilder.ToString()}"); + } + + if (!response.Mappings.TryGetValue(indexName, out var indexMappingRecord)) + { + throw new Exception($"Index {indexName} not found in response"); + } + + cacheItem = ParseMapping(indexMappingRecord.Mappings, indexName); + + _cache.Set(cacheKey, cacheItem, _cacheDuration); + } + + return cacheItem; + } + + private IndexMappingInfo ParseMapping(TypeMapping mappings, string indexName) + { + var mappingInfo = new IndexMappingInfo { IndexName = indexName }; + + if (mappings?.Properties != null) + { + ParseProperties(mappings.Properties, mappingInfo, string.Empty); + } + + return mappingInfo; + } + + private void ParseProperties(Properties? properties, IndexMappingInfo mappingInfo, string parentPath) + { + if (properties == null) return; + + foreach (var kvp in properties) + { + var propertyName = kvp.Key.ToString(); + var property = kvp.Value; + var fullPath = string.IsNullOrEmpty(parentPath) + ? propertyName + : $"{parentPath}.{propertyName}"; + + var fieldInfo = new FieldMappingInfo + { + Path = fullPath, + Name = propertyName, + Type = GetPropertyType(property) + }; + + switch (property) + { + // Keyword 类型 + case KeywordProperty keyword: + fieldInfo.IsKeyword = true; + mappingInfo.KeywordFields.Add(fullPath); + break; + + // Text 类型 - 包含多字段支持 + case TextProperty text: + fieldInfo.IsText = true; + mappingInfo.TextFields.Add(fullPath); + + // 处理 Text 的 Fields(多字段) + if (text.Fields != null && text.Fields.Count() > 0) + { + fieldInfo.Properties = new Dictionary(); + + foreach (var subFieldKvp in text.Fields) + { + var subFieldName = subFieldKvp.Key.ToString(); + var subFieldProperty = subFieldKvp.Value; + var subFieldPath = $"{fullPath}.{subFieldName}"; + + var subFieldInfo = new FieldMappingInfo + { + Path = subFieldPath, + Name = subFieldName, + Type = GetPropertyType(subFieldProperty) + }; + + // 处理子字段的类型 + if (subFieldProperty is KeywordProperty) + { + subFieldInfo.IsKeyword = true; + mappingInfo.KeywordFields.Add(subFieldPath); + } + else if (subFieldProperty is TextProperty) + { + subFieldInfo.IsText = true; + mappingInfo.TextFields.Add(subFieldPath); + } + + fieldInfo.Properties[subFieldName] = subFieldInfo; + mappingInfo.Fields[subFieldPath] = subFieldInfo; + } + } + break; + + // 日期类型 + case DateProperty date: + fieldInfo.IsDate = true; + fieldInfo.Format = date.Format; + mappingInfo.DateFields.Add(fullPath); + break; + + // 日期纳秒类型 + case DateNanosProperty dateNanos: + fieldInfo.IsDate = true; + fieldInfo.Format = dateNanos.Format; + mappingInfo.DateFields.Add(fullPath); + break; + + // 数值类型 + case ByteNumberProperty: + case DoubleNumberProperty: + case FloatNumberProperty: + case HalfFloatNumberProperty: + case IntegerNumberProperty: + case LongNumberProperty: + case ScaledFloatNumberProperty: + case ShortNumberProperty: + case UnsignedLongNumberProperty: + fieldInfo.IsNumeric = true; + mappingInfo.NumericFields.Add(fullPath); + break; + + // 布尔类型 + case BooleanProperty: + fieldInfo.IsBoolean = true; + mappingInfo.BooleanFields.Add(fullPath); + break; + + // Nested 类型 + case NestedProperty nested: + fieldInfo.IsNested = true; + fieldInfo.IsObject = true; + mappingInfo.NestedFieldPaths.Add(fullPath); + + var nestedInfo = new NestedMappingInfo + { + Path = fullPath, + Name = propertyName, + Properties = new Dictionary() + }; + + if (nested.Properties != null) + { + // 先递归解析内部字段 + ParseProperties(nested.Properties, mappingInfo, fullPath); + + // 收集 nested 内部的字段信息 + foreach (var innerKvp in nested.Properties) + { + var innerName = innerKvp.Key.ToString(); + var innerFullPath = $"{fullPath}.{innerName}"; + + if (mappingInfo.Fields.TryGetValue(innerFullPath, out var innerFieldInfo)) + { + nestedInfo.Properties[innerName] = innerFieldInfo; + } + else + { + innerFieldInfo = new FieldMappingInfo + { + Path = innerFullPath, + Name = innerName, + Type = GetPropertyType(innerKvp.Value) + }; + nestedInfo.Properties[innerName] = innerFieldInfo; + mappingInfo.Fields[innerFullPath] = innerFieldInfo; + } + } + } + + mappingInfo.NestedFields[fullPath] = nestedInfo; + break; + + // Object 类型 + case ObjectProperty obj: + fieldInfo.IsObject = true; + fieldInfo.Properties = new Dictionary(); + + if (obj.Properties != null) + { + ParseProperties(obj.Properties, mappingInfo, fullPath); + } + break; + + // 范围类型 + case DateRangeProperty: + case DoubleRangeProperty: + case FloatRangeProperty: + case IntegerRangeProperty: + case LongRangeProperty: + case IpRangeProperty: + fieldInfo.IsRange = true; + break; + + // 其他类型 + case FlattenedProperty: + fieldInfo.Type = "flattened"; + break; + + case GeoPointProperty: + fieldInfo.Type = "geo_point"; + break; + + case GeoShapeProperty: + fieldInfo.Type = "geo_shape"; + break; + + case IpProperty: + fieldInfo.Type = "ip"; + break; + + case VersionProperty: + fieldInfo.Type = "version"; + break; + + case MatchOnlyTextProperty matchOnlyText: + fieldInfo.IsText = true; + fieldInfo.Type = "match_only_text"; + mappingInfo.TextFields.Add(fullPath); + + // MatchOnlyText 也可能有 Fields + if (matchOnlyText.Fields != null && matchOnlyText.Fields.Count() > 0) + { + fieldInfo.Properties = new Dictionary(); + foreach (var subFieldKvp in matchOnlyText.Fields) + { + var subFieldName = subFieldKvp.Key.ToString(); + var subFieldPath = $"{fullPath}.{subFieldName}"; + var subFieldInfo = new FieldMappingInfo + { + Path = subFieldPath, + Name = subFieldName, + Type = GetPropertyType(subFieldKvp.Value) + }; + if (subFieldKvp.Value is KeywordProperty) + { + subFieldInfo.IsKeyword = true; + mappingInfo.KeywordFields.Add(subFieldPath); + } + fieldInfo.Properties[subFieldName] = subFieldInfo; + mappingInfo.Fields[subFieldPath] = subFieldInfo; + } + } + break; + + case WildcardProperty: + fieldInfo.IsWildcard = true; + fieldInfo.Type = "wildcard"; + mappingInfo.WildcardFields.Add(fullPath); + break; + + case CompletionProperty: + fieldInfo.Type = "completion"; + break; + + case JoinProperty: + fieldInfo.Type = "join"; + break; + + case PercolatorProperty: + fieldInfo.Type = "percolator"; + break; + + case RankFeatureProperty: + fieldInfo.Type = "rank_feature"; + break; + + case RankFeaturesProperty: + fieldInfo.Type = "rank_features"; + break; + + case DenseVectorProperty: + fieldInfo.Type = "dense_vector"; + break; + + case SparseVectorProperty: + fieldInfo.Type = "sparse_vector"; + break; + + default: + fieldInfo.Type = property.GetType().Name.Replace("Property", "").ToLowerInvariant(); + break; + } + + mappingInfo.Fields[fullPath] = fieldInfo; + } + } + + private string GetPropertyType(IProperty property) + { + return property switch + { + KeywordProperty => "keyword", + TextProperty => "text", + DateProperty => "date", + DateNanosProperty => "date_nanos", + ByteNumberProperty => "byte", + DoubleNumberProperty => "double", + FloatNumberProperty => "float", + HalfFloatNumberProperty => "half_float", + IntegerNumberProperty => "integer", + LongNumberProperty => "long", + ScaledFloatNumberProperty => "scaled_float", + ShortNumberProperty => "short", + UnsignedLongNumberProperty => "unsigned_long", + BooleanProperty => "boolean", + NestedProperty => "nested", + ObjectProperty => "object", + FlattenedProperty => "flattened", + GeoPointProperty => "geo_point", + GeoShapeProperty => "geo_shape", + IpProperty => "ip", + VersionProperty => "version", + MatchOnlyTextProperty => "match_only_text", + WildcardProperty => "wildcard", + CompletionProperty => "completion", + JoinProperty => "join", + PercolatorProperty => "percolator", + RankFeatureProperty => "rank_feature", + RankFeaturesProperty => "rank_features", + DenseVectorProperty => "dense_vector", + SparseVectorProperty => "sparse_vector", + DateRangeProperty => "date_range", + DoubleRangeProperty => "double_range", + FloatRangeProperty => "float_range", + IntegerRangeProperty => "integer_range", + LongRangeProperty => "long_range", + IpRangeProperty => "ip_range", + _ => property.GetType().Name.Replace("Property", "").ToLowerInvariant() + }; + } +} 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 new file mode 100644 index 000000000..fff6a0190 --- /dev/null +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.cs @@ -0,0 +1,1356 @@ +using Elastic.Clients.Elasticsearch; +using Elastic.Clients.Elasticsearch.QueryDsl; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Linq.Expressions; +using System.Reflection; +using System.Text.Json.Serialization; +using Volo.Abp; + +namespace LINGYUN.Abp.Elasticsearch; + +/// +/// 表达式查询转换器 - 将 LINQ 表达式转换为 Elasticsearch Query +/// +/// 文档类型 +public class ExpressionQueryTranslator +{ + private readonly bool _defaultNestedBehavior; + private readonly IndexMappingInfo _mappingInfo; + + /// + /// 构造函数(使用已有的映射信息) + /// + public ExpressionQueryTranslator( + IndexMappingInfo mappingInfo, + bool defaultNestedBehavior = false) + { + _mappingInfo = mappingInfo; + _defaultNestedBehavior = defaultNestedBehavior; + } + + /// + /// 同步翻译表达式(需要已加载映射) + /// + public virtual Query Translate(Expression> expression) + { + Check.NotNull(expression, nameof(expression)); + + return TranslateNode(expression.Body, prefix: null, _mappingInfo); + } + + #region 节点翻译 + + /// + /// 翻译表达式节点 + /// + protected virtual Query TranslateNode(Expression node, string? prefix, IndexMappingInfo? mappingInfo) + { + return node switch + { + ConstantExpression { Value: bool value } => + value ? new MatchAllQuery() : new MatchNoneQuery(), + + UnaryExpression { NodeType: ExpressionType.Not } unary => + Negate(TranslateNode(unary.Operand, prefix, mappingInfo)), + + UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } unary => + TranslateNode(unary.Operand, prefix, mappingInfo), + + BinaryExpression binary => TranslateBinary(binary, prefix, mappingInfo), + + MethodCallExpression method => TranslateMethodCall(method, prefix, mappingInfo), + + MemberExpression member when IsHasValueAccess(member) => + new ExistsQuery { Field = ResolveField(member.Expression!, prefix, mappingInfo).Path }, + + MemberExpression member when member.Type == typeof(bool) => + new TermQuery { Field = ResolveField(member, prefix, mappingInfo).Path, Value = true }, + + MemberExpression member when IsNullableHasValue(member) => + new ExistsQuery { Field = ResolveField(member.Expression!, prefix, mappingInfo).Path }, + + MemberExpression member when IsNullableValueAccess(member) => + TranslateNode(member.Expression!, prefix, mappingInfo), + + _ => throw new NotSupportedException($"Unsupported expression node {node.NodeType}: {node}"), + }; + } + #endregion + + #region 二元表达式翻译 + + /// + /// 翻译二元表达式 + /// + protected virtual Query TranslateBinary(BinaryExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + return node.NodeType switch + { + ExpressionType.AndAlso or ExpressionType.And => TranslateAndAlso(node, prefix, mappingInfo), + + ExpressionType.OrElse or ExpressionType.Or => TranslateOrElse(node, prefix, mappingInfo), + + ExpressionType.Equal => TranslateComparison(node, prefix, mappingInfo), + + ExpressionType.NotEqual => TranslateComparison(node, prefix, mappingInfo), + + ExpressionType.GreaterThan or ExpressionType.GreaterThanOrEqual or + ExpressionType.LessThan or ExpressionType.LessThanOrEqual => TranslateComparison(node, prefix, mappingInfo), + + _ => throw new NotSupportedException($"Unsupported binary operator {node.NodeType}: {node}"), + }; + } + + + /// + /// 构建 And 查询(展平 BoolQuery) + /// + private Query TranslateAndAlso(BinaryExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + var filters = new List(); + + // 收集左侧所有 Filter + CollectFilters(node.Left, prefix, mappingInfo, filters); + + // 收集右侧 Filter + CollectFilters(node.Right, prefix, mappingInfo, filters); + + return new BoolQuery + { + Filter = filters.ToArray() + }; + } + + /// + /// 递归收集 Filter 查询 + /// + private void CollectFilters(Expression node, string? prefix, IndexMappingInfo? mappingInfo, List filters) + { + if (node is BinaryExpression binary && + (binary.NodeType == ExpressionType.AndAlso || binary.NodeType == ExpressionType.And)) + { + // 递归收集左右子节点 + CollectFilters(binary.Left, prefix, mappingInfo, filters); + CollectFilters(binary.Right, prefix, mappingInfo, filters); + } + else + { + // 非 And 表达式,直接翻译并添加到列表 + var query = TranslateNode(node, prefix, mappingInfo); + + // 如果翻译结果是 BoolQuery 且有 Filter,展平它 + if (query.Bool != null && query.Bool.Filter != null && query.Bool.Filter.Count > 0) + { + foreach (var subQuery in query.Bool.Filter) + { + filters.Add(subQuery); + } + } + else if (query.Bool != null && query.Bool.Must != null && query.Bool.Must.Count > 0) + { + foreach (var subQuery in query.Bool.Must) + { + filters.Add(subQuery); + } + } + else + { + filters.Add(query); + } + } + } + + /// + /// 构建 Or 查询(展平 BoolQuery) + /// + private Query TranslateOrElse(BinaryExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + var shouldQueries = new List(); + + CollectShouldQueries(node.Left, prefix, mappingInfo, shouldQueries); + CollectShouldQueries(node.Right, prefix, mappingInfo, shouldQueries); + + return new BoolQuery + { + Should = shouldQueries.ToArray(), + MinimumShouldMatch = 1 + }; + } + + /// + /// 递归收集 Should 查询 + /// + private void CollectShouldQueries(Expression node, string? prefix, IndexMappingInfo? mappingInfo, List shouldQueries) + { + if (node is BinaryExpression binary && + (binary.NodeType == ExpressionType.OrElse || binary.NodeType == ExpressionType.Or)) + { + CollectShouldQueries(binary.Left, prefix, mappingInfo, shouldQueries); + CollectShouldQueries(binary.Right, prefix, mappingInfo, shouldQueries); + } + else + { + var query = TranslateNode(node, prefix, mappingInfo); + + // 如果翻译结果是 BoolQuery 且有 Should,展平它 + if (query.Bool != null && query.Bool.Should != null && query.Bool.Should.Count > 0) + { + foreach (var subQuery in query.Bool.Should) + { + shouldQueries.Add(subQuery); + } + } + else + { + shouldQueries.Add(query); + } + } + } + + /// + /// 翻译比较表达式 + /// + protected virtual Query TranslateComparison(BinaryExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + var (fieldExpression, valueExpression) = ResolveOperands(node); + var field = ResolveField(fieldExpression, prefix, mappingInfo); + + // 处理 null 比较 + if (IsNullConstant(valueExpression)) + { + return node.NodeType == ExpressionType.NotEqual + ? new ExistsQuery { Field = field.Path } + : new BoolQuery + { + MustNot = new Query[] { new ExistsQuery { Field = field.Path } } + }; + } + + var value = Evaluate(valueExpression); + + if (value == null) + { + throw new NotSupportedException("The null value is only supported for the == null / != null comparison."); + } + + // 处理集合包含 + if (field.Type.IsArray || (field.Type.IsGenericType && typeof(IEnumerable).IsAssignableFrom(field.Type))) + { + return BuildTermsQuery(field, value); + } + + return node.NodeType switch + { + ExpressionType.Equal => BuildEquality(field, value), + ExpressionType.NotEqual => BuildNotEqualQuery(field, value), + ExpressionType.GreaterThan => BuildRange(field, greaterThan: value), + ExpressionType.GreaterThanOrEqual => BuildRange(field, greaterThanOrEqualTo: value), + ExpressionType.LessThan => BuildRange(field, lessThan: value), + ExpressionType.LessThanOrEqual => BuildRange(field, lessThanOrEqualTo: value), + _ => throw new NotSupportedException($"Unsupported comparison operator {node.NodeType}"), + }; + } + + /// + /// 解析操作数,确定字段和值 + /// + private static (Expression Field, Expression Value) ResolveOperands(BinaryExpression node) + { + var leftIsField = IsFieldLike(node.Left); + var rightIsField = IsFieldLike(node.Right); + + if (leftIsField == rightIsField) + { + throw new NotSupportedException($"The comparison expression must have one side as a field and the other side as a value: {node}"); + } + + return leftIsField ? (node.Left, node.Right) : (node.Right, node.Left); + } + + /// + /// 判断表达式是否为字段 + /// + private static bool IsFieldLike(Expression expression) + { + var current = expression; + while (current is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } unary) + { + current = unary.Operand; + } + + while (current is MemberExpression member) + { + current = member.Expression!; + } + + return current is ParameterExpression; + } + + /// + /// 判断表达式是否为 null 常量 + /// + private static bool IsNullConstant(Expression expression) + { + while (expression is UnaryExpression { NodeType: ExpressionType.Convert or ExpressionType.ConvertChecked } unary) + { + expression = unary.Operand; + } + + return expression is ConstantExpression { Value: null }; + } + + #endregion + + #region 方法调用翻译 + + /// + /// 取反查询 + /// + private static Query Negate(Query query) + { + // 检查是否为 BoolQuery(通过 Bool 属性) + if (query.Bool != null) + { + var boolQuery = query.Bool; + var newBool = new BoolQuery(); + + // MustNot -> Must + if (boolQuery.MustNot != null && boolQuery.MustNot.Count > 0) + { + newBool.Must = boolQuery.MustNot.ToArray(); + return newBool; + } + + // Must -> MustNot + if (boolQuery.Must != null && boolQuery.Must.Count > 0) + { + newBool.MustNot = boolQuery.Must.ToArray(); + return newBool; + } + + // Filter -> MustNot + if (boolQuery.Filter != null && boolQuery.Filter.Count > 0) + { + newBool.MustNot = boolQuery.Filter.ToArray(); + return newBool; + } + + // Should -> MustNot + if (boolQuery.Should != null && boolQuery.Should.Count > 0) + { + newBool.MustNot = boolQuery.Should.ToArray(); + return newBool; + } + + // 空 BoolQuery -> MatchAll + return new MatchAllQuery(); + } + + // 对于 ExistsQuery,取反后变成 MustNot + Exists + if (query.Exists != null) + { + return new BoolQuery + { + MustNot = new Query[] { query.Exists } + }; + } + + // 对于 MatchAllQuery,取反后变成 MatchNoneQuery + if (query.MatchAll != null) + { + return new MatchNoneQuery(); + } + + // 对于 MatchNoneQuery,取反后变成 MatchAllQuery + if (query.MatchNone != null) + { + return new MatchAllQuery(); + } + + // 对于 TermQuery,取反后变成 MustNot + Term + if (query.Term != null) + { + if (query.Term.Value.IsBool && query.Term.Value.TryGetBool(out var boolValue)) + { + return new TermQuery + { + Field = query.Term.Field, + Value = FieldValue.FromValue(!boolValue) + }; + } + return new BoolQuery + { + MustNot = new Query[] { query.Term } + }; + } + + // 对于 WildcardQuery,取反后变成 MustNot + Wildcard + if (query.Wildcard != null) + { + return new BoolQuery + { + MustNot = new Query[] { query.Wildcard } + }; + } + + // 对于 MatchQuery,取反后变成 MustNot + Match + if (query.Match != null) + { + return new BoolQuery + { + MustNot = new Query[] { query.Match } + }; + } + + // 对于 MatchPhraseQuery,取反后变成 MustNot + MatchPhrase + if (query.MatchPhrase != null) + { + return new BoolQuery + { + MustNot = new Query[] { query.MatchPhrase } + }; + } + + // 对于 MatchPhrasePrefixQuery,取反后变成 MustNot + MatchPhrasePrefix + if (query.MatchPhrasePrefix != null) + { + return new BoolQuery + { + MustNot = new Query[] { query.MatchPhrasePrefix } + }; + } + + // 对于 NumberRangeQuery + if (query.Range != null && query.Range is NumberRangeQuery numberRange) + { + return new BoolQuery + { + MustNot = new Query[] { numberRange } + }; + } + + // 对于 DateRangeQuery + if (query.Range != null && query.Range is DateRangeQuery dateRange) + { + return new BoolQuery + { + MustNot = new Query[] { dateRange } + }; + } + + // 对于 TermsQuery + if (query.Terms != null) + { + return new BoolQuery + { + MustNot = new Query[] { query.Terms } + }; + } + + // 对于 NestedQuery + if (query.Nested != null) + { + return new BoolQuery + { + MustNot = new Query[] { query.Nested } + }; + } + + // 对于 ScriptQuery + if (query.Script != null) + { + return new BoolQuery + { + MustNot = new Query[] { query.Script } + }; + } + + // 默认:对于未知类型,使用 MustNot 包装 + return new BoolQuery + { + MustNot = new Query[] { query } + }; + } + + /// + /// 翻译方法调用表达式 + /// + protected virtual Query TranslateMethodCall(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) + { + // Enumerable.Any / Enumerable.Contains / Enumerable.All + if (node.Method.DeclaringType == typeof(Enumerable)) + { + 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) + { + return TranslateStringMethod(node, prefix, mappingInfo); + } + + // Enum.HasFlag + if (node.Method.Name == nameof(Enum.HasFlag)) + { + return TranslateEnumHasFlag(node, prefix, mappingInfo); + } + + // List/Collection.Contains (实例方法) + if (node.Method.Name == nameof(ICollection<>.Contains) && node.Object != null) + { + return TranslateCollectionContains(node, prefix, mappingInfo); + } + + throw new NotSupportedException( + $"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) || + _defaultNestedBehavior; + + 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 查询构建 + + /// + /// 构建相等查询 + /// + protected virtual Query BuildEquality(FieldInfo field, object value) + { + var fieldName = field.Path; + + // 1. Wildcard 字段 - 使用 Term 查询 + if (field.IsWildcard) + { + return new TermQuery + { + Field = fieldName, + Value = value.ToString()! + }; + } + + // 2. Keyword 字段或字符串 + if (field.IsKeyword || field.Type == typeof(string)) + { + // 如果是枚举类型作为字符串存储,需要转换一下 + var stringValue = field.Type.IsEnum + ? NormalizeEnumName(field.Type, value) + : value.ToString()!; + return new TermQuery { Field = fieldName, Value = stringValue }; + } + + // 3. 日期字段 + if (field.IsDate || field.Type == typeof(DateTime) || field.Type == typeof(DateTime?)) + { + var date = (DateTime)value; + return new DateRangeQuery + { + Field = fieldName, + Gte = date, + Lte = date, + Format = field.Format ?? "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis" + }; + } + + // 4. 数值字段 + if (field.IsNumeric || IsNumericType(field.Type)) + { + return new TermQuery { Field = fieldName, Value = NormalizeValue(value) }; + } + + // 5. 布尔字段 + if (field.IsBoolean || field.Type == typeof(bool) || field.Type == typeof(bool?)) + { + return new TermQuery { Field = fieldName, Value = (bool)value }; + } + + // 6. 枚举(值作为数值) + if (field.Type.IsEnum) + { + return new TermQuery { Field = fieldName, Value = Convert.ToInt64(value) }; + } + + // 7. 如果值是枚举但字段类型不是枚举 + if (value.GetType().IsEnum) + { + return new TermQuery { Field = fieldName, Value = Convert.ToInt64(value) }; + } + + // 8. Text 字段 - 使用 MatchQuery + if (field.IsText) + { + return new MatchQuery + { + Field = fieldName, + Query = value.ToString()! + }; + } + + // 9. 默认 + return new TermQuery { Field = fieldName, Value = NormalizeValue(value) }; + } + + /// + /// 构建 NotEqual 查询 + /// + private Query BuildNotEqualQuery(FieldInfo field, object value) + { + // 对于布尔值,直接取反值(更简洁) + if (value is bool boolValue) + { + return new TermQuery { Field = field.Path, Value = !boolValue }; + } + + // 对于其他类型,使用 must_not 包装 + return new BoolQuery + { + MustNot = new Query[] { BuildEquality(field, value) } + }; + } + + /// + /// 构建 Terms 查询(用于集合包含) + /// + protected virtual Query BuildTermsQuery(FieldInfo field, object value) + { + var fieldName = field.Path; + + if (value is IEnumerable enumerable && value is not string) + { + var values = enumerable.Cast().Select(NormalizeValue).ToList(); + if (values.Count == 0) + { + return new MatchNoneQuery(); + } + if (values.Count == 1) + { + return BuildEquality(field, values[0]); + } + return new TermsQuery { Field = fieldName, Terms = new TermsQueryField(values) }; + } + + return BuildEquality(field, value); + } + + /// + /// 构建范围查询 + /// + protected virtual Query BuildRange( + FieldInfo field, + object? greaterThan = null, + object? greaterThanOrEqualTo = null, + object? lessThan = null, + object? lessThanOrEqualTo = null) + { + var fieldName = field.Path; + + // 日期范围 + if (field.IsDate || field.Type == typeof(DateTime) || field.Type == typeof(DateTime?)) + { + var range = new DateRangeQuery + { + Field = fieldName, + Format = field.Format ?? "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis" + }; + + if (greaterThan != null) + { + range.Gt = DateMath.FromString(NormalizeDate(greaterThan)); + } + if (greaterThanOrEqualTo != null) + { + range.Gte = DateMath.FromString(NormalizeDate(greaterThanOrEqualTo)); + } + if (lessThan != null) + { + range.Lt = DateMath.FromString(NormalizeDate(lessThan)); + } + if (lessThanOrEqualTo != null) + { + range.Lte = DateMath.FromString(NormalizeDate(lessThanOrEqualTo)); + } + + return range; + } + + // 数值范围 + var numberRange = new NumberRangeQuery { Field = fieldName }; + + if (greaterThan != null) + { + numberRange.Gt = ToNumber(greaterThan); + } + if (greaterThanOrEqualTo != null) + { + numberRange.Gte = ToNumber(greaterThanOrEqualTo); + } + if (lessThan != null) + { + numberRange.Lt = ToNumber(lessThan); + } + if (lessThanOrEqualTo != null) + { + numberRange.Lte = ToNumber(lessThanOrEqualTo); + } + + return numberRange; + } + + + #endregion + + #region 辅助方法 + + /// + /// 解包 Lambda 表达式 + /// + private static LambdaExpression UnwrapLambda(Expression expression) + { + while (expression is UnaryExpression { NodeType: ExpressionType.Quote } unary) + { + expression = unary.Operand; + } + return (LambdaExpression)expression; + } + + /// + /// 求值表达式 + /// + private static object? Evaluate(Expression expression) + { + if (expression is ConstantExpression constant) + { + return constant.Value; + } + + // 处理成员访问(捕获外部变量) + 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 (memberExpr.Member is PropertyInfo propertyInfo) + { + return propertyInfo.GetValue(obj); + } + } + + // 处理数组索引 + if (expression is IndexExpression indexExpr) + { + var obj = Evaluate(indexExpr.Object!); + var indices = indexExpr.Arguments.Select(Evaluate).ToArray(); + if (obj is Array array && indices.Length == 1 && indices[0] is int index) + { + return array.GetValue(index); + } + } + + // 处理方法调用 + if (expression is MethodCallExpression methodCall) + { + try + { + return Expression.Lambda(methodCall).Compile().DynamicInvoke(); + } + catch + { + return null; + } + } + + // 处理 NewArrayExpression + if (expression is NewArrayExpression newArray) + { + var values = newArray.Expressions.Select(Evaluate).ToArray(); + return values; + } + + return Expression.Lambda(expression).Compile().DynamicInvoke(); + } + + /// + /// 转义通配符 + /// + private static string EscapeWildcard(string value) + { + return value.Replace("\\", "\\\\").Replace("*", "\\*").Replace("?", "\\?"); + } + + /// + /// 标准化值 + /// + private static FieldValue NormalizeValue(object value) + { + return value switch + { + string s => s, + bool b => b, + int i => i, + long l => l, + double d => d, + float f => f, + decimal dec => Convert.ToDouble(dec), + Guid g => g.ToString(), + Enum e => Convert.ToInt64(e), + DateTime dt => NormalizeDate(dt), + DateTimeOffset dto => NormalizeDate(dto), + _ => Convert.ToDouble(value) + }; + } + + /// + /// 规范化日期值 + /// + private static string NormalizeDate(object value) + { + if (value is DateTime dateTime) + { + // 格式化为 Elasticsearch 支持的格式(不带时区) + return dateTime.ToString("yyyy-MM-dd HH:mm:ss"); + } + + if (value is DateTimeOffset dateTimeOffset) + { + return dateTimeOffset.ToString("yyyy-MM-dd HH:mm:ss"); + } + + return value.ToString()!; + } + /// + /// 转换为 Number + /// + private static Number? ToNumber(object? value) + { + return value == null + ? null + : value switch + { + Number n => n, + sbyte v => (Number)v, + byte v => (Number)v, + short v => (Number)v, + ushort v => (Number)v, + int v => (Number)v, + uint v => (Number)v, + long v => (Number)v, + ulong v => (Number)v, + float v => (Number)v, + double v => (Number)v, + decimal v => (Number)(double)v, + _ => (Number)Convert.ToDouble(value) + }; + } + + /// + /// 规范化枚举名称 + /// + private static string NormalizeEnumName(Type enumType, object value) + { + var enumValue = value is Enum e + ? e + : (Enum)Enum.ToObject(enumType, value); + return enumValue.ToString(); + } + + /// + /// 判断是否为数值类型 + /// + private static bool IsNumericType(Type type) + { + var underlyingType = Nullable.GetUnderlyingType(type) ?? type; + return underlyingType == typeof(byte) || underlyingType == typeof(sbyte) || + underlyingType == typeof(short) || underlyingType == typeof(ushort) || + underlyingType == typeof(int) || underlyingType == typeof(uint) || + underlyingType == typeof(long) || underlyingType == typeof(ulong) || + underlyingType == typeof(float) || underlyingType == typeof(double) || + underlyingType == typeof(decimal); + } + + /// + /// 判断是否为 Nullable.HasValue 访问 + /// + private static bool IsNullableHasValue(MemberExpression member) + { + return member.Member.Name == nameof(Nullable<>.HasValue) + && member.Expression is MemberExpression + && member.Member.DeclaringType?.IsGenericType == true + && member.Member.DeclaringType.GetGenericTypeDefinition() == typeof(Nullable<>); + } + + /// + /// 判断是否为 Nullable.Value 访问 + /// + private static bool IsNullableValueAccess(MemberExpression member) + { + return member.Member.Name == nameof(Nullable<>.Value) + && member.Expression is MemberExpression + && member.Member.DeclaringType?.IsGenericType == true + && member.Member.DeclaringType.GetGenericTypeDefinition() == typeof(Nullable<>); + } + + private static bool IsHasValueAccess(MemberExpression member) + { + return IsNullableHasValue(member); + } + + #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/FieldInfo.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/FieldInfo.cs new file mode 100644 index 000000000..aa08c9397 --- /dev/null +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/FieldInfo.cs @@ -0,0 +1,52 @@ +using System; + +namespace LINGYUN.Abp.Elasticsearch; +/// +/// 字段信息 +/// +public record FieldInfo +{ + public string Path { get; init; } + public Type Type { get; init; } + public string Name { get; init; } + public bool IsKeyword { get; init; } + public bool IsWildcard { get; init; } + public bool IsText { get; init; } + public bool IsNested { get; init; } + public bool IsDate { get; init; } + public bool IsNumeric { get; init; } + public bool IsBoolean { get; init; } + public bool IsRange { get; init; } + public string? Format { get; init; } + public bool HasMultiFields { get; init; } + + public FieldInfo( + string path, + Type type, + string name, + bool isKeyword = false, + bool isText = false, + bool isWildcard = false, + bool isNested = false, + bool isDate = false, + bool isNumeric = false, + bool isBoolean = false, + bool isRange = false, + string? format = null, + bool hasMultiFields = false) + { + Path = path; + Type = type; + Name = name; + IsKeyword = isKeyword; + IsText = isText; + IsWildcard = isWildcard; + IsNested = isNested; + IsDate = isDate; + IsNumeric = isNumeric; + IsBoolean = isBoolean; + IsRange = isRange; + Format = format; + HasMultiFields = hasMultiFields; + } +} diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/FieldMappingInfo.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/FieldMappingInfo.cs new file mode 100644 index 000000000..a43d393c1 --- /dev/null +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/FieldMappingInfo.cs @@ -0,0 +1,47 @@ +using System; +using System.Collections.Generic; + +namespace LINGYUN.Abp.Elasticsearch; + +public class FieldMappingInfo +{ + public string Path { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Type { get; set; } = string.Empty; + public Type? ClrType { get; set; } + + public bool IsKeyword { get; set; } + public bool IsText { get; set; } + public bool IsWildcard { get; set; } + public bool IsNested { get; set; } + public bool IsObject { get; set; } + public bool IsDate { get; set; } + public bool IsNumeric { get; set; } + public bool IsBoolean { get; set; } + public bool IsRange { get; set; } + + public string? Format { get; set; } + public bool? Store { get; set; } + public bool? Index { get; set; } + + // 子字段(用于 text 的 keyword 子字段,或 object/nested 的内部字段) + public Dictionary? Properties { get; set; } + public Dictionary? Meta { get; set; } + + public bool HasMultiFields => Properties?.Count > 0; + + public string GetKeywordPath() + { + if (IsKeyword) return Path; + + // 如果是 text 类型且有 keyword 子字段 + if (IsText && Properties?.ContainsKey("keyword") == true) + { + return $"{Path}.keyword"; + } + + return Path; + } + + public bool IsComparable => IsDate || IsNumeric || IsRange; +} \ No newline at end of file diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IIndexMappingProvider.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IIndexMappingProvider.cs new file mode 100644 index 000000000..fbbfbd86b --- /dev/null +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IIndexMappingProvider.cs @@ -0,0 +1,9 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace LINGYUN.Abp.Elasticsearch; + +public interface IIndexMappingProvider +{ + Task GetMappingAsync(string indexName, CancellationToken cancellationToken = default); +} diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IndexMappingInfo.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IndexMappingInfo.cs new file mode 100644 index 000000000..46c7c29e1 --- /dev/null +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IndexMappingInfo.cs @@ -0,0 +1,139 @@ +using System.Collections.Generic; +using System.Linq; + +namespace LINGYUN.Abp.Elasticsearch; +/// +/// 索引映射信息 +/// +public class IndexMappingInfo +{ + /// + /// 索引名称 + /// + public string IndexName { get; set; } = string.Empty; + + /// + /// 所有字段映射(扁平化) + /// + public Dictionary Fields { get; set; } = new(); + + /// + /// Nested 字段映射 + /// + public Dictionary NestedFields { get; set; } = new(); + + /// + /// Keyword 字段列表 + /// + public HashSet KeywordFields { get; set; } = new(); + + /// + /// Text 字段列表 + /// + public HashSet TextFields { get; set; } = new(); + /// + /// Wildcard 字段列表 + /// + public HashSet WildcardFields { get; set; } = new(); + + /// + /// 日期字段列表 + /// + public HashSet DateFields { get; set; } = new(); + + /// + /// 数值字段列表 + /// + public HashSet NumericFields { get; set; } = new(); + + /// + /// 布尔字段列表 + /// + public HashSet BooleanFields { get; set; } = new(); + + /// + /// Nested 字段路径列表 + /// + public HashSet NestedFieldPaths { get; set; } = new(); + + /// + /// 获取字段映射信息 + /// + public FieldMappingInfo? GetField(string path) + { + return Fields.GetOrDefault(path); + } + + /// + /// 判断是否为 Nested 字段 + /// + public bool IsNested(string path) + { + return NestedFields.ContainsKey(path) || NestedFieldPaths.Contains(path); + } + + /// + /// 获取 Nested 字段信息 + /// + public NestedMappingInfo? GetNestedField(string path) + { + return NestedFields.GetOrDefault(path); + } + + /// + /// 获取字段的精确匹配路径(处理 text 的 keyword 子字段) + /// + public string GetExactFieldPath(string path) + { + var field = GetField(path); + if (field == null) + { + return path; + } + + return field.GetKeywordPath(); + } + + /// + /// 判断字段是否需要 Nested 查询 + /// + public bool ShouldUseNestedQuery(string path) + { + // 检查路径本身是否是 Nested + if (IsNested(path)) + { + return true; + } + + // 检查路径的父级是否是 Nested + var parts = path.Split('.'); + for (int i = 0; i < parts.Length - 1; i++) + { + var parentPath = string.Join(".", parts.Take(i + 1)); + if (IsNested(parentPath)) + { + return true; + } + } + + return false; + } + + /// + /// 获取 Nested 字段路径(如果字段在 Nested 内部,返回最近的 Nested 父级路径) + /// + public string? GetNestedParentPath(string path) + { + var parts = path.Split('.'); + for (int i = parts.Length - 1; i >= 0; i--) + { + var parentPath = string.Join(".", parts.Take(i + 1)); + if (IsNested(parentPath)) + { + return parentPath; + } + } + + return null; + } +} \ No newline at end of file diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IsExternalInit.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IsExternalInit.cs new file mode 100644 index 000000000..ef904e6c2 --- /dev/null +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IsExternalInit.cs @@ -0,0 +1,4 @@ +namespace System.Runtime.CompilerServices +{ + internal static class IsExternalInit { } +} \ No newline at end of file diff --git a/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/NestedMappingInfo.cs b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/NestedMappingInfo.cs new file mode 100644 index 000000000..cad1deed0 --- /dev/null +++ b/aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/NestedMappingInfo.cs @@ -0,0 +1,89 @@ +using System.Collections.Generic; + +namespace LINGYUN.Abp.Elasticsearch; + +/// +/// Nested 字段的映射信息 +/// +public class NestedMappingInfo +{ + /// + /// Nested 字段的完整路径 + /// + public string Path { get; set; } = string.Empty; + + /// + /// Nested 字段名称(最后一段) + /// + public string Name { get; set; } = string.Empty; + + /// + /// Nested 内部的属性映射 + /// + public Dictionary Properties { get; set; } = new(); + + /// + /// 是否包含指定子字段 + /// + public bool ContainsProperty(string propertyName) + { + return Properties.ContainsKey(propertyName); + } + + /// + /// 获取子字段信息 + /// + public FieldMappingInfo? GetProperty(string propertyName) + { + return Properties.GetOrDefault(propertyName); + } + + /// + /// 获取 nested 内部字段的完整路径 + /// + public string GetFullPath(string propertyName) + { + return $"{Path}.{propertyName}"; + } + + /// + /// 获取 nested 内部字段的映射信息(递归) + /// + public FieldMappingInfo? GetNestedField(string fullPath) + { + // 去掉当前 nested 路径前缀 + if (!fullPath.StartsWith(Path + ".")) + { + return null; + } + + var remainingPath = fullPath.Substring(Path.Length + 1); + var parts = remainingPath.Split('.'); + + FieldMappingInfo? current = null; + Dictionary? currentProperties = Properties; + + for (var i = 0; i < parts.Length; i++) + { + var part = parts[i]; + + if (currentProperties == null) + { + return null; + } + + if (!currentProperties.TryGetValue(part, out current)) + { + return null; + } + + // 如果还有下一级,且当前字段是 object 或 nested 类型 + if (i < parts.Length - 1) + { + currentProperties = current.Properties; + } + } + + return current; + } +} diff --git a/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN.Abp.Elasticsearch.Tests.csproj b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN.Abp.Elasticsearch.Tests.csproj new file mode 100644 index 000000000..b42899e5b --- /dev/null +++ b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN.Abp.Elasticsearch.Tests.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + + false + AnyCPU + enable + + + + + + + + + + + + diff --git a/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/AbpElasticsearchTestBase.cs b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/AbpElasticsearchTestBase.cs new file mode 100644 index 000000000..2b70bfcd7 --- /dev/null +++ b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/AbpElasticsearchTestBase.cs @@ -0,0 +1,7 @@ +using LINGYUN.Abp.Tests; + +namespace LINGYUN.Abp.Elasticsearch; + +public abstract class AbpElasticsearchTestBase : AbpTestsBase +{ +} 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 new file mode 100644 index 000000000..7cc2a584b --- /dev/null +++ b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/AbpElasticsearchTestModule.cs @@ -0,0 +1,142 @@ +using Elastic.Clients.Elasticsearch.IndexManagement; +using Elastic.Clients.Elasticsearch.Mapping; +using Elastic.Transport.Diagnostics.Auditing; +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; + +[DependsOn( + typeof(AbpTestsBaseModule), + 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) + { + context.Services.ReplaceConfiguration(ConfigurationHelper.BuildConfiguration(builderAction: builder => + { + 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/ExpressionQueryTranslator_Tests.cs b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator_Tests.cs new file mode 100644 index 000000000..99163a96f --- /dev/null +++ b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator_Tests.cs @@ -0,0 +1,1173 @@ +using Elastic.Clients.Elasticsearch.QueryDsl; +using Shouldly; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Xunit; + +namespace LINGYUN.Abp.Elasticsearch.Tests; + +public class ExpressionQueryTranslatorTests : AbpElasticsearchTestBase +{ + private readonly IIndexMappingProvider _indexMappingProvider; + + public ExpressionQueryTranslatorTests() + { + _indexMappingProvider = GetRequiredService(); + } + + private async Task> CreateTranslator( + bool defaultNestedBehavior = false) + { + var indexMapping = await _indexMappingProvider.GetMappingAsync(TestDocumentIndexNames.Index); + return new ExpressionQueryTranslator( + indexMapping, + defaultNestedBehavior); + } + + #region 基础查询测试 + + [Fact] + public async Task Translate_ConstantTrue_ReturnsMatchAllQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => true); + + // Assert + query.ShouldNotBeNull(); + query.MatchAll.ShouldNotBeNull(); + } + + [Fact] + public async Task Translate_ConstantFalse_ReturnsMatchNoneQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => false); + + // Assert + query.ShouldNotBeNull(); + query.MatchNone.ShouldNotBeNull(); + } + + [Fact] + public async Task Translate_NullExpression_ThrowsArgumentNullException() + { + // Arrange + var translator = await CreateTranslator(); + + // Act & Assert + Should.Throw(() => translator.Translate(null!)); + } + + #endregion + + #region 相等性查询测试 + + [Fact] + public async Task Translate_Equal_NumericField_ReturnsTermQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var idValue = 100; + var query = translator.Translate(x => x.Id == idValue); + + // Assert + query.ShouldNotBeNull(); + query.Term.ShouldNotBeNull(); + query.Term.Field.ToString().ShouldBe("Id"); + query.Term.Value.ShouldBe(idValue); + } + + [Fact] + public async Task Translate_Equal_StringField_ReturnsTermQueryWithKeyword() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var stringValue = "test"; + var query = translator.Translate(x => x.Name == stringValue); + + // Assert + query.ShouldNotBeNull(); + query.Term.ShouldNotBeNull(); + query.Term.Field.ToString().ShouldBe("Name.keyword"); + query.Term.Value.ShouldBe(stringValue); + } + + [Fact] + public async Task Translate_Equal_BooleanField_ReturnsTermQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var boolValue = true; + var query = translator.Translate(x => x.IsActive == boolValue); + + // Assert + query.ShouldNotBeNull(); + query.Term.ShouldNotBeNull(); + query.Term.Field.ToString().ShouldBe("IsActive"); + query.Term.Value.ShouldBe(boolValue); + } + + [Fact] + public async Task Translate_Equal_EnumField_ReturnsTermQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var enumValue = TestEnum.Active; + var query = translator.Translate(x => x.Status == enumValue); + + // Assert + query.ShouldNotBeNull(); + query.Term.ShouldNotBeNull(); + query.Term.Field.ToString().ShouldBe("Status"); + query.Term.Value.ShouldBe((long)enumValue); + } + + [Fact] + public async Task Translate_Equal_StringEnumField_ReturnsTermQueryWithString() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var enumValue = TestEnum.Pending; + var query = translator.Translate(x => x.StringValueStatus == enumValue); + + // Assert + query.ShouldNotBeNull(); + query.Term.ShouldNotBeNull(); + query.Term.Field.ToString().ShouldBe("StringValueStatus.keyword"); + query.Term.Value.ShouldBe(enumValue.ToString()); + } + + [Fact] + public async Task Translate_NotEqual_StringField_ReturnsBoolMustNot() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Name != "test"); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.MustNot.ShouldNotBeNull(); + query.Bool.MustNot.Count.ShouldBe(1); + + var mustNot = query.Bool.MustNot.First(); + mustNot.ShouldNotBeNull(); + mustNot.Term.ShouldNotBeNull(); + mustNot.Term.Field.ToString().ShouldBe("Name.keyword"); + } + + [Fact] + public async Task Translate_Equal_NullableEnumField_ReturnsTermQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var enumValue = TestEnum.Inactive; + var query = translator.Translate(x => x.NullableStatus == enumValue); + + // Assert + query.ShouldNotBeNull(); + query.Term.ShouldNotBeNull(); + query.Term.Field.ToString().ShouldBe("NullableStatus"); + query.Term.Value.ShouldBe((long)enumValue); + } + + [Fact] + public async Task Translate_Equal_DecimalField_ReturnsTermQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Salary == 5000.50m); + + // Assert + query.ShouldNotBeNull(); + query.Term.ShouldNotBeNull(); + query.Term.Field.ToString().ShouldBe("Salary"); + } + + [Fact] + public async Task Translate_Equal_NullableDateField_ReturnsDateRangeQuery() + { + // Arrange + var translator = await CreateTranslator(); + var date = new DateTime(2024, 3, 15, 10, 30, 0); + + // Act + var query = translator.Translate(x => x.UpdatedTime == date); + + // Assert + query.ShouldNotBeNull(); + var rangeQuery = query.Range.ShouldBeOfType(); + rangeQuery.Field.ToString().ShouldBe("UpdatedTime"); + rangeQuery.Gte.ShouldNotBeNull(); + rangeQuery.Lte.ShouldNotBeNull(); + } + + #endregion + + #region Null 比较测试 + + [Fact] + public async Task Translate_EqualNull_ReturnsMustNotExistsQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Name == null); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.MustNot.ShouldNotBeNull(); + query.Bool.MustNot.Count.ShouldBe(1); + + var mustNot = query.Bool.MustNot.First(); + mustNot.ShouldNotBeNull(); + mustNot.Exists.ShouldNotBeNull(); + mustNot.Exists.Field.ToString().ShouldBe("Name.keyword"); + } + + [Fact] + public async Task Translate_NotEqualNull_ReturnsExistsQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Name != null); + + // Assert + query.ShouldNotBeNull(); + query.Exists.ShouldNotBeNull(); + query.Exists.Field.ToString().ShouldBe("Name.keyword"); + } + + [Fact] + public async Task Translate_NullableType_IsNull_ReturnsMustNotExists() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.UpdatedTime == null); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.MustNot.ShouldNotBeNull(); + query.Bool.MustNot.Count.ShouldBe(1); + query.Bool.MustNot.First().Exists.ShouldNotBeNull(); + } + + [Fact] + public async Task Translate_NullableType_IsNotNull_ReturnsExistsQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.UpdatedTime != null); + + // Assert + query.ShouldNotBeNull(); + query.Exists.ShouldNotBeNull(); + query.Exists.Field.ToString().ShouldBe("UpdatedTime"); + } + + [Fact] + public async Task Translate_NullableEnum_IsNull_ReturnsMustNotExists() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.NullableStatus == null); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.MustNot.ShouldNotBeNull(); + query.Bool.MustNot.Count.ShouldBe(1); + query.Bool.MustNot.First().Exists.ShouldNotBeNull(); + } + + #endregion + + #region 逻辑运算测试 + + [Fact] + public async Task Translate_AndAlso_TwoConditions_ReturnsBoolFilterWithTwoQueries() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Age > 18 && x.IsActive == true); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.Filter.ShouldNotBeNull(); + query.Bool.Filter.Count.ShouldBe(2); + } + + [Fact] + public async Task Translate_AndAlso_ThreeConditions_ReturnsBoolFilterWithThreeQueries() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Age > 18 && x.IsActive == true && x.Name != null); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.Filter.ShouldNotBeNull(); + query.Bool.Filter.Count.ShouldBe(3); + } + + [Fact] + public async Task Translate_OrElse_TwoConditions_ReturnsBoolShouldWithTwoQueries() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Age > 18 || x.IsActive == true); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.Should.ShouldNotBeNull(); + query.Bool.Should.Count.ShouldBe(2); + query.Bool.MinimumShouldMatch.ShouldNotBeNull(); + query.Bool.MinimumShouldMatch.Value1.ShouldBe(1); + } + + [Fact] + public async Task Translate_OrElse_ThreeConditions_ReturnsBoolShouldWithThreeQueries() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Age > 18 || x.IsActive == true || x.Name != null); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.Should.ShouldNotBeNull(); + query.Bool.Should.Count.ShouldBe(3); + query.Bool.MinimumShouldMatch.ShouldNotBeNull(); + query.Bool.MinimumShouldMatch.Value1.ShouldBe(1); + } + + [Fact] + public async Task Translate_AndAlsoWithOrElse_ReturnsNestedBoolQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => (x.Age > 18 || x.IsActive == true) && x.Name != null); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.Filter.ShouldNotBeNull(); + query.Bool.Filter.Count.ShouldBe(2); + + // 检查第一个 filter 是否为 Should 查询 + var shouldQuery = query.Bool.Filter.FirstOrDefault(q => q.Bool?.Should != null); + shouldQuery.ShouldNotBeNull(); + shouldQuery.Bool.ShouldNotBeNull(); + shouldQuery.Bool.Should.ShouldNotBeNull(); + shouldQuery.Bool.Should.Count.ShouldBe(2); + } + + [Fact] + public async Task Translate_Not_ReturnsNegatedBooleanQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => !x.IsActive); + + // Assert + query.ShouldNotBeNull(); + query.Term.ShouldNotBeNull(); + query.Term.Field.ToString().ShouldBe("IsActive"); + query.Term.Value.TryGetBool(out var value).ShouldBeTrue(); + value.Value.ShouldBeFalse(); + } + + [Fact] + public async Task Translate_Not_OnComparison_ReturnsMustNotQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => !(x.Age > 18)); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.MustNot.ShouldNotBeNull(); + query.Bool.MustNot.Count.ShouldBe(1); + query.Bool.MustNot.First().Range.ShouldNotBeNull(); + } + + #endregion + + #region 范围查询测试 + + [Fact] + public async Task Translate_GreaterThan_ReturnsRangeQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Age > 18); + + // Assert + query.ShouldNotBeNull(); + var rangeQuery = query.Range.ShouldBeOfType(); + rangeQuery.Field.ToString().ShouldBe("Age"); + rangeQuery.Gt.ShouldNotBeNull(); + rangeQuery.Gt!.Value.ShouldBe(18); + } + + [Fact] + public async Task Translate_GreaterThanOrEqual_ReturnsRangeQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Age >= 18); + + // Assert + query.ShouldNotBeNull(); + var rangeQuery = query.Range.ShouldBeOfType(); + rangeQuery.Field.ToString().ShouldBe("Age"); + rangeQuery.Gte.ShouldNotBeNull(); + rangeQuery.Gte!.Value.ShouldBe(18); + } + + [Fact] + public async Task Translate_LessThan_ReturnsRangeQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Age < 65); + + // Assert + query.ShouldNotBeNull(); + var rangeQuery = query.Range.ShouldBeOfType(); + rangeQuery.Field.ToString().ShouldBe("Age"); + rangeQuery.Lt.ShouldNotBeNull(); + rangeQuery.Lt!.Value.ShouldBe(65); + } + + [Fact] + public async Task Translate_LessThanOrEqual_ReturnsRangeQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Age <= 65); + + // Assert + query.ShouldNotBeNull(); + var rangeQuery = query.Range.ShouldBeOfType(); + rangeQuery.Field.ToString().ShouldBe("Age"); + rangeQuery.Lte.ShouldNotBeNull(); + rangeQuery.Lte!.Value.ShouldBe(65); + } + + [Fact] + public async Task Translate_DateComparison_GreaterThan_ReturnsDateRangeQuery() + { + // Arrange + var translator = await CreateTranslator(); + var date = new DateTime(2024, 1, 15); + + // Act + var query = translator.Translate(x => x.CreatedTime > date); + + // Assert + query.ShouldNotBeNull(); + var rangeQuery = query.Range.ShouldBeOfType(); + rangeQuery.Field.ToString().ShouldBe("CreatedTime"); + rangeQuery.Gt.ShouldNotBeNull(); + } + + [Fact] + public async Task Translate_DateComparison_BetweenDates_ReturnsDateRangeQuery() + { + // Arrange + var translator = await CreateTranslator(); + var startDate = new DateTime(2024, 1, 1); + var endDate = new DateTime(2024, 12, 31); + + // Act + var query = translator.Translate(x => x.CreatedTime >= startDate && x.CreatedTime <= endDate); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.Filter.ShouldNotBeNull(); + query.Bool.Filter.Count.ShouldBe(2); + + // 两个 filter 都应该是 DateRangeQuery + query.Bool.Filter.ShouldAllBe(filter => filter.Range is DateRangeQuery); + } + + [Fact] + public async Task Translate_DecimalComparison_ReturnsRangeQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Salary >= 3000.50m); + + // Assert + query.ShouldNotBeNull(); + var rangeQuery = query.Range.ShouldBeOfType(); + rangeQuery.Field.ToString().ShouldBe("Salary"); + rangeQuery.Gte.ShouldNotBeNull(); + } + + #endregion + + #region 字符串方法测试 + + [Fact] + public async Task Translate_StringContains_KeywordField_ReturnsWildcardQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Name!.Contains("test")); + + // Assert + query.ShouldNotBeNull(); + query.Wildcard.ShouldNotBeNull(); + query.Wildcard.Field.ToString().ShouldBe("Name.keyword"); + query.Wildcard.Value.ShouldBe("*test*"); + } + + [Fact] + public async Task Translate_StringStartsWith_KeywordField_ReturnsWildcardQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Name!.StartsWith("test")); + + // Assert + query.ShouldNotBeNull(); + query.Wildcard.ShouldNotBeNull(); + query.Wildcard.Field.ToString().ShouldBe("Name.keyword"); + query.Wildcard.Value.ShouldBe("test*"); + } + + [Fact] + public async Task Translate_StringEndsWith_KeywordField_ReturnsWildcardQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Name!.EndsWith("test")); + + // Assert + query.ShouldNotBeNull(); + query.Wildcard.ShouldNotBeNull(); + query.Wildcard.Field.ToString().ShouldBe("Name.keyword"); + query.Wildcard.Value.ShouldBe("*test"); + } + + [Fact] + public async Task Translate_StringContains_WildcardField_ReturnsWildcardQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Exceptions!.Contains("error")); + + // Assert + query.ShouldNotBeNull(); + query.Wildcard.ShouldNotBeNull(); + query.Wildcard.Field.ToString().ShouldBe("Exceptions"); + query.Wildcard.Value.ShouldBe("*error*"); + } + + [Fact] + public async Task Translate_StringContains_TextWithoutKeyword_ReturnsMatchPhraseQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Description!.Contains("hello world")); + + // Assert + query.ShouldNotBeNull(); + query.MatchPhrase.ShouldNotBeNull(); + query.MatchPhrase.Field.ToString().ShouldBe("Description"); + query.MatchPhrase.Query.ShouldBe("hello world"); + } + + [Fact] + public async Task Translate_StringStartsWith_TextWithoutKeyword_ReturnsMatchPhrasePrefixQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Description!.StartsWith("hello")); + + // Assert + query.ShouldNotBeNull(); + query.MatchPhrasePrefix.ShouldNotBeNull(); + query.MatchPhrasePrefix.Field.ToString().ShouldBe("Description"); + query.MatchPhrasePrefix.Query.ShouldBe("hello"); + } + + [Fact] + public async Task Translate_StringContains_EscapesWildcardCharacters() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Name!.Contains("test*value")); + + // Assert + query.ShouldNotBeNull(); + query.Wildcard.ShouldNotBeNull(); + query.Wildcard.Value.ShouldBe("*test\\*value*"); + } + + [Fact] + public async Task Translate_StringContains_EscapesQuestionMark() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Name!.Contains("test?value")); + + // Assert + query.ShouldNotBeNull(); + query.Wildcard.ShouldNotBeNull(); + query.Wildcard.Value.ShouldBe("*test\\?value*"); + } + + [Fact] + public async Task Translate_StringEquals_ReturnsTermQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Name!.Equals("test")); + + // Assert + query.ShouldNotBeNull(); + query.Term.ShouldNotBeNull(); + query.Term.Field.ToString().ShouldBe("Name.keyword"); + query.Term.Value.TryGetString(out var value).ShouldBeTrue(); + value.ShouldBe("test"); + } + + #endregion + + #region 集合方法测试 + + [Fact] + public async Task Translate_EnumerableContains_Tags_ReturnsTermsQuery() + { + // Arrange + var translator = await CreateTranslator(); + var tags = new List { "tag1", "tag2", "tag3" }; + + // Act + var query = translator.Translate(x => tags.Contains(x.Name!)); + + // Assert + query.ShouldNotBeNull(); + query.Terms.ShouldNotBeNull(); + query.Terms.Field.ToString().ShouldBe("Name.keyword"); + } + + [Fact] + public async Task Translate_CollectionContains_SingleItem_ReturnsTermQuery() + { + // Arrange + var translator = await CreateTranslator(); + var tags = new List { "tag1" }; + + // Act + var query = translator.Translate(x => tags.Contains(x.Name!)); + + // Assert + query.ShouldNotBeNull(); + query.Term.ShouldNotBeNull(); + query.Term.Field.ToString().ShouldBe("Name.keyword"); + } + + [Fact] + public async Task Translate_EnumerableContains_EmptyCollection_ReturnsMatchNoneQuery() + { + // Arrange + var translator = await CreateTranslator(); + var tags = new List(); + + // Act + var query = translator.Translate(x => tags.Contains(x.Name!)); + + // Assert + query.ShouldNotBeNull(); + query.MatchNone.ShouldNotBeNull(); + } + + [Fact] + public async Task Translate_EnumerableAny_WithoutPredicate_ReturnsNestedExistsQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Items!.Any()); + + // Assert + query.ShouldNotBeNull(); + query.Nested.ShouldNotBeNull(); + query.Nested.Path.ShouldBe("Items"); + query.Nested.Query.Exists.ShouldNotBeNull(); + } + + [Fact] + public async Task Translate_EnumerableAny_WithPredicate_ReturnsNestedQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Items!.Any(i => i.Id == 10)); + + // Assert + query.ShouldNotBeNull(); + query.Nested.ShouldNotBeNull(); + query.Nested.Path.ShouldBe("Items"); + query.Nested.Query.Term.ShouldNotBeNull(); + query.Nested.Query.Term.Field.ToString().ShouldBe("Items.Id"); + } + + [Fact] + public async Task Translate_EnumerableAny_WithComplexPredicate_ReturnsNestedBoolQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Items!.Any(i => i.Price > 100 && i.Price < 1000)); + + // Assert + query.ShouldNotBeNull(); + query.Nested.ShouldNotBeNull(); + query.Nested.Path.ShouldBe("Items"); + query.Nested.Query.Bool.ShouldNotBeNull(); + query.Nested.Query.Bool.Filter.ShouldNotBeNull(); + query.Nested.Query.Bool.Filter.Count.ShouldBe(2); + } + + [Fact] + public async Task Translate_EnumerableAll_ReturnsNestedQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Items!.All(i => i.Price > 0)); + + // Assert + query.ShouldNotBeNull(); + query.Nested.ShouldNotBeNull(); + query.Nested.Path.ShouldBe("Items"); + query.Nested.Query.Range.ShouldNotBeNull(); + } + + #endregion + + #region 嵌套属性测试 + + [Fact] + public async Task Translate_NestedObjectProperty_ReturnsQueryWithNestedPath() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Address!.City == "Beijing"); + + // Assert + query.ShouldNotBeNull(); + query.Term.ShouldNotBeNull(); + query.Term.Field.ToString().ShouldBe("Address.City.keyword"); + } + + [Fact] + public async Task Translate_NestedObjectStringContains_ReturnsWildcardQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Address!.City!.Contains("bei")); + + // Assert + query.ShouldNotBeNull(); + query.Wildcard.ShouldNotBeNull(); + query.Wildcard.Field.ToString().ShouldBe("Address.City.keyword"); + query.Wildcard.Value.ShouldBe("*bei*"); + } + + [Fact] + public async Task Translate_NestedObjectProperty_NotEqualNull_ReturnsExistsQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Address!.City != null); + + // Assert + query.ShouldNotBeNull(); + query.Exists.ShouldNotBeNull(); + query.Exists.Field.ToString().ShouldBe("Address.City.keyword"); + } + + #endregion + + #region Nullable 属性测试 + + [Fact] + public async Task Translate_NullableDate_HasValue_ReturnsExistsQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.UpdatedTime.HasValue); + + // Assert + query.ShouldNotBeNull(); + query.Exists.ShouldNotBeNull(); + query.Exists.Field.ToString().ShouldBe("UpdatedTime"); + } + + #endregion + + #region 复杂表达式测试 + + [Fact] + public async Task Translate_ComplexExpression_ReturnsCorrectQuery() + { + // Arrange + var translator = await CreateTranslator(); + var minAge = 18; + var maxAge = 65; + var namePrefix = "test"; + + // Act + var query = translator.Translate(x => + (x.Age >= minAge && x.Age <= maxAge) && + (x.Name != null && x.Name.StartsWith(namePrefix)) && + x.IsActive == true && + (x.Status == TestEnum.Active || x.Status == TestEnum.Pending)); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.Filter.ShouldNotBeNull(); + query.Bool.Filter.Count.ShouldBe(6); + + // 1. 验证年龄范围查询 + var rangeQueries = query.Bool.Filter.Where(q => q.Range != null).ToList(); + rangeQueries.Count.ShouldBe(2); + + var minAgeRange = rangeQueries[0].Range.ShouldBeOfType(); + minAgeRange.Field.ToString().ShouldBe("Age"); + minAgeRange.Gte.ShouldNotBeNull(); + minAgeRange.Gte!.Value.ShouldBe(minAge); + + var maxAgeRange = rangeQueries[1].Range.ShouldBeOfType(); + maxAgeRange.Field.ToString().ShouldBe("Age"); + maxAgeRange.Lte.ShouldNotBeNull(); + maxAgeRange.Lte!.Value.ShouldBe(maxAge); + + // 2. 验证 Exists 查询 + var existsQuery = query.Bool.Filter.FirstOrDefault(q => q.Exists != null); + existsQuery.ShouldNotBeNull(); + existsQuery.Exists.ShouldNotBeNull(); + existsQuery.Exists.Field.ToString().ShouldBe("Name.keyword"); + + // 3. 验证通配符查询 + var wildcardQuery = query.Bool.Filter.FirstOrDefault(q => q.Wildcard != null); + wildcardQuery.ShouldNotBeNull(); + wildcardQuery.Wildcard.ShouldNotBeNull(); + wildcardQuery.Wildcard.Value.ShouldBe("test*"); + wildcardQuery.Wildcard.Field.ToString().ShouldBe("Name.keyword"); + + // 4. 验证 Term 查询 + var termQuery = query.Bool.Filter.FirstOrDefault(q => q.Term != null); + termQuery.ShouldNotBeNull(); + termQuery.Term.ShouldNotBeNull(); + termQuery.Term.Field.ToString().ShouldBe("IsActive"); + termQuery.Term.Value.TryGetBool(out var boolValue).ShouldBeTrue(); + boolValue.Value.ShouldBeTrue(); + + // 5. 验证 Should 查询 + var shouldQuery = query.Bool.Filter.FirstOrDefault(q => q.Bool?.Should != null); + shouldQuery.ShouldNotBeNull(); + shouldQuery.Bool.ShouldNotBeNull(); + shouldQuery.Bool.Should.ShouldNotBeNull(); + shouldQuery.Bool.Should.Count.ShouldBe(2); + shouldQuery.Bool.MinimumShouldMatch.ShouldNotBeNull(); + shouldQuery.Bool.MinimumShouldMatch.Value1.ShouldBe(1); + } + + [Fact] + public async Task Translate_ExpressionWithCapturedVariable_ReturnsCorrectQuery() + { + // Arrange + var translator = await CreateTranslator(); + var targetName = "John"; + var targetAge = 30; + + // Act + var query = translator.Translate(x => x.Name == targetName && x.Age == targetAge); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.Filter.ShouldNotBeNull(); + query.Bool.Filter.Count.ShouldBe(2); + + var nameTerm = query.Bool.Filter.First().Term; + nameTerm.ShouldNotBeNull(); + nameTerm.Value.TryGetString(out var nameValue).ShouldBeTrue(); + nameValue.ShouldBe("John"); + + var ageTerm = query.Bool.Filter.Last().Term; + ageTerm.ShouldNotBeNull(); + ageTerm.Value.ShouldBe(targetAge); + } + + [Fact] + public async Task Translate_ExpressionWithDateTimeVariable_ReturnsDateRangeQuery() + { + // Arrange + var translator = await CreateTranslator(); + var targetDate = new DateTime(2024, 6, 15); + + // Act + var query = translator.Translate(x => x.CreatedTime == targetDate); + + // Assert + query.ShouldNotBeNull(); + var rangeQuery = query.Range.ShouldBeOfType(); + rangeQuery.Field.ToString().ShouldBe("CreatedTime"); + rangeQuery.Gte.ShouldNotBeNull(); + rangeQuery.Lte.ShouldNotBeNull(); + } + + #endregion + + #region 边界情况测试 + + [Fact] + public async Task Translate_UnsupportedMethod_ThrowsNotSupportedException() + { + // Arrange + var translator = await CreateTranslator(); + + // Act & Assert + Should.Throw(() => translator.Translate(x => x.Name!.ToUpper() == "TEST")); + } + + [Fact] + public async Task Translate_MultipleWhereConditions_ReturnsCorrectQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => + (x.Age > 18 || x.Age < 65) && + x.Name != null && + !x.IsActive); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.Filter.ShouldNotBeNull(); + query.Bool.Filter.Count.ShouldBe(3); + } + + [Fact] + public async Task Translate_NestedAnyWithContains_ReturnsNestedTermsQuery() + { + // Arrange + var translator = await CreateTranslator(); + var validIds = new List { 1, 2, 3, 4, 5 }; + + // Act + var query = translator.Translate(x => x.Items!.Any(i => validIds.Contains(i.Id))); + + // Assert + query.ShouldNotBeNull(); + query.Nested.ShouldNotBeNull(); + query.Nested.Path.ShouldBe("Items"); + query.Nested.Query.Terms.ShouldNotBeNull(); + query.Nested.Query.Terms.Field.ToString().ShouldBe("Items.Id"); + } + + [Fact] + public async Task Translate_StringEquals_NullValue_ReturnsMustNotExists() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Name!.Equals(null)); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.MustNot.ShouldNotBeNull(); + query.Bool.MustNot.Count.ShouldBe(1); + query.Bool.MustNot.First().Exists.ShouldNotBeNull(); + } + + [Fact] + public async Task Translate_NotEqual_NullableEnum_ReturnsCorrectQuery() + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.NullableStatus != TestEnum.Active); + + // Assert + query.ShouldNotBeNull(); + query.Bool.ShouldNotBeNull(); + query.Bool.MustNot.ShouldNotBeNull(); + query.Bool.MustNot.Count.ShouldBe(1); + + var mustNot = query.Bool.MustNot.First(); + mustNot.Term.ShouldNotBeNull(); + mustNot.Term.Field.ToString().ShouldBe("NullableStatus"); + } + + #endregion + + #region 使用 Theory 的参数化测试 + + [Theory] + [InlineData(TestEnum.Active, 1)] + [InlineData(TestEnum.Inactive, 2)] + [InlineData(TestEnum.Pending, 3)] + public async Task Translate_Equal_EnumField_ReturnsCorrectValue(TestEnum enumValue, long expectedValue) + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Status == enumValue); + + // Assert + query.ShouldNotBeNull(); + query.Term.ShouldNotBeNull(); + query.Term.Field.ToString().ShouldBe("Status"); + query.Term.Value.ShouldBe(expectedValue); + } + + [Theory] + [InlineData(18)] + [InlineData(30)] + [InlineData(65)] + public async Task Translate_GreaterThan_VariousAges_ReturnsCorrectRange(int age) + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Age > age); + + // Assert + query.ShouldNotBeNull(); + var rangeQuery = query.Range.ShouldBeOfType(); + rangeQuery.Field.ToString().ShouldBe("Age"); + rangeQuery.Gt.ShouldNotBeNull(); + rangeQuery.Gt!.Value.ShouldBe(age); + } + + [Theory] + [InlineData("test")] + [InlineData("hello")] + [InlineData("world")] + public async Task Translate_StringContains_VariousValues_ReturnsCorrectWildcard(string searchValue) + { + // Arrange + var translator = await CreateTranslator(); + + // Act + var query = translator.Translate(x => x.Name!.Contains(searchValue)); + + // Assert + query.ShouldNotBeNull(); + query.Wildcard.ShouldNotBeNull(); + query.Wildcard.Value.ShouldBe($"*{searchValue}*"); + } + + #endregion +} \ No newline at end of file diff --git a/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/TestDocument.cs b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/TestDocument.cs new file mode 100644 index 000000000..b4d8bee1b --- /dev/null +++ b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/TestDocument.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; + +namespace LINGYUN.Abp.Elasticsearch; + +public class TestDocument +{ + public int Id { get; set; } + public string? Name { get; set; } + public string? Description { get; set; } + public int Age { get; set; } + public decimal Salary { get; set; } + public bool IsActive { get; set; } + public DateTime CreatedTime { get; set; } + public DateTime? UpdatedTime { get; set; } + public TestEnum Status { get; set; } + public TestEnum StringValueStatus { get; set; } + public TestEnum? NullableStatus { get; set; } + public List? Items { get; set; } + public List? Tags { get; set; } + public Address? Address { get; set; } + public string? Exceptions { get; set; } +} + +public class SubDocument +{ + public int Id { get; set; } + public string? Name { get; set; } + public decimal Price { get; set; } +} + +public class Address +{ + public string? City { get; set; } + public string? Street { get; set; } +} + +public enum TestEnum +{ + Active = 1, + Inactive = 2, + Pending = 3 +} diff --git a/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/TestDocumentIndexNames.cs b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/TestDocumentIndexNames.cs new file mode 100644 index 000000000..1e89d5307 --- /dev/null +++ b/aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/TestDocumentIndexNames.cs @@ -0,0 +1,6 @@ +namespace LINGYUN.Abp.Elasticsearch; + +public static class TestDocumentIndexNames +{ + public const string Index = "abp-elasticsearch-test"; +}