13 changed files with 1032 additions and 595 deletions
@ -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,5 @@ |
|||||
|
namespace LINGYUN.Abp.Elasticsearch; |
||||
|
|
||||
|
public class DefaultExpressionQueryService_Tests : ExpressionQueryService_Tests<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"); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue