Browse Source

Merge pull request #1552 from colinin/es-expression-tree-translator

feat: Add an ES expression tree translator
pull/1467/merge
yx lin 1 month ago
committed by GitHub
parent
commit
cb49d33343
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 2
      Directory.Packages.props
  2. 6
      aspnet-core/LINGYUN.MicroService.All.slnx
  3. 3
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/FodyWeavers.xml
  4. 30
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/FodyWeavers.xsd
  5. 24
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN.Abp.Elasticsearch.EsqlQuery.csproj
  6. 8
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN/Abp/Elasticsearch/EsqlQuery/AbpElasticsearchEsqlQueryModule.cs
  7. 83
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN/Abp/Elasticsearch/EsqlQuery/EsqlExpressionQueryService.cs
  8. 22
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/README.md
  9. 2
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN.Abp.Elasticsearch.csproj
  10. 15
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/AbpElasticsearchModule.cs
  11. 387
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ElasticsearchIndexMappingProvider.cs
  12. 273
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryService.cs
  13. 98
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Collection.cs
  14. 25
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Enum.cs
  15. 97
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Enumerable.cs
  16. 114
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Field.cs
  17. 317
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.String.cs
  18. 964
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.cs
  19. 52
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/FieldInfo.cs
  20. 47
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/FieldMappingInfo.cs
  21. 48
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IExpressionQueryService.cs
  22. 11
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IExpressionQueryTranslator.cs
  23. 9
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IIndexMappingProvider.cs
  24. 139
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IndexMappingInfo.cs
  25. 4
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IsExternalInit.cs
  26. 89
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/NestedMappingInfo.cs
  27. 8
      aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/README.md
  28. 20
      aspnet-core/tests/LINGYUN.Abp.Elasticsearch.EsqlQuery.Tests/LINGYUN.Abp.Elasticsearch.EsqlQuery.Tests.csproj
  29. 7
      aspnet-core/tests/LINGYUN.Abp.Elasticsearch.EsqlQuery.Tests/LINGYUN/Abp/Elasticsearch/EsqlQuery/AbpElasticsearchEsqlQueryTestBase.cs
  30. 21
      aspnet-core/tests/LINGYUN.Abp.Elasticsearch.EsqlQuery.Tests/LINGYUN/Abp/Elasticsearch/EsqlQuery/AbpElasticsearchEsqlQueryTestModule.cs
  31. 5
      aspnet-core/tests/LINGYUN.Abp.Elasticsearch.EsqlQuery.Tests/LINGYUN/Abp/Elasticsearch/EsqlQuery/EsqlExpressionQueryService_Tests.cs
  32. 20
      aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN.Abp.Elasticsearch.Tests.csproj
  33. 7
      aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/AbpElasticsearchTestBase.cs
  34. 22
      aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/AbpElasticsearchTestModule.cs
  35. 5
      aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/DefaultExpressionQueryService_Tests.cs
  36. 7
      aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/DefaultExpressionQueryTranslatorTests.cs
  37. 169
      aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/ExpressionQueryService_Tests.cs
  38. 1096
      aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator_Tests.cs
  39. 43
      aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/TestDocument.cs
  40. 6
      aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/TestDocumentIndexNames.cs

2
Directory.Packages.props

@ -360,7 +360,7 @@
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="NEST" Version="7.17.5" />
<PackageVersion Include="Elastic.Clients.Elasticsearch" Version="8.19.4" />
<PackageVersion Include="NRules" Version="0.9.2" />
<PackageVersion Include="Elastic.Clients.Esql" Version="0.11.0" />
<PackageVersion Include="Ocelot.Provider.Polly" Version="20.0.0" />
<PackageVersion Include="OllamaSharp" Version="5.4.20" />
<PackageVersion Include="ModelContextProtocol" Version="1.1.0" />

6
aspnet-core/LINGYUN.MicroService.All.slnx

@ -155,6 +155,7 @@
<Project Path="framework/efcore/LINGYUN.Abp.EntityFrameworkCore.MySQL.Microting/LINGYUN.Abp.EntityFrameworkCore.MySQL.Microting.csproj" />
</Folder>
<Folder Name="/framework/elasticsearch/">
<Project Path="framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN.Abp.Elasticsearch.EsqlQuery.csproj" Id="ef34fa8c-55e4-4976-852b-20e94c355ab5" />
<Project Path="framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN.Abp.Elasticsearch.csproj" />
</Folder>
<Folder Name="/framework/entity-change/">
@ -525,9 +526,12 @@
<Folder Name="/tests/">
<Project Path="tests/LINGYUN.Abp.Aliyun.Tests/LINGYUN.Abp.Aliyun.Tests.csproj" />
<Project Path="tests/LINGYUN.Abp.AuditLogging.Elasticsearch.Tests/LINGYUN.Abp.AuditLogging.Elasticsearch.Tests.csproj" />
<Project Path="tests/LINGYUN.Abp.AuditLogging.Tests/LINGYUN.Abp.AuditLogging.Tests.csproj" Id="a360d3e3-0cef-433c-a866-77abd64d8a52" />
<Project Path="tests/LINGYUN.Abp.BlobStoring.Aliyun.Tests/LINGYUN.Abp.BlobStoring.Aliyun.Tests.csproj" />
<Project Path="tests/LINGYUN.Abp.BlobStoring.Nexus.Tests/LINGYUN.Abp.BlobStoring.Nexus.Tests.csproj" />
<Project Path="tests/LINGYUN.Abp.DataProtection.Tests/LINGYUN.Abp.DataProtection.Tests.csproj" />
<Project Path="tests/LINGYUN.Abp.Elasticsearch.EsqlQuery.Tests/LINGYUN.Abp.Elasticsearch.EsqlQuery.Tests.csproj" Id="52d961a7-c89d-4fca-ad35-8c7f3588aeb9" />
<Project Path="tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN.Abp.Elasticsearch.Tests.csproj" Id="056a13a4-8fbb-4437-adc8-fe76d0869880" />
<Project Path="tests/LINGYUN.Abp.Emailing.Platform.Tests/LINGYUN.Abp.Emailing.Platform.Tests.csproj" />
<Project Path="tests/LINGYUN.Abp.EntityFrameworkCore.Tests/LINGYUN.Abp.EntityFrameworkCore.Tests.csproj" />
<Project Path="tests/LINGYUN.Abp.ExceptionHandling.Emailing.Tests/LINGYUN.Abp.ExceptionHandling.Emailing.Tests.csproj" />
@ -537,6 +541,8 @@
<Project Path="tests/LINGYUN.Abp.Localization.Xml.Tests/LINGYUN.Abp.Localization.Xml.Tests.csproj" />
<Project Path="tests/LINGYUN.Abp.Location.Baidu.Tests/LINGYUN.Abp.Location.Baidu.Tests.csproj" />
<Project Path="tests/LINGYUN.Abp.Location.Tencent.Tests/LINGYUN.Abp.Location.Tencent.Tests.csproj" />
<Project Path="tests/LINGYUN.Abp.Logging.Serilog.Elasticsearch.Tests/LINGYUN.Abp.Logging.Serilog.Elasticsearch.Tests.csproj" Id="d9b9a706-000c-4344-8eac-27ee78fcbb2a" />
<Project Path="tests/LINGYUN.Abp.Logging.Tests/LINGYUN.Abp.Logging.Tests.csproj" Id="1e05270f-6652-443d-b76f-5d4360f2722e" />
<Project Path="tests/LINGYUN.Abp.Rules.RulesEngine.Tests/LINGYUN.Abp.Rules.RulesEngine.Tests.csproj" />
<Project Path="tests/LINGYUN.Abp.Sms.Platform.Tests/LINGYUN.Abp.Sms.Platform.Tests.csproj" />
<Project Path="tests/LINGYUN.Abp.TestBase/LINGYUN.Abp.TestsBase.csproj" />

3
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/FodyWeavers.xml

@ -0,0 +1,3 @@
<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
<ConfigureAwait ContinueOnCapturedContext="false" />
</Weavers>

30
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/FodyWeavers.xsd

@ -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>

24
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN.Abp.Elasticsearch.EsqlQuery.csproj

@ -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>

8
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN/Abp/Elasticsearch/EsqlQuery/AbpElasticsearchEsqlQueryModule.cs

@ -0,0 +1,8 @@
using Volo.Abp.Modularity;
namespace LINGYUN.Abp.Elasticsearch.EsqlQuery;
[DependsOn(typeof(AbpElasticsearchModule))]
public class AbpElasticsearchEsqlQueryModule : AbpModule
{
}

83
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/LINGYUN/Abp/Elasticsearch/EsqlQuery/EsqlExpressionQueryService.cs

@ -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);
}
}

22
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch.EsqlQuery/README.md

@ -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
}
```

2
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN.Abp.Elasticsearch.csproj

@ -17,7 +17,7 @@
<ItemGroup>
<PackageReference Include="Elastic.Clients.Elasticsearch" />
<PackageReference Include="Volo.Abp.Core" />
<PackageReference Include="Volo.Abp.Caching" />
</ItemGroup>
</Project>

15
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/AbpElasticsearchModule.cs

@ -1,14 +1,15 @@
using Microsoft.Extensions.DependencyInjection;
using Volo.Abp.Caching;
using Volo.Abp.Modularity;
namespace LINGYUN.Abp.Elasticsearch
namespace LINGYUN.Abp.Elasticsearch;
[DependsOn(typeof(AbpCachingModule))]
public class AbpElasticsearchModule : AbpModule
{
public class AbpElasticsearchModule : AbpModule
public override void ConfigureServices(ServiceConfigurationContext context)
{
public override void ConfigureServices(ServiceConfigurationContext context)
{
var configuration = context.Services.GetConfiguration();
Configure<AbpElasticsearchOptions>(configuration.GetSection("Elasticsearch"));
}
var configuration = context.Services.GetConfiguration();
Configure<AbpElasticsearchOptions>(configuration.GetSection("Elasticsearch"));
}
}

387
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ElasticsearchIndexMappingProvider.cs

@ -0,0 +1,387 @@
using Elastic.Clients.Elasticsearch;
using Elastic.Clients.Elasticsearch.Mapping;
using Elastic.Transport.Products.Elasticsearch;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Volo.Abp.DependencyInjection;
namespace LINGYUN.Abp.Elasticsearch;
public class ElasticsearchIndexMappingProvider : IIndexMappingProvider, ITransientDependency
{
private readonly IMemoryCache _cache;
private readonly IElasticsearchClientFactory _clientFactory;
private readonly TimeSpan _cacheDuration = TimeSpan.FromMinutes(10);
public ElasticsearchIndexMappingProvider(
IElasticsearchClientFactory clientFactory,
IMemoryCache cache)
{
_clientFactory = clientFactory;
_cache = cache;
}
public async Task<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()
};
}
}

273
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryService.cs

@ -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();
}
}

98
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Collection.cs

@ -0,0 +1,98 @@
using Elastic.Clients.Elasticsearch.QueryDsl;
using System;
using System.Collections;
using System.Linq;
using System.Linq.Expressions;
namespace LINGYUN.Abp.Elasticsearch;
public partial class ExpressionQueryTranslator
{
/// <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}");
}
}
}

25
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Enum.cs

@ -0,0 +1,25 @@
using Elastic.Clients.Elasticsearch.QueryDsl;
using System;
using System.Linq.Expressions;
namespace LINGYUN.Abp.Elasticsearch;
public partial class ExpressionQueryTranslator
{
/// <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 };
}
}

97
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Enumerable.cs

@ -0,0 +1,97 @@
using Elastic.Clients.Elasticsearch.QueryDsl;
using System;
using System.Linq;
using System.Linq.Expressions;
namespace LINGYUN.Abp.Elasticsearch;
public partial class ExpressionQueryTranslator
{
/// <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);
}
}

114
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.Field.cs

@ -0,0 +1,114 @@
using System;
using System.Collections.Generic;
using System.Linq.Expressions;
using System.Reflection;
using System.Text.Json.Serialization;
namespace LINGYUN.Abp.Elasticsearch;
public partial class ExpressionQueryTranslator
{
/// <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;
}
}

317
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.String.cs

@ -0,0 +1,317 @@
using Elastic.Clients.Elasticsearch.QueryDsl;
using System;
using System.Linq.Expressions;
namespace LINGYUN.Abp.Elasticsearch;
public partial class ExpressionQueryTranslator
{
/// <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);
}
}

964
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator.cs

@ -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
}

52
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/FieldInfo.cs

@ -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;
}
}

47
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/FieldMappingInfo.cs

@ -0,0 +1,47 @@
using System;
using System.Collections.Generic;
namespace LINGYUN.Abp.Elasticsearch;
public class FieldMappingInfo
{
public string Path { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Type { get; set; } = string.Empty;
public Type? ClrType { get; set; }
public bool IsKeyword { get; set; }
public bool IsText { get; set; }
public bool IsWildcard { get; set; }
public bool IsNested { get; set; }
public bool IsObject { get; set; }
public bool IsDate { get; set; }
public bool IsNumeric { get; set; }
public bool IsBoolean { get; set; }
public bool IsRange { get; set; }
public string? Format { get; set; }
public bool? Store { get; set; }
public bool? Index { get; set; }
// 子字段(用于 text 的 keyword 子字段,或 object/nested 的内部字段)
public Dictionary<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;
}

48
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IExpressionQueryService.cs

@ -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;
}

11
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IExpressionQueryTranslator.cs

@ -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);
}

9
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IIndexMappingProvider.cs

@ -0,0 +1,9 @@
using System.Threading;
using System.Threading.Tasks;
namespace LINGYUN.Abp.Elasticsearch;
public interface IIndexMappingProvider
{
Task<IndexMappingInfo> GetMappingAsync(string indexName, CancellationToken cancellationToken = default);
}

139
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IndexMappingInfo.cs

@ -0,0 +1,139 @@
using System.Collections.Generic;
using System.Linq;
namespace LINGYUN.Abp.Elasticsearch;
/// <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;
}
}

4
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/IsExternalInit.cs

@ -0,0 +1,4 @@
namespace System.Runtime.CompilerServices
{
internal static class IsExternalInit { }
}

89
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/LINGYUN/Abp/Elasticsearch/NestedMappingInfo.cs

@ -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;
}
}

8
aspnet-core/framework/elasticsearch/LINGYUN.Abp.Elasticsearch/README.md

@ -6,7 +6,6 @@ Abp Elasticsearch集成,提供全局唯一IElasticClient访问接口
## 模块引用
```csharp
[DependsOn(typeof(AbpElasticsearchModule))]
public class YouProjectModule : AbpModule
@ -15,6 +14,13 @@ public class YouProjectModule : AbpModule
}
```
## 接口定义
* [IIndexMappingProvider](./LINGYUN/Abp/Elasticsearch/IIndexMappingProvider.cs) 获取索引映射属性
* [IExpressionQueryTranslator](./LINGYUN/Abp/Elasticsearch/IExpressionQueryTranslator.cs) 表达式树翻译为ES查询类
* [IExpressionQueryService](./LINGYUN/Abp/Elasticsearch/IExpressionQueryService.cs) 表达式树数据查询
* [IElasticsearchClientFactory](./LINGYUN/Abp/Elasticsearch/IElasticsearchClientFactory.cs) ES客户端管理
## 配置项
* AbpElasticsearchOptions.FieldCamelCase 字段是否采用 camelCase 格式, 默认false

20
aspnet-core/tests/LINGYUN.Abp.Elasticsearch.EsqlQuery.Tests/LINGYUN.Abp.Elasticsearch.EsqlQuery.Tests.csproj

@ -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>

7
aspnet-core/tests/LINGYUN.Abp.Elasticsearch.EsqlQuery.Tests/LINGYUN/Abp/Elasticsearch/EsqlQuery/AbpElasticsearchEsqlQueryTestBase.cs

@ -0,0 +1,7 @@
using LINGYUN.Abp.Tests;
namespace LINGYUN.Abp.Elasticsearch.EsqlQuery;
public abstract class AbpElasticsearchEsqlQueryTestBase : AbpTestsBase<AbpElasticsearchEsqlQueryTestModule>
{
}

21
aspnet-core/tests/LINGYUN.Abp.Elasticsearch.EsqlQuery.Tests/LINGYUN/Abp/Elasticsearch/EsqlQuery/AbpElasticsearchEsqlQueryTestModule.cs

@ -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);
}));
}
}

5
aspnet-core/tests/LINGYUN.Abp.Elasticsearch.EsqlQuery.Tests/LINGYUN/Abp/Elasticsearch/EsqlQuery/EsqlExpressionQueryService_Tests.cs

@ -0,0 +1,5 @@
namespace LINGYUN.Abp.Elasticsearch.EsqlQuery;
public class EsqlExpressionQueryService_Tests : ExpressionQueryService_Tests<AbpElasticsearchEsqlQueryTestModule>
{
}

20
aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN.Abp.Elasticsearch.Tests.csproj

@ -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>

7
aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/AbpElasticsearchTestBase.cs

@ -0,0 +1,7 @@
using LINGYUN.Abp.Tests;
namespace LINGYUN.Abp.Elasticsearch;
public abstract class AbpElasticsearchTestBase : AbpTestsBase<AbpElasticsearchTestModule>
{
}

22
aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/AbpElasticsearchTestModule.cs

@ -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);
}));
}
}

5
aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/DefaultExpressionQueryService_Tests.cs

@ -0,0 +1,5 @@
namespace LINGYUN.Abp.Elasticsearch;
public class DefaultExpressionQueryService_Tests : ExpressionQueryService_Tests<AbpElasticsearchTestModule>
{
}

7
aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/DefaultExpressionQueryTranslatorTests.cs

@ -0,0 +1,7 @@
using LINGYUN.Abp.Elasticsearch.Tests;
namespace LINGYUN.Abp.Elasticsearch;
public class DefaultExpressionQueryTranslatorTests : ExpressionQueryTranslatorTests<AbpElasticsearchTestModule>
{
}

169
aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/ExpressionQueryService_Tests.cs

@ -0,0 +1,169 @@
using Elastic.Clients.Elasticsearch;
using Shouldly;
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Volo.Abp.Modularity;
using Volo.Abp.Testing;
using Volo.Abp.Threading;
using Xunit;
namespace LINGYUN.Abp.Elasticsearch;
public abstract class ExpressionQueryService_Tests<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");
}
}

1096
aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/ExpressionQueryTranslator_Tests.cs

File diff suppressed because it is too large

43
aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/TestDocument.cs

@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
namespace LINGYUN.Abp.Elasticsearch;
public class TestDocument
{
public int Id { get; set; }
public string? Name { get; set; }
public string? Description { get; set; }
public int Age { get; set; }
public decimal Salary { get; set; }
public bool IsActive { get; set; }
public DateTime CreatedTime { get; set; }
public DateTime? UpdatedTime { get; set; }
public TestEnum Status { get; set; }
public TestEnum StringValueStatus { get; set; }
public TestEnum? NullableStatus { get; set; }
public List<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
}

6
aspnet-core/tests/LINGYUN.Abp.Elasticsearch.Tests/LINGYUN/Abp/Elasticsearch/TestDocumentIndexNames.cs

@ -0,0 +1,6 @@
namespace LINGYUN.Abp.Elasticsearch;
public static class TestDocumentIndexNames
{
public const string Index = "abp-elasticsearch-test";
}
Loading…
Cancel
Save