committed by
GitHub
40 changed files with 4295 additions and 10 deletions
@ -0,0 +1,3 @@ |
|||||
|
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd"> |
||||
|
<ConfigureAwait ContinueOnCapturedContext="false" /> |
||||
|
</Weavers> |
||||
@ -0,0 +1,30 @@ |
|||||
|
<?xml version="1.0" encoding="utf-8"?> |
||||
|
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> |
||||
|
<!-- This file was generated by Fody. Manual changes to this file will be lost when your project is rebuilt. --> |
||||
|
<xs:element name="Weavers"> |
||||
|
<xs:complexType> |
||||
|
<xs:all> |
||||
|
<xs:element name="ConfigureAwait" minOccurs="0" maxOccurs="1"> |
||||
|
<xs:complexType> |
||||
|
<xs:attribute name="ContinueOnCapturedContext" type="xs:boolean" /> |
||||
|
</xs:complexType> |
||||
|
</xs:element> |
||||
|
</xs:all> |
||||
|
<xs:attribute name="VerifyAssembly" type="xs:boolean"> |
||||
|
<xs:annotation> |
||||
|
<xs:documentation>'true' to run assembly verification (PEVerify) on the target assembly after all weavers have been executed.</xs:documentation> |
||||
|
</xs:annotation> |
||||
|
</xs:attribute> |
||||
|
<xs:attribute name="VerifyIgnoreCodes" type="xs:string"> |
||||
|
<xs:annotation> |
||||
|
<xs:documentation>A comma-separated list of error codes that can be safely ignored in assembly verification.</xs:documentation> |
||||
|
</xs:annotation> |
||||
|
</xs:attribute> |
||||
|
<xs:attribute name="GenerateXsd" type="xs:boolean"> |
||||
|
<xs:annotation> |
||||
|
<xs:documentation>'false' to turn off automatic generation of the XML Schema file.</xs:documentation> |
||||
|
</xs:annotation> |
||||
|
</xs:attribute> |
||||
|
</xs:complexType> |
||||
|
</xs:element> |
||||
|
</xs:schema> |
||||
@ -0,0 +1,24 @@ |
|||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
|
||||
|
<Import Project="..\..\..\..\configureawait.props" /> |
||||
|
<Import Project="..\..\..\..\common.props" /> |
||||
|
|
||||
|
<PropertyGroup> |
||||
|
<TargetFrameworks>netstandard2.0;netstandard2.1;net8.0;net9.0;net10.0</TargetFrameworks> |
||||
|
<AssemblyName>LINGYUN.Abp.Elasticsearch.EsqlQuery</AssemblyName> |
||||
|
<PackageId>LINGYUN.Abp.Elasticsearch.EsqlQuery</PackageId> |
||||
|
<GenerateAssemblyConfigurationAttribute>false</GenerateAssemblyConfigurationAttribute> |
||||
|
<GenerateAssemblyCompanyAttribute>false</GenerateAssemblyCompanyAttribute> |
||||
|
<GenerateAssemblyProductAttribute>false</GenerateAssemblyProductAttribute> |
||||
|
<RootNamespace /> |
||||
|
</PropertyGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<PackageReference Include="Elastic.Clients.Esql" /> |
||||
|
</ItemGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<ProjectReference Include="..\LINGYUN.Abp.Elasticsearch\LINGYUN.Abp.Elasticsearch.csproj" /> |
||||
|
</ItemGroup> |
||||
|
|
||||
|
</Project> |
||||
@ -0,0 +1,8 @@ |
|||||
|
using Volo.Abp.Modularity; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch.EsqlQuery; |
||||
|
|
||||
|
[DependsOn(typeof(AbpElasticsearchModule))] |
||||
|
public class AbpElasticsearchEsqlQueryModule : AbpModule |
||||
|
{ |
||||
|
} |
||||
@ -0,0 +1,83 @@ |
|||||
|
using Elastic.Clients.Elasticsearch; |
||||
|
using Elastic.Esql.Extensions; |
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Linq.Dynamic.Core; |
||||
|
using System.Linq.Expressions; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch.EsqlQuery; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// NOTE: The tests for methods like Contains in the collection have failed. Please do not use this interface for collection filtering.
|
||||
|
/// TODO: According to the official documentation, is it feasible? Is the support for nested types not available?
|
||||
|
/// See: https://www.elastic.co/docs/reference/elasticsearch/clients/dotnet/linq-to-esql#linq-esql-filtering
|
||||
|
/// var brands = new[] { "TechCorp", "StyleMax", "HomeBase" };
|
||||
|
/// client.Esql.Query<Product>(q => q.Where(p => brands.Contains(p.Brand)));
|
||||
|
/// → WHERE brand IN ("TechCorp", "StyleMax", "HomeBase")
|
||||
|
/// </summary>
|
||||
|
|
||||
|
[Dependency(ReplaceServices = true)] |
||||
|
public class EsqlExpressionQueryService : ExpressionQueryService, ITransientDependency |
||||
|
{ |
||||
|
public EsqlExpressionQueryService( |
||||
|
IElasticsearchClientFactory clientFactory, |
||||
|
IExpressionQueryTranslator expressionQueryTranslator) |
||||
|
: base(clientFactory, expressionQueryTranslator) |
||||
|
{ |
||||
|
} |
||||
|
|
||||
|
public async override Task<long> GetCountAsync<TDocument>( |
||||
|
string indexName, |
||||
|
Expression<Func<TDocument, bool>> expression, |
||||
|
CancellationToken cancellationToken = default) where TDocument : class |
||||
|
{ |
||||
|
var client = ClientFactory.Create(); |
||||
|
|
||||
|
return await client.Esql.CreateQuery<TDocument>() |
||||
|
.From(indexName) |
||||
|
.Where(expression) |
||||
|
.AsEsqlQueryable() |
||||
|
.CountAsync(cancellationToken); |
||||
|
} |
||||
|
|
||||
|
public async override Task<List<TDocument>> GetListAsync<TDocument>( |
||||
|
string indexName, |
||||
|
Expression<Func<TDocument, bool>> expression, |
||||
|
string? sorting = null, |
||||
|
int maxResultCount = 50, |
||||
|
int skipCount = 0, |
||||
|
Fields? sourceExcludes = null, |
||||
|
Fields? sourceIncludes = null, |
||||
|
object[]? beginMarker = null, |
||||
|
CancellationToken cancellationToken = default) where TDocument : class |
||||
|
{ |
||||
|
var client = ClientFactory.Create(); |
||||
|
|
||||
|
var query = client.Esql.CreateQuery<TDocument>() |
||||
|
.From(indexName) |
||||
|
.Where(expression); |
||||
|
if (!sorting.IsNullOrWhiteSpace()) |
||||
|
{ |
||||
|
query = query.OrderBy(sorting); |
||||
|
} |
||||
|
if (sourceExcludes != null) |
||||
|
{ |
||||
|
query = query.Drop(sourceExcludes.Select(f => f.Name!).ToArray()); |
||||
|
} |
||||
|
if (sourceIncludes != null) |
||||
|
{ |
||||
|
query = query.Keep(sourceIncludes.Select(f => f.Name!).ToArray()); |
||||
|
} |
||||
|
|
||||
|
// TODO: 需要构建范围过滤条件,加入到query中以实现分页查询
|
||||
|
// See: https://www.elastic.co/docs/reference/elasticsearch/clients/dotnet/linq-to-esql#linq-esql-sorting
|
||||
|
return await query |
||||
|
.Take(maxResultCount) |
||||
|
.AsEsqlQueryable() |
||||
|
.ToListAsync(cancellationToken); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
# LINGYUN.Abp.Elasticsearch.EsqlQuery |
||||
|
|
||||
|
[IExpressionQueryService](../LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IExpressionQueryService.cs) 的ES|QL查询集成. |
||||
|
|
||||
|
**注意**: |
||||
|
* 使用 ES|QL 查询方案不能进行深度分页,数据条目超出10000时依旧存在ES分页限制. |
||||
|
如需支持深度分页,请使用默认 [ExpressionQueryService](../LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryService.cs) `search_after` 方案 |
||||
|
|
||||
|
* 不支持使用 由于ES|QL不支持 `Skip`, 暂不支持分页查询 |
||||
|
|
||||
|
* 对于嵌套类型的集合方法测试未通过,请勿使用集合的过滤条件 |
||||
|
|
||||
|
|
||||
|
## 模块引用 |
||||
|
|
||||
|
```csharp |
||||
|
[DependsOn(typeof(AbpElasticsearchEsqlQueryModule))] |
||||
|
public class YouProjectModule : AbpModule |
||||
|
{ |
||||
|
// other |
||||
|
} |
||||
|
``` |
||||
@ -1,14 +1,15 @@ |
|||||
using Microsoft.Extensions.DependencyInjection; |
using Microsoft.Extensions.DependencyInjection; |
||||
|
using Volo.Abp.Caching; |
||||
using Volo.Abp.Modularity; |
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<AbpElasticsearchOptions>(configuration.GetSection("Elasticsearch")); |
||||
var configuration = context.Services.GetConfiguration(); |
|
||||
Configure<AbpElasticsearchOptions>(configuration.GetSection("Elasticsearch")); |
|
||||
} |
|
||||
} |
} |
||||
} |
} |
||||
|
|||||
@ -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<IndexMappingInfo> GetMappingAsync(string indexName, CancellationToken cancellationToken = default) |
||||
|
{ |
||||
|
var cacheKey = $"es_mapping_{indexName}"; |
||||
|
|
||||
|
var cacheItem = _cache.Get<IndexMappingInfo>(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<string, FieldMappingInfo>(); |
||||
|
|
||||
|
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<string, FieldMappingInfo>() |
||||
|
}; |
||||
|
|
||||
|
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<string, FieldMappingInfo>(); |
||||
|
|
||||
|
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<string, FieldMappingInfo>(); |
||||
|
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() |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,273 @@ |
|||||
|
using Elastic.Clients.Elasticsearch; |
||||
|
using Elastic.Clients.Elasticsearch.QueryDsl; |
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
using System.Linq.Expressions; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
public class ExpressionQueryService : IExpressionQueryService, ITransientDependency |
||||
|
{ |
||||
|
protected IElasticsearchClientFactory ClientFactory { get; } |
||||
|
protected IExpressionQueryTranslator ExpressionQueryTranslator { get; } |
||||
|
|
||||
|
public ExpressionQueryService( |
||||
|
IElasticsearchClientFactory clientFactory, |
||||
|
IExpressionQueryTranslator expressionQueryTranslator) |
||||
|
{ |
||||
|
ClientFactory = clientFactory; |
||||
|
ExpressionQueryTranslator = expressionQueryTranslator; |
||||
|
} |
||||
|
|
||||
|
public async virtual Task<long> GetCountAsync<TDocument>( |
||||
|
string indexName, |
||||
|
Expression<Func<TDocument, bool>> expression, |
||||
|
CancellationToken cancellationToken = default) where TDocument : class |
||||
|
{ |
||||
|
var client = ClientFactory.Create(); |
||||
|
var query = await ExpressionQueryTranslator.TranslateAsync(indexName, expression); |
||||
|
|
||||
|
var response = await client.CountAsync<TDocument>(dsl => |
||||
|
dsl.Indices(indexName).Query(query), |
||||
|
cancellationToken); |
||||
|
|
||||
|
return response.Count; |
||||
|
} |
||||
|
|
||||
|
public async virtual Task<List<TDocument>> GetListAsync<TDocument>( |
||||
|
string indexName, |
||||
|
Expression<Func<TDocument, bool>> expression, |
||||
|
string? sorting = null, |
||||
|
int maxResultCount = 50, |
||||
|
int skipCount = 0, |
||||
|
Fields? sourceExcludes = null, |
||||
|
Fields? sourceIncludes = null, |
||||
|
object[]? beginMarker = null, |
||||
|
CancellationToken cancellationToken = default) where TDocument : class |
||||
|
{ |
||||
|
var client = ClientFactory.Create(); |
||||
|
var query = await ExpressionQueryTranslator.TranslateAsync(indexName, expression); |
||||
|
|
||||
|
SortOptions[]? sorts = null; |
||||
|
if (!sorting.IsNullOrWhiteSpace()) |
||||
|
{ |
||||
|
var sortOrder = !sorting.IsNullOrWhiteSpace() && sorting.EndsWith("asc", StringComparison.InvariantCultureIgnoreCase) |
||||
|
? SortOrder.Asc : SortOrder.Desc; |
||||
|
|
||||
|
sorts = new SortOptions[1] |
||||
|
{ |
||||
|
new SortOptions |
||||
|
{ |
||||
|
Field = new FieldSort(new Field(sorting)) |
||||
|
{ |
||||
|
Order = sortOrder, |
||||
|
}, |
||||
|
} |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// 数量超过10000且存在排序时才可以使用SearchAfter特性
|
||||
|
return skipCount >= 10000 && sorts != null |
||||
|
? await SearchAfter<TDocument>( |
||||
|
client, |
||||
|
indexName, |
||||
|
query, |
||||
|
sorts, |
||||
|
maxResultCount, |
||||
|
skipCount, |
||||
|
sourceExcludes, |
||||
|
sourceIncludes, |
||||
|
beginMarker, |
||||
|
cancellationToken) |
||||
|
: await SearchFromSize<TDocument>( |
||||
|
client, |
||||
|
indexName, |
||||
|
query, |
||||
|
sorts, |
||||
|
maxResultCount, |
||||
|
skipCount, |
||||
|
sourceExcludes, |
||||
|
sourceIncludes, |
||||
|
cancellationToken); |
||||
|
} |
||||
|
|
||||
|
private async Task<List<TDocument>> SearchFromSize<TDocument>( |
||||
|
ElasticsearchClient client, |
||||
|
string indexName, |
||||
|
Query query, |
||||
|
SortOptions[]? sorts = null, |
||||
|
int maxResultCount = 50, |
||||
|
int skipCount = 0, |
||||
|
Fields? sourceExcludes = null, |
||||
|
Fields? sourceIncludes = null, |
||||
|
CancellationToken cancellationToken = default) |
||||
|
{ |
||||
|
var searchResponse = await client.SearchAsync<TDocument>(dsl => |
||||
|
{ |
||||
|
dsl.Indices(indexName) |
||||
|
.Query(query) |
||||
|
.From(skipCount) |
||||
|
.Size(maxResultCount); |
||||
|
if (sorts != null) |
||||
|
{ |
||||
|
dsl.Sort(sorts); |
||||
|
} |
||||
|
if (sourceExcludes != null) |
||||
|
{ |
||||
|
dsl.SourceExcludes(sourceExcludes); |
||||
|
} |
||||
|
if (sourceIncludes != null) |
||||
|
{ |
||||
|
dsl.SourceIncludes(sourceIncludes); |
||||
|
} |
||||
|
}, cancellationToken); |
||||
|
|
||||
|
if (!searchResponse.IsSuccess()) |
||||
|
{ |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
return searchResponse.Documents.ToList(); |
||||
|
} |
||||
|
|
||||
|
private async Task<List<TDocument>> SearchAfter<TDocument>( |
||||
|
ElasticsearchClient client, |
||||
|
string indexName, |
||||
|
Query query, |
||||
|
SortOptions[] sorts, |
||||
|
int maxResultCount, |
||||
|
int skipCount, |
||||
|
Fields? sourceExcludes = null, |
||||
|
Fields? sourceIncludes = null, |
||||
|
object[]? beginMarker = null, |
||||
|
CancellationToken cancellationToken = default) |
||||
|
{ |
||||
|
List<FieldValue>? searchAfter = null; |
||||
|
if (beginMarker != null) |
||||
|
{ |
||||
|
searchAfter = beginMarker.Select(FieldValue.FromValue).ToList(); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
searchAfter = await GetSearchAfterValue<TDocument>( |
||||
|
client, |
||||
|
indexName, |
||||
|
query, |
||||
|
sorts, |
||||
|
skipCount, |
||||
|
cancellationToken); |
||||
|
} |
||||
|
|
||||
|
if (searchAfter == null || !searchAfter.Any()) |
||||
|
{ |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
var searchResponse = await client.SearchAsync<TDocument>(dsl => |
||||
|
{ |
||||
|
dsl.Indices(indexName) |
||||
|
.Query(query) |
||||
|
.Sort(sorts) |
||||
|
.Size(maxResultCount) |
||||
|
.SearchAfter(searchAfter); |
||||
|
if (sourceExcludes != null) |
||||
|
{ |
||||
|
dsl.SourceExcludes(sourceExcludes); |
||||
|
} |
||||
|
if (sourceIncludes != null) |
||||
|
{ |
||||
|
dsl.SourceIncludes(sourceIncludes); |
||||
|
} |
||||
|
}, cancellationToken); |
||||
|
|
||||
|
if (!searchResponse.IsSuccess()) |
||||
|
{ |
||||
|
return []; |
||||
|
} |
||||
|
|
||||
|
return searchResponse.Documents.ToList(); |
||||
|
} |
||||
|
|
||||
|
private async Task<List<FieldValue>?> GetSearchAfterValue<TDocument>( |
||||
|
ElasticsearchClient client, |
||||
|
string indexName, |
||||
|
Query query, |
||||
|
SortOptions[] sorts, |
||||
|
int skipCount, |
||||
|
CancellationToken cancellationToken = default) |
||||
|
{ |
||||
|
// 10000以内直接取最后一条数据
|
||||
|
if (skipCount < 10000) |
||||
|
{ |
||||
|
var response = await client.SearchAsync<TDocument>( |
||||
|
dsl => dsl.Indices(indexName) |
||||
|
.Query(query) |
||||
|
.Sort(sorts) |
||||
|
.From(skipCount) |
||||
|
.Size(1), |
||||
|
cancellationToken); |
||||
|
|
||||
|
if (!response.IsSuccess() || response.Hits == null || !response.Hits.Any()) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
var hit = response.Hits.FirstOrDefault(); |
||||
|
return hit?.Sort?.ToList(); |
||||
|
} |
||||
|
|
||||
|
// 获取第9999条数据Hits作为searchAfter
|
||||
|
var firstResponse = await client.SearchAsync<TDocument>( |
||||
|
dsl => dsl.Indices(indexName) |
||||
|
.Query(query) |
||||
|
.Sort(sorts) |
||||
|
.SourceIncludes([]) |
||||
|
.From(9999) |
||||
|
.Size(1), |
||||
|
cancellationToken); |
||||
|
|
||||
|
if (!firstResponse.IsSuccess() || firstResponse.Hits == null || !firstResponse.Hits.Any()) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
var firstHit = firstResponse.Hits.FirstOrDefault(); |
||||
|
if (firstHit?.Sort == null || !firstHit.Sort.Any()) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
var remaining = skipCount - 10000; |
||||
|
// 获取skipCount最近一条数据作为searchAfter
|
||||
|
var secondResponse = await client.SearchAsync<TDocument>( |
||||
|
dsl => dsl.Indices(indexName) |
||||
|
.Query(query) |
||||
|
.Sort(sorts) |
||||
|
.SourceIncludes([]) |
||||
|
.SearchAfter(firstHit.Sort.ToList()) |
||||
|
.Size(remaining), |
||||
|
cancellationToken); |
||||
|
|
||||
|
if (!secondResponse.IsSuccess() || secondResponse.Hits == null || !secondResponse.Hits.Any()) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
if (secondResponse.Hits.Count < remaining) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
var lastHit = secondResponse.Hits.LastOrDefault(); |
||||
|
if (lastHit?.Sort == null || !lastHit.Sort.Any()) |
||||
|
{ |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
return lastHit.Sort.ToList(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,98 @@ |
|||||
|
using Elastic.Clients.Elasticsearch.QueryDsl; |
||||
|
using System; |
||||
|
using System.Collections; |
||||
|
using System.Linq; |
||||
|
using System.Linq.Expressions; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
public partial class ExpressionQueryTranslator |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// 翻译集合 Contains
|
||||
|
/// </summary>
|
||||
|
private Query TranslateCollectionContains(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) |
||||
|
{ |
||||
|
// list.Contains(value) 或 hashSet.Contains(value)
|
||||
|
// 有两种情况:
|
||||
|
// 1. 外部集合.Contains(字段) - 例如:new[]{"a","b"}.Contains(x.xxx)
|
||||
|
// 2. 字段.Contains(值) - 例如:x.Y.Contains("B")
|
||||
|
|
||||
|
Expression collectionExpr = node.Object!; |
||||
|
Expression valueExpr = node.Arguments[0]; |
||||
|
|
||||
|
// 判断哪个是字段,哪个是值
|
||||
|
var collectionIsField = IsFieldLike(collectionExpr); |
||||
|
var valueIsField = IsFieldLike(valueExpr); |
||||
|
|
||||
|
if (collectionIsField && !valueIsField) |
||||
|
{ |
||||
|
// 情况 2:字段.Contains(值)
|
||||
|
// 这通常用于集合字段,如 x.Tags.Contains("B")
|
||||
|
var field = ResolveField(collectionExpr, prefix, mappingInfo); |
||||
|
var value = Evaluate(valueExpr); |
||||
|
|
||||
|
if (value == null) |
||||
|
{ |
||||
|
return new MatchNoneQuery(); |
||||
|
} |
||||
|
|
||||
|
// 对于集合字段,使用 Terms 查询或 Term 查询
|
||||
|
// 如果字段是数组或集合类型,单个值的 Contains 实际上就是 Term 查询
|
||||
|
return BuildEquality(field, value); |
||||
|
} |
||||
|
else if (!collectionIsField && valueIsField) |
||||
|
{ |
||||
|
// 情况 1:外部集合.Contains(字段)
|
||||
|
var field = ResolveField(valueExpr, prefix, mappingInfo); |
||||
|
|
||||
|
// 尝试获取集合的值
|
||||
|
var collectionValue = Evaluate(collectionExpr); |
||||
|
|
||||
|
if (collectionValue is IEnumerable enumerable && collectionValue is not string) |
||||
|
{ |
||||
|
var values = enumerable.Cast<object>().Select(NormalizeValue).ToList(); |
||||
|
if (values.Count == 0) |
||||
|
{ |
||||
|
return new MatchNoneQuery(); |
||||
|
} |
||||
|
if (values.Count == 1) |
||||
|
{ |
||||
|
// 如果集合只有一个值,使用 TermQuery
|
||||
|
return BuildEquality(field, values[0]); |
||||
|
} |
||||
|
// 多个值使用 TermsQuery
|
||||
|
return new TermsQuery { Field = field.Path, Terms = new TermsQueryField(values) }; |
||||
|
} |
||||
|
|
||||
|
// 如果集合值无法获取,使用默认处理
|
||||
|
var defaultValue = Evaluate(valueExpr); |
||||
|
return BuildEquality(field, defaultValue!); |
||||
|
} |
||||
|
else if (collectionIsField && valueIsField) |
||||
|
{ |
||||
|
// 两个都是字段,这种情况较少见
|
||||
|
throw new NotSupportedException($"Unsupported Contains with two field expressions: {node}"); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
// 两个都不是字段,尝试直接求值
|
||||
|
var collectionValue = Evaluate(collectionExpr); |
||||
|
var value = Evaluate(valueExpr); |
||||
|
|
||||
|
if (collectionValue is IEnumerable enumerable && collectionValue is not string) |
||||
|
{ |
||||
|
if (enumerable.Cast<object>().Contains(value)) |
||||
|
{ |
||||
|
return new MatchAllQuery(); |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
return new MatchNoneQuery(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
throw new NotSupportedException($"Unsupported Contains expression: {node}"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
using Elastic.Clients.Elasticsearch.QueryDsl; |
||||
|
using System; |
||||
|
using System.Linq.Expressions; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
public partial class ExpressionQueryTranslator |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// 翻译 Enum.HasFlag
|
||||
|
/// </summary>
|
||||
|
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 }; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,97 @@ |
|||||
|
using Elastic.Clients.Elasticsearch.QueryDsl; |
||||
|
using System; |
||||
|
using System.Linq; |
||||
|
using System.Linq.Expressions; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
public partial class ExpressionQueryTranslator |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// 翻译 Enumerable 方法
|
||||
|
/// </summary>
|
||||
|
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}"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译 Enumerable.Any
|
||||
|
/// </summary>
|
||||
|
private Query TranslateEnumerableAny(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) |
||||
|
{ |
||||
|
var collectionField = ResolveField(node.Arguments[0], prefix, mappingInfo); |
||||
|
|
||||
|
Query inner; |
||||
|
if (node.Arguments.Count == 1) |
||||
|
{ |
||||
|
// .Any() 检查集合是否存在
|
||||
|
inner = new ExistsQuery { Field = collectionField.Path }; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
// .Any(predicate)
|
||||
|
var predicate = UnwrapLambda(node.Arguments[1]); |
||||
|
inner = TranslateNode(predicate.Body, prefix: collectionField.Path, mappingInfo); |
||||
|
} |
||||
|
|
||||
|
var shouldUseNested = collectionField.IsNested || (mappingInfo?.IsNested(collectionField.Path) ?? false); |
||||
|
|
||||
|
return shouldUseNested |
||||
|
? new NestedQuery(collectionField.Path, inner) |
||||
|
: inner; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译 Enumerable.Contains
|
||||
|
/// </summary>
|
||||
|
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!); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译 Enumerable.All
|
||||
|
/// </summary>
|
||||
|
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); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,114 @@ |
|||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq.Expressions; |
||||
|
using System.Reflection; |
||||
|
using System.Text.Json.Serialization; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
public partial class ExpressionQueryTranslator |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// 解析字段
|
||||
|
/// </summary>
|
||||
|
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<string>(); |
||||
|
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 |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 获取成员的实际类型
|
||||
|
/// </summary>
|
||||
|
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) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 解析字段名称
|
||||
|
/// </summary>
|
||||
|
private static string ResolveFieldName(MemberInfo member) |
||||
|
{ |
||||
|
// 检查 JsonPropertyName 属性
|
||||
|
if (member is PropertyInfo property) |
||||
|
{ |
||||
|
var jsonName = property.GetCustomAttribute<JsonPropertyNameAttribute>(); |
||||
|
if (jsonName != null && !string.IsNullOrWhiteSpace(jsonName.Name)) |
||||
|
{ |
||||
|
return jsonName.Name; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return member.Name; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,317 @@ |
|||||
|
using Elastic.Clients.Elasticsearch.QueryDsl; |
||||
|
using System; |
||||
|
using System.Linq.Expressions; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
public partial class ExpressionQueryTranslator |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// 翻译字符串方法
|
||||
|
/// </summary>
|
||||
|
private Query TranslateStringMethod(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) |
||||
|
{ |
||||
|
return node.Method.Name switch |
||||
|
{ |
||||
|
// 实例方法
|
||||
|
nameof(string.Contains) => TranslateStringContains(node, prefix, mappingInfo), |
||||
|
nameof(string.StartsWith) => TranslateStringStartsWith(node, prefix, mappingInfo), |
||||
|
nameof(string.EndsWith) => TranslateStringEndsWith(node, prefix, mappingInfo), |
||||
|
nameof(string.Equals) => TranslateStringEquals(node, prefix, mappingInfo), |
||||
|
nameof(string.CompareTo) => TranslateStringCompareTo(node, prefix, mappingInfo), |
||||
|
nameof(string.IndexOf) => TranslateStringIndexOf(node, prefix, mappingInfo), |
||||
|
|
||||
|
// 静态方法
|
||||
|
nameof(string.IsNullOrEmpty) => TranslateStringIsNullOrEmpty(node, prefix, mappingInfo), |
||||
|
nameof(string.IsNullOrWhiteSpace) => TranslateStringIsNullOrWhiteSpace(node, prefix, mappingInfo), |
||||
|
|
||||
|
// 其他方法不支持
|
||||
|
_ => throw new NotSupportedException($"Unsupported string method {node.Method.Name}"), |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译 Contains
|
||||
|
/// </summary>
|
||||
|
private Query TranslateStringContains(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) |
||||
|
{ |
||||
|
var (field, value) = GetStringMethodOperands(node, prefix, mappingInfo); |
||||
|
var fieldMapping = mappingInfo?.GetField(field.Path); |
||||
|
return TranslateStringContains(field, fieldMapping, value); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译 StartsWith
|
||||
|
/// </summary>
|
||||
|
private Query TranslateStringStartsWith(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) |
||||
|
{ |
||||
|
var (field, value) = GetStringMethodOperands(node, prefix, mappingInfo); |
||||
|
var fieldMapping = mappingInfo?.GetField(field.Path); |
||||
|
return TranslateStartsWith(field, fieldMapping, value); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译 EndsWith
|
||||
|
/// </summary>
|
||||
|
private Query TranslateStringEndsWith(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) |
||||
|
{ |
||||
|
var (field, value) = GetStringMethodOperands(node, prefix, mappingInfo); |
||||
|
var fieldMapping = mappingInfo?.GetField(field.Path); |
||||
|
return TranslateEndsWith(field, fieldMapping, value); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译 StartsWith
|
||||
|
/// </summary>
|
||||
|
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 }; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译 EndsWith
|
||||
|
/// </summary>
|
||||
|
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 }; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译 string.Equals
|
||||
|
/// </summary>
|
||||
|
private Query TranslateStringEquals(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) |
||||
|
{ |
||||
|
var (field, value) = GetStringMethodOperands(node, prefix, mappingInfo); |
||||
|
|
||||
|
if (string.IsNullOrEmpty(value)) |
||||
|
{ |
||||
|
return new BoolQuery { MustNot = new Query[] { new ExistsQuery { Field = field.Path } } }; |
||||
|
} |
||||
|
|
||||
|
return BuildEquality(field, value); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译 string.CompareTo
|
||||
|
/// </summary>
|
||||
|
private Query TranslateStringCompareTo(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) |
||||
|
{ |
||||
|
var (field, value) = GetStringMethodOperands(node, prefix, mappingInfo); |
||||
|
|
||||
|
// CompareTo 通常用于比较,这里简化为相等比较
|
||||
|
// 如果需要更复杂的比较逻辑,可以在这里扩展
|
||||
|
return BuildEquality(field, value); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译 string.IndexOf
|
||||
|
/// </summary>
|
||||
|
private Query TranslateStringIndexOf(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) |
||||
|
{ |
||||
|
var (field, value) = GetStringMethodOperands(node, prefix, mappingInfo); |
||||
|
var fieldMapping = mappingInfo?.GetField(field.Path); |
||||
|
|
||||
|
// IndexOf >= 0 等价于 Contains
|
||||
|
return TranslateStringContains(field, fieldMapping, value); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译 Contains
|
||||
|
/// </summary>
|
||||
|
private Query TranslateStringContains(FieldInfo field, FieldMappingInfo? fieldMapping, string value) |
||||
|
{ |
||||
|
// 1. Wildcard 类型 - 直接使用通配符查询(最优)
|
||||
|
if (field.IsWildcard || fieldMapping?.IsWildcard == true) |
||||
|
{ |
||||
|
return new WildcardQuery |
||||
|
{ |
||||
|
Field = field.Path, |
||||
|
Value = "*" + EscapeWildcard(value) + "*" |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// 2. Keyword 类型 - 使用通配符查询
|
||||
|
if (field.IsKeyword || fieldMapping?.IsKeyword == true) |
||||
|
{ |
||||
|
return new WildcardQuery |
||||
|
{ |
||||
|
Field = field.Path, |
||||
|
Value = "*" + EscapeWildcard(value) + "*" |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// 3. Text 类型
|
||||
|
if (field.IsText || fieldMapping?.IsText == true) |
||||
|
{ |
||||
|
// 3.1 如果有 keyword 子字段,使用 .keyword 进行通配符查询
|
||||
|
if (fieldMapping?.Properties?.ContainsKey("keyword") == true) |
||||
|
{ |
||||
|
return new WildcardQuery |
||||
|
{ |
||||
|
Field = $"{field.Path}.keyword", |
||||
|
Value = "*" + EscapeWildcard(value) + "*" |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// 3.2 没有 keyword 子字段,使用 MatchPhrase 进行全文搜索
|
||||
|
// 注意:这不是精确的 Contains,而是分词后的短语匹配
|
||||
|
return new MatchPhraseQuery |
||||
|
{ |
||||
|
Field = field.Path, |
||||
|
Query = value |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// 4. 默认 - 尝试使用通配符
|
||||
|
return new WildcardQuery |
||||
|
{ |
||||
|
Field = field.Path, |
||||
|
Value = "*" + EscapeWildcard(value) + "*" |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译 string.IsNullOrEmpty
|
||||
|
/// </summary>
|
||||
|
private Query TranslateStringIsNullOrEmpty(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) |
||||
|
{ |
||||
|
var field = ResolveField(node.Arguments[0], prefix, mappingInfo); |
||||
|
|
||||
|
// field == null || field == ""
|
||||
|
return new BoolQuery |
||||
|
{ |
||||
|
Should = new Query[] |
||||
|
{ |
||||
|
// null 或不存在
|
||||
|
new BoolQuery |
||||
|
{ |
||||
|
MustNot = new Query[] { new ExistsQuery { Field = field.Path } } |
||||
|
}, |
||||
|
// 空字符串
|
||||
|
new TermQuery |
||||
|
{ |
||||
|
Field = field.Path, |
||||
|
Value = string.Empty |
||||
|
} |
||||
|
}, |
||||
|
MinimumShouldMatch = 1 |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译 string.IsNullOrWhiteSpace
|
||||
|
/// </summary>
|
||||
|
private Query TranslateStringIsNullOrWhiteSpace(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) |
||||
|
{ |
||||
|
var field = ResolveField(node.Arguments[0], prefix, mappingInfo); |
||||
|
|
||||
|
// field == null || field == "" || Regex.IsMatch(field, @"^\s*$")
|
||||
|
return new BoolQuery |
||||
|
{ |
||||
|
Should = new Query[] |
||||
|
{ |
||||
|
// null 或不存在
|
||||
|
new BoolQuery |
||||
|
{ |
||||
|
MustNot = new Query[] { new ExistsQuery { Field = field.Path } } |
||||
|
}, |
||||
|
// 空字符串
|
||||
|
new TermQuery |
||||
|
{ |
||||
|
Field = field.Path, |
||||
|
Value = string.Empty |
||||
|
}, |
||||
|
// 使用正则表达式匹配只有空白字符的字符串
|
||||
|
new RegexpQuery |
||||
|
{ |
||||
|
Field = field.Path, |
||||
|
Value = @"^\s*$" |
||||
|
} |
||||
|
}, |
||||
|
MinimumShouldMatch = 1 |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译字符串实例方法(获取字段和值)
|
||||
|
/// </summary>
|
||||
|
private (FieldInfo Field, string Value) GetStringMethodOperands(MethodCallExpression node, string? prefix, IndexMappingInfo? mappingInfo) |
||||
|
{ |
||||
|
Expression fieldExpression; |
||||
|
Expression valueExpression; |
||||
|
|
||||
|
if (node.Object != null) |
||||
|
{ |
||||
|
// 实例方法:obj.Method(value)
|
||||
|
fieldExpression = node.Object; |
||||
|
valueExpression = node.Arguments[0]; |
||||
|
} |
||||
|
else |
||||
|
{ |
||||
|
// 静态方法:string.Method(field, value)
|
||||
|
fieldExpression = node.Arguments[0]; |
||||
|
valueExpression = node.Arguments.Count > 1 ? node.Arguments[1] : node.Arguments[0]; |
||||
|
} |
||||
|
|
||||
|
var field = ResolveField(fieldExpression, prefix, mappingInfo); |
||||
|
var value = Evaluate(valueExpression)?.ToString() ?? string.Empty; |
||||
|
|
||||
|
return (field, value); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,964 @@ |
|||||
|
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 System.Threading.Tasks; |
||||
|
using Volo.Abp; |
||||
|
using Volo.Abp.DependencyInjection; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 表达式查询转换器 - 将 LINQ 表达式转换为 Elasticsearch Query
|
||||
|
/// </summary>
|
||||
|
public partial class ExpressionQueryTranslator : IExpressionQueryTranslator, ISingletonDependency |
||||
|
{ |
||||
|
private readonly IIndexMappingProvider _indexMappingProvider; |
||||
|
|
||||
|
public ExpressionQueryTranslator( |
||||
|
IIndexMappingProvider indexMappingProvider) |
||||
|
{ |
||||
|
_indexMappingProvider = indexMappingProvider; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译表达式
|
||||
|
/// </summary>
|
||||
|
public async virtual Task<Query> TranslateAsync<TDocument>(string indexName, Expression<Func<TDocument, bool>> expression) |
||||
|
{ |
||||
|
Check.NotNullOrWhiteSpace(indexName, nameof(indexName)); |
||||
|
Check.NotNull(expression, nameof(expression)); |
||||
|
|
||||
|
var indexMapping = await _indexMappingProvider.GetMappingAsync(indexName); |
||||
|
|
||||
|
return TranslateNode(expression.Body, prefix: null, indexMapping); |
||||
|
} |
||||
|
|
||||
|
#region 节点翻译
|
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译表达式节点
|
||||
|
/// </summary>
|
||||
|
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 二元表达式翻译
|
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译二元表达式
|
||||
|
/// </summary>
|
||||
|
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}"), |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 构建 And 查询(展平 BoolQuery)
|
||||
|
/// </summary>
|
||||
|
private Query TranslateAndAlso(BinaryExpression node, string? prefix, IndexMappingInfo? mappingInfo) |
||||
|
{ |
||||
|
var filters = new List<Query>(); |
||||
|
|
||||
|
// 收集左侧所有 Filter
|
||||
|
CollectFilters(node.Left, prefix, mappingInfo, filters); |
||||
|
|
||||
|
// 收集右侧 Filter
|
||||
|
CollectFilters(node.Right, prefix, mappingInfo, filters); |
||||
|
|
||||
|
return new BoolQuery |
||||
|
{ |
||||
|
Filter = filters.ToArray() |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 递归收集 Filter 查询
|
||||
|
/// </summary>
|
||||
|
private void CollectFilters(Expression node, string? prefix, IndexMappingInfo? mappingInfo, List<Query> 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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 构建 Or 查询(展平 BoolQuery)
|
||||
|
/// </summary>
|
||||
|
private Query TranslateOrElse(BinaryExpression node, string? prefix, IndexMappingInfo? mappingInfo) |
||||
|
{ |
||||
|
var shouldQueries = new List<Query>(); |
||||
|
|
||||
|
CollectShouldQueries(node.Left, prefix, mappingInfo, shouldQueries); |
||||
|
CollectShouldQueries(node.Right, prefix, mappingInfo, shouldQueries); |
||||
|
|
||||
|
return new BoolQuery |
||||
|
{ |
||||
|
Should = shouldQueries.ToArray(), |
||||
|
MinimumShouldMatch = 1 |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 递归收集 Should 查询
|
||||
|
/// </summary>
|
||||
|
private void CollectShouldQueries(Expression node, string? prefix, IndexMappingInfo? mappingInfo, List<Query> 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); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译比较表达式
|
||||
|
/// </summary>
|
||||
|
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}"), |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 解析操作数,确定字段和值
|
||||
|
/// </summary>
|
||||
|
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); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 判断表达式是否为字段
|
||||
|
/// </summary>
|
||||
|
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; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 判断表达式是否为 null 常量
|
||||
|
/// </summary>
|
||||
|
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 方法调用翻译
|
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 取反查询
|
||||
|
/// </summary>
|
||||
|
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 } |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 翻译方法调用表达式
|
||||
|
/// </summary>
|
||||
|
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); |
||||
|
} |
||||
|
|
||||
|
// 字符串方法
|
||||
|
if (node.Method.DeclaringType == typeof(string)) |
||||
|
{ |
||||
|
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}"); |
||||
|
} |
||||
|
|
||||
|
#endregion
|
||||
|
|
||||
|
#region 查询构建
|
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 构建相等查询
|
||||
|
/// </summary>
|
||||
|
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) }; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 构建 NotEqual 查询
|
||||
|
/// </summary>
|
||||
|
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) } |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 构建 Terms 查询(用于集合包含)
|
||||
|
/// </summary>
|
||||
|
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<object>().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); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 构建范围查询
|
||||
|
/// </summary>
|
||||
|
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 辅助方法
|
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 解包 Lambda 表达式
|
||||
|
/// </summary>
|
||||
|
private static LambdaExpression UnwrapLambda(Expression expression) |
||||
|
{ |
||||
|
while (expression is UnaryExpression { NodeType: ExpressionType.Quote } unary) |
||||
|
{ |
||||
|
expression = unary.Operand; |
||||
|
} |
||||
|
return (LambdaExpression)expression; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 求值表达式
|
||||
|
/// </summary>
|
||||
|
private static object? Evaluate(Expression expression) |
||||
|
{ |
||||
|
// 如果是常量表达式,直接返回值
|
||||
|
if (expression is ConstantExpression constant) |
||||
|
{ |
||||
|
return constant.Value; |
||||
|
} |
||||
|
|
||||
|
// 如果是参数表达式,无法求值
|
||||
|
if (expression is ParameterExpression) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"Cannot evaluate parameter expression: {expression}"); |
||||
|
} |
||||
|
|
||||
|
// 处理成员访问(捕获外部变量)
|
||||
|
if (expression is MemberExpression memberExpr) |
||||
|
{ |
||||
|
// 检查是否是字段或属性访问
|
||||
|
if (memberExpr.Member is System.Reflection.FieldInfo fieldInfo) |
||||
|
{ |
||||
|
// 如果是静态字段
|
||||
|
if (fieldInfo.IsStatic) |
||||
|
{ |
||||
|
return fieldInfo.GetValue(null); |
||||
|
} |
||||
|
|
||||
|
// 如果是实例字段,需要先求值实例
|
||||
|
if (memberExpr.Expression != null) |
||||
|
{ |
||||
|
var obj = Evaluate(memberExpr.Expression); |
||||
|
if (obj != null) |
||||
|
{ |
||||
|
return fieldInfo.GetValue(obj); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 如果无法求值,尝试编译整个表达式
|
||||
|
try |
||||
|
{ |
||||
|
return Expression.Lambda(memberExpr).Compile().DynamicInvoke(); |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
throw new InvalidOperationException($"Cannot evaluate member expression: {memberExpr}"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (memberExpr.Member is PropertyInfo propertyInfo) |
||||
|
{ |
||||
|
// 如果是静态属性
|
||||
|
var getMethod = propertyInfo.GetGetMethod(); |
||||
|
if (getMethod != null && getMethod.IsStatic) |
||||
|
{ |
||||
|
return propertyInfo.GetValue(null); |
||||
|
} |
||||
|
|
||||
|
// 如果是实例属性,需要先求值实例
|
||||
|
if (memberExpr.Expression != null) |
||||
|
{ |
||||
|
var obj = Evaluate(memberExpr.Expression); |
||||
|
if (obj != null) |
||||
|
{ |
||||
|
return propertyInfo.GetValue(obj); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 如果无法求值,尝试编译整个表达式
|
||||
|
try |
||||
|
{ |
||||
|
return Expression.Lambda(memberExpr).Compile().DynamicInvoke(); |
||||
|
} |
||||
|
catch |
||||
|
{ |
||||
|
throw new InvalidOperationException($"Cannot evaluate member expression: {memberExpr}"); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 处理数组索引
|
||||
|
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 |
||||
|
{ |
||||
|
// 尝试编译并执行
|
||||
|
var lambda = Expression.Lambda(methodCall); |
||||
|
return lambda.Compile().DynamicInvoke(); |
||||
|
} |
||||
|
catch (InvalidOperationException) |
||||
|
{ |
||||
|
// 如果包含参数引用,无法编译
|
||||
|
throw; |
||||
|
} |
||||
|
catch (Exception ex) |
||||
|
{ |
||||
|
throw new InvalidOperationException($"Cannot evaluate method call: {methodCall}", ex); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 处理 NewArrayExpression
|
||||
|
if (expression is NewArrayExpression newArray) |
||||
|
{ |
||||
|
var values = newArray.Expressions.Select(Evaluate).ToArray(); |
||||
|
return values; |
||||
|
} |
||||
|
|
||||
|
// 默认尝试编译执行
|
||||
|
|
||||
|
return Expression.Lambda(expression).Compile().DynamicInvoke(); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 转义通配符
|
||||
|
/// </summary>
|
||||
|
private static string EscapeWildcard(string value) |
||||
|
{ |
||||
|
return value.Replace("\\", "\\\\").Replace("*", "\\*").Replace("?", "\\?"); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 标准化值
|
||||
|
/// </summary>
|
||||
|
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) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 规范化日期值
|
||||
|
/// </summary>
|
||||
|
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()!; |
||||
|
} |
||||
|
/// <summary>
|
||||
|
/// 转换为 Number
|
||||
|
/// </summary>
|
||||
|
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) |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 规范化枚举名称
|
||||
|
/// </summary>
|
||||
|
private static string NormalizeEnumName(Type enumType, object value) |
||||
|
{ |
||||
|
var enumValue = value is Enum e |
||||
|
? e |
||||
|
: (Enum)Enum.ToObject(enumType, value); |
||||
|
return enumValue.ToString(); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 判断是否为数值类型
|
||||
|
/// </summary>
|
||||
|
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); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 判断是否为 Nullable.HasValue 访问
|
||||
|
/// </summary>
|
||||
|
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<>); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 判断是否为 Nullable.Value 访问
|
||||
|
/// </summary>
|
||||
|
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
|
||||
|
} |
||||
@ -0,0 +1,52 @@ |
|||||
|
using System; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
/// <summary>
|
||||
|
/// 字段信息
|
||||
|
/// </summary>
|
||||
|
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; |
||||
|
} |
||||
|
} |
||||
@ -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<string, FieldMappingInfo>? Properties { get; set; } |
||||
|
public Dictionary<string, object>? 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; |
||||
|
} |
||||
@ -0,0 +1,48 @@ |
|||||
|
using Elastic.Clients.Elasticsearch; |
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Linq.Expressions; |
||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
public interface IExpressionQueryService |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// 查询符合条件的文档数量
|
||||
|
/// </summary>
|
||||
|
/// <typeparam name="TDocument">文档类型</typeparam>
|
||||
|
/// <param name="indexName">文档索引</param>
|
||||
|
/// <param name="expression">查询表达式树</param>
|
||||
|
/// <param name="cancellationToken"></param>
|
||||
|
/// <returns></returns>
|
||||
|
Task<long> GetCountAsync<TDocument>( |
||||
|
string indexName, |
||||
|
Expression<Func<TDocument, bool>> expression, |
||||
|
CancellationToken cancellationToken = default) where TDocument : class; |
||||
|
/// <summary>
|
||||
|
/// 查询符合条件的文档列表
|
||||
|
/// </summary>
|
||||
|
/// <typeparam name="TDocument">文档类型</typeparam>
|
||||
|
/// <param name="indexName">文档索引</param>
|
||||
|
/// <param name="expression">查询表达式树</param>
|
||||
|
/// <param name="sorting">排序字段</param>
|
||||
|
/// <param name="maxResultCount">最大返回数据大小</param>
|
||||
|
/// <param name="skipCount">跳过数据大小</param>
|
||||
|
/// <param name="sourceExcludes">包含字段</param>
|
||||
|
/// <param name="sourceIncludes">忽略字段</param>
|
||||
|
/// <param name="beginMarker">排序起始字段</param>
|
||||
|
/// <param name="cancellationToken"></param>
|
||||
|
/// <returns></returns>
|
||||
|
Task<List<TDocument>> GetListAsync<TDocument>( |
||||
|
string indexName, |
||||
|
Expression<Func<TDocument, bool>> expression, |
||||
|
string? sorting = null, |
||||
|
int maxResultCount = 50, |
||||
|
int skipCount = 0, |
||||
|
Fields? sourceExcludes = null, |
||||
|
Fields? sourceIncludes = null, |
||||
|
object[]? beginMarker = null, |
||||
|
CancellationToken cancellationToken = default) where TDocument : class; |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
using Elastic.Clients.Elasticsearch.QueryDsl; |
||||
|
using System; |
||||
|
using System.Linq.Expressions; |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
public interface IExpressionQueryTranslator |
||||
|
{ |
||||
|
Task<Query> TranslateAsync<TDocument>(string indexName, Expression<Func<TDocument, bool>> expression); |
||||
|
} |
||||
@ -0,0 +1,9 @@ |
|||||
|
using System.Threading; |
||||
|
using System.Threading.Tasks; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
public interface IIndexMappingProvider |
||||
|
{ |
||||
|
Task<IndexMappingInfo> GetMappingAsync(string indexName, CancellationToken cancellationToken = default); |
||||
|
} |
||||
@ -0,0 +1,139 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
using System.Linq; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
/// <summary>
|
||||
|
/// 索引映射信息
|
||||
|
/// </summary>
|
||||
|
public class IndexMappingInfo |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// 索引名称
|
||||
|
/// </summary>
|
||||
|
public string IndexName { get; set; } = string.Empty; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 所有字段映射(扁平化)
|
||||
|
/// </summary>
|
||||
|
public Dictionary<string, FieldMappingInfo> Fields { get; set; } = new(); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Nested 字段映射
|
||||
|
/// </summary>
|
||||
|
public Dictionary<string, NestedMappingInfo> NestedFields { get; set; } = new(); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Keyword 字段列表
|
||||
|
/// </summary>
|
||||
|
public HashSet<string> KeywordFields { get; set; } = new(); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Text 字段列表
|
||||
|
/// </summary>
|
||||
|
public HashSet<string> TextFields { get; set; } = new(); |
||||
|
/// <summary>
|
||||
|
/// Wildcard 字段列表
|
||||
|
/// </summary>
|
||||
|
public HashSet<string> WildcardFields { get; set; } = new(); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 日期字段列表
|
||||
|
/// </summary>
|
||||
|
public HashSet<string> DateFields { get; set; } = new(); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 数值字段列表
|
||||
|
/// </summary>
|
||||
|
public HashSet<string> NumericFields { get; set; } = new(); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 布尔字段列表
|
||||
|
/// </summary>
|
||||
|
public HashSet<string> BooleanFields { get; set; } = new(); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Nested 字段路径列表
|
||||
|
/// </summary>
|
||||
|
public HashSet<string> NestedFieldPaths { get; set; } = new(); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 获取字段映射信息
|
||||
|
/// </summary>
|
||||
|
public FieldMappingInfo? GetField(string path) |
||||
|
{ |
||||
|
return Fields.GetOrDefault(path); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 判断是否为 Nested 字段
|
||||
|
/// </summary>
|
||||
|
public bool IsNested(string path) |
||||
|
{ |
||||
|
return NestedFields.ContainsKey(path) || NestedFieldPaths.Contains(path); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 获取 Nested 字段信息
|
||||
|
/// </summary>
|
||||
|
public NestedMappingInfo? GetNestedField(string path) |
||||
|
{ |
||||
|
return NestedFields.GetOrDefault(path); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 获取字段的精确匹配路径(处理 text 的 keyword 子字段)
|
||||
|
/// </summary>
|
||||
|
public string GetExactFieldPath(string path) |
||||
|
{ |
||||
|
var field = GetField(path); |
||||
|
if (field == null) |
||||
|
{ |
||||
|
return path; |
||||
|
} |
||||
|
|
||||
|
return field.GetKeywordPath(); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 判断字段是否需要 Nested 查询
|
||||
|
/// </summary>
|
||||
|
public bool ShouldUseNestedQuery(string path) |
||||
|
{ |
||||
|
// 检查路径本身是否是 Nested
|
||||
|
if (IsNested(path)) |
||||
|
{ |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
// 检查路径的父级是否是 Nested
|
||||
|
var parts = path.Split('.'); |
||||
|
for (var i = 0; i < parts.Length - 1; i++) |
||||
|
{ |
||||
|
var parentPath = string.Join(".", parts.Take(i + 1)); |
||||
|
if (IsNested(parentPath)) |
||||
|
{ |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 获取 Nested 字段路径(如果字段在 Nested 内部,返回最近的 Nested 父级路径)
|
||||
|
/// </summary>
|
||||
|
public string? GetNestedParentPath(string path) |
||||
|
{ |
||||
|
var parts = path.Split('.'); |
||||
|
for (var i = parts.Length - 1; i >= 0; i--) |
||||
|
{ |
||||
|
var parentPath = string.Join(".", parts.Take(i + 1)); |
||||
|
if (IsNested(parentPath)) |
||||
|
{ |
||||
|
return parentPath; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,4 @@ |
|||||
|
namespace System.Runtime.CompilerServices |
||||
|
{ |
||||
|
internal static class IsExternalInit { } |
||||
|
} |
||||
@ -0,0 +1,89 @@ |
|||||
|
using System.Collections.Generic; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Nested 字段的映射信息
|
||||
|
/// </summary>
|
||||
|
public class NestedMappingInfo |
||||
|
{ |
||||
|
/// <summary>
|
||||
|
/// Nested 字段的完整路径
|
||||
|
/// </summary>
|
||||
|
public string Path { get; set; } = string.Empty; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Nested 字段名称(最后一段)
|
||||
|
/// </summary>
|
||||
|
public string Name { get; set; } = string.Empty; |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// Nested 内部的属性映射
|
||||
|
/// </summary>
|
||||
|
public Dictionary<string, FieldMappingInfo> Properties { get; set; } = new(); |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 是否包含指定子字段
|
||||
|
/// </summary>
|
||||
|
public bool ContainsProperty(string propertyName) |
||||
|
{ |
||||
|
return Properties.ContainsKey(propertyName); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 获取子字段信息
|
||||
|
/// </summary>
|
||||
|
public FieldMappingInfo? GetProperty(string propertyName) |
||||
|
{ |
||||
|
return Properties.GetOrDefault(propertyName); |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 获取 nested 内部字段的完整路径
|
||||
|
/// </summary>
|
||||
|
public string GetFullPath(string propertyName) |
||||
|
{ |
||||
|
return $"{Path}.{propertyName}"; |
||||
|
} |
||||
|
|
||||
|
/// <summary>
|
||||
|
/// 获取 nested 内部字段的映射信息(递归)
|
||||
|
/// </summary>
|
||||
|
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<string, FieldMappingInfo>? 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; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
|
||||
|
<PropertyGroup> |
||||
|
<TargetFramework>net10.0</TargetFramework> |
||||
|
<RootNamespace /> |
||||
|
<IsPackable>false</IsPackable> |
||||
|
<Platforms>AnyCPU</Platforms> |
||||
|
</PropertyGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" /> |
||||
|
<PackageReference Include="Moq.AutoMock" /> |
||||
|
<PackageReference Include="Serilog.Sinks.Elasticsearch" /> |
||||
|
</ItemGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<ProjectReference Include="..\..\framework\elasticsearch\LINGYUN.Abp.Elasticsearch.EsqlQuery\LINGYUN.Abp.Elasticsearch.EsqlQuery.csproj" /> |
||||
|
<ProjectReference Include="..\LINGYUN.Abp.Elasticsearch.Tests\LINGYUN.Abp.Elasticsearch.Tests.csproj" /> |
||||
|
</ItemGroup> |
||||
|
</Project> |
||||
@ -0,0 +1,7 @@ |
|||||
|
using LINGYUN.Abp.Tests; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch.EsqlQuery; |
||||
|
|
||||
|
public abstract class AbpElasticsearchEsqlQueryTestBase : AbpTestsBase<AbpElasticsearchEsqlQueryTestModule> |
||||
|
{ |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
using Microsoft.Extensions.Configuration; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
using Volo.Abp.Modularity; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch.EsqlQuery; |
||||
|
|
||||
|
[DependsOn( |
||||
|
typeof(AbpElasticsearchEsqlQueryModule), |
||||
|
typeof(AbpElasticsearchTestModule))] |
||||
|
public class AbpElasticsearchEsqlQueryTestModule : AbpModule |
||||
|
{ |
||||
|
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); |
||||
|
})); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,5 @@ |
|||||
|
namespace LINGYUN.Abp.Elasticsearch.EsqlQuery; |
||||
|
|
||||
|
public class EsqlExpressionQueryService_Tests : ExpressionQueryService_Tests<AbpElasticsearchEsqlQueryTestModule> |
||||
|
{ |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
<Project Sdk="Microsoft.NET.Sdk"> |
||||
|
|
||||
|
<PropertyGroup> |
||||
|
<TargetFramework>net10.0</TargetFramework> |
||||
|
<RootNamespace /> |
||||
|
<IsPackable>false</IsPackable> |
||||
|
<Platforms>AnyCPU</Platforms> |
||||
|
<Nullable>enable</Nullable> |
||||
|
</PropertyGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<PackageReference Include="Microsoft.NET.Test.Sdk" /> |
||||
|
<PackageReference Include="Moq.AutoMock" /> |
||||
|
</ItemGroup> |
||||
|
|
||||
|
<ItemGroup> |
||||
|
<ProjectReference Include="..\..\framework\elasticsearch\LINGYUN.Abp.Elasticsearch\LINGYUN.Abp.Elasticsearch.csproj" /> |
||||
|
<ProjectReference Include="..\LINGYUN.Abp.TestBase\LINGYUN.Abp.TestsBase.csproj" /> |
||||
|
</ItemGroup> |
||||
|
</Project> |
||||
@ -0,0 +1,7 @@ |
|||||
|
using LINGYUN.Abp.Tests; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
public abstract class AbpElasticsearchTestBase : AbpTestsBase<AbpElasticsearchTestModule> |
||||
|
{ |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
using LINGYUN.Abp.Tests; |
||||
|
using Microsoft.Extensions.Configuration; |
||||
|
using Microsoft.Extensions.DependencyInjection; |
||||
|
using Volo.Abp.Modularity; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
[DependsOn( |
||||
|
typeof(AbpTestsBaseModule), |
||||
|
typeof(AbpElasticsearchModule))] |
||||
|
public class AbpElasticsearchTestModule : AbpModule |
||||
|
{ |
||||
|
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); |
||||
|
})); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,5 @@ |
|||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
public class DefaultExpressionQueryService_Tests : ExpressionQueryService_Tests<AbpElasticsearchTestModule> |
||||
|
{ |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
using LINGYUN.Abp.Elasticsearch.Tests; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
public class DefaultExpressionQueryTranslatorTests : ExpressionQueryTranslatorTests<AbpElasticsearchTestModule> |
||||
|
{ |
||||
|
} |
||||
@ -0,0 +1,169 @@ |
|||||
|
using Elastic.Clients.Elasticsearch; |
||||
|
using Shouldly; |
||||
|
using System; |
||||
|
using System.Collections.Generic; |
||||
|
using System.Threading.Tasks; |
||||
|
using Volo.Abp.Modularity; |
||||
|
using Volo.Abp.Testing; |
||||
|
using Volo.Abp.Threading; |
||||
|
using Xunit; |
||||
|
|
||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
public abstract class ExpressionQueryService_Tests<TStartupModule> : AbpIntegratedTest<TStartupModule> |
||||
|
where TStartupModule : IAbpModule |
||||
|
{ |
||||
|
private List<TestDocument> _documents = new List<TestDocument>(); |
||||
|
protected IExpressionQueryService ExpressionQueryService { get; } |
||||
|
protected ExpressionQueryService_Tests() |
||||
|
{ |
||||
|
ExpressionQueryService = GetRequiredService<IExpressionQueryService>(); |
||||
|
} |
||||
|
|
||||
|
#region 索引初始化
|
||||
|
|
||||
|
protected override void AfterInitialize() |
||||
|
{ |
||||
|
AsyncHelper.RunSync(async () => await ApplicationInitializationAsync()); |
||||
|
} |
||||
|
|
||||
|
public override void Dispose() |
||||
|
{ |
||||
|
AsyncHelper.RunSync(async () => await ApplicationShutdownAsync()); |
||||
|
} |
||||
|
|
||||
|
protected async virtual Task ApplicationInitializationAsync() |
||||
|
{ |
||||
|
var clientFactory = GetRequiredService<IElasticsearchClientFactory>(); |
||||
|
var client = clientFactory.Create(); |
||||
|
|
||||
|
_documents.AddRange(new[] |
||||
|
{ |
||||
|
new TestDocument |
||||
|
{ |
||||
|
Name = "Name1", |
||||
|
Age = 20, |
||||
|
Address = new Address |
||||
|
{ |
||||
|
City = "HANGZHOU", |
||||
|
}, |
||||
|
CreatedTime = new DateTime(2026, 8, 1, 0, 0, 0), |
||||
|
Status = TestEnum.Active, |
||||
|
StringValueStatus = TestEnum.Pending, |
||||
|
IsActive = true, |
||||
|
Id = 1, |
||||
|
Salary = 3m, |
||||
|
}, |
||||
|
new TestDocument |
||||
|
{ |
||||
|
Name = "Name2", |
||||
|
Age = 10, |
||||
|
Address = new Address |
||||
|
{ |
||||
|
City = "GUANGZHOU", |
||||
|
}, |
||||
|
CreatedTime = new DateTime(2026, 5, 1, 0, 0, 0), |
||||
|
Status = TestEnum.Inactive, |
||||
|
StringValueStatus = TestEnum.Active, |
||||
|
IsActive = false, |
||||
|
Id = 2, |
||||
|
Salary = 7m, |
||||
|
Tags = new List<string>{ "B" }, |
||||
|
}, |
||||
|
new TestDocument |
||||
|
{ |
||||
|
Name = "Test1", |
||||
|
Age = 30, |
||||
|
Address = new Address |
||||
|
{ |
||||
|
City = "BEIJING", |
||||
|
}, |
||||
|
CreatedTime = new DateTime(2026, 3, 1, 0, 0, 0), |
||||
|
Status = TestEnum.Pending, |
||||
|
StringValueStatus = TestEnum.Inactive, |
||||
|
IsActive = true, |
||||
|
Id = 3, |
||||
|
Salary = 10m, |
||||
|
Tags = new List<string> { "B", "C" }, |
||||
|
}, |
||||
|
}); |
||||
|
|
||||
|
await client.BulkAsync(b => |
||||
|
b.Index(TestDocumentIndexNames.Index) |
||||
|
.Refresh(Refresh.WaitFor) |
||||
|
.IndexMany(_documents)); |
||||
|
} |
||||
|
|
||||
|
protected async virtual Task ApplicationShutdownAsync() |
||||
|
{ |
||||
|
var clientFactory = GetRequiredService<IElasticsearchClientFactory>(); |
||||
|
var client = clientFactory.Create(); |
||||
|
|
||||
|
await client.Indices.DeleteAsync(TestDocumentIndexNames.Index); |
||||
|
} |
||||
|
|
||||
|
#endregion
|
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Get_Count() |
||||
|
{ |
||||
|
(await ExpressionQueryService.GetCountAsync<TestDocument>( |
||||
|
TestDocumentIndexNames.Index, |
||||
|
x => x.Name!.StartsWith("Name") && x.Salary <= 10m)).ShouldBe(2); |
||||
|
|
||||
|
(await ExpressionQueryService.GetCountAsync<TestDocument>( |
||||
|
TestDocumentIndexNames.Index, |
||||
|
x => !string.IsNullOrWhiteSpace(x.Name) && x.Tags != null && x.Tags.Contains("B"))).ShouldBe(2); |
||||
|
|
||||
|
(await ExpressionQueryService.GetCountAsync<TestDocument>( |
||||
|
TestDocumentIndexNames.Index, |
||||
|
x => |
||||
|
x.Name != null && x.Name.Contains("1") && |
||||
|
(x.Status == TestEnum.Active || x.Status == TestEnum.Pending))).ShouldBe(2); |
||||
|
} |
||||
|
|
||||
|
[Fact] |
||||
|
public async Task Should_Get_List() |
||||
|
{ |
||||
|
var list1 = await ExpressionQueryService.GetListAsync<TestDocument>( |
||||
|
TestDocumentIndexNames.Index, |
||||
|
x => x.Name!.StartsWith("Name") && x.Salary <= 10m); |
||||
|
list1.Count.ShouldBe(2); |
||||
|
list1[0].Name.ShouldBe("Name1"); |
||||
|
list1[0].Age.ShouldBe(20); |
||||
|
list1[0].Status.ShouldBe(TestEnum.Active); |
||||
|
list1[0].StringValueStatus.ShouldBe(TestEnum.Pending); |
||||
|
list1[0].IsActive.ShouldBeTrue(); |
||||
|
list1[0].Tags.ShouldBeNull(); |
||||
|
list1[0].Address.ShouldNotBeNull(); |
||||
|
list1[0].Address!.City.ShouldBe("HANGZHOU"); |
||||
|
|
||||
|
var list2 = await ExpressionQueryService.GetListAsync<TestDocument>( |
||||
|
TestDocumentIndexNames.Index, |
||||
|
x => !string.IsNullOrWhiteSpace(x.Name) && x.Tags != null && x.Tags.Contains("B")); |
||||
|
list2.Count.ShouldBe(2); |
||||
|
list2[0].Name.ShouldBe("Name2"); |
||||
|
list2[0].Age.ShouldBe(10); |
||||
|
list2[0].Status.ShouldBe(TestEnum.Inactive); |
||||
|
list2[0].StringValueStatus.ShouldBe(TestEnum.Active); |
||||
|
list2[0].IsActive.ShouldBeFalse(); |
||||
|
list2[0].Tags.ShouldNotBeEmpty(); |
||||
|
list2[0].Tags!.ShouldContain("B"); |
||||
|
list2[0].Address!.City.ShouldBe("GUANGZHOU"); |
||||
|
|
||||
|
var list3 = await ExpressionQueryService.GetListAsync<TestDocument>( |
||||
|
TestDocumentIndexNames.Index, |
||||
|
x => |
||||
|
x.Name != null && x.Name.Contains("1") && |
||||
|
(x.Status == TestEnum.Active || x.Status == TestEnum.Pending)); |
||||
|
list3.Count.ShouldBe(2); |
||||
|
list3[1].Name.ShouldBe("Test1"); |
||||
|
list3[1].Age.ShouldBe(30); |
||||
|
list3[1].Status.ShouldBe(TestEnum.Pending); |
||||
|
list3[1].StringValueStatus.ShouldBe(TestEnum.Inactive); |
||||
|
list3[1].IsActive.ShouldBeTrue(); |
||||
|
list3[1].Tags.ShouldNotBeEmpty(); |
||||
|
list3[1].Tags!.ShouldContain("C"); |
||||
|
list3[1].Address!.City.ShouldBe("BEIJING"); |
||||
|
} |
||||
|
} |
||||
File diff suppressed because it is too large
@ -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<SubDocument>? Items { get; set; } |
||||
|
public List<string>? 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 |
||||
|
} |
||||
@ -0,0 +1,6 @@ |
|||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
public static class TestDocumentIndexNames |
||||
|
{ |
||||
|
public const string Index = "abp-elasticsearch-test"; |
||||
|
} |
||||
Loading…
Reference in new issue