Skip to content

Commit 9f74c0e

Browse files
committed
Adds nested query support
Adds support for nested queries and aggregations within the ElasticQueryParser. This change introduces a NestedVisitor that automatically wraps queries targeting nested properties in NestedQuery objects. It also updates the CombineAggregationsVisitor to properly handle nested aggregations. The DefaultQueryNodeExtensions are modified to handle nested fields when creating default queries. These changes allow users to query and aggregate data within nested objects in Elasticsearch, which was previously unsupported. Also adds extensive tests to validate the new functionality.
1 parent 8347c99 commit 9f74c0e

File tree

7 files changed

+896
-106
lines changed

7 files changed

+896
-106
lines changed

src/Foundatio.Parsers.ElasticQueries/Extensions/DefaultQueryNodeExtensions.cs

Lines changed: 288 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System;
2+
using System.Collections.Generic;
3+
using System.Linq;
24
using System.Threading.Tasks;
35
using Foundatio.Parsers.ElasticQueries.Visitors;
46
using Foundatio.Parsers.LuceneQueries.Extensions;
@@ -32,82 +34,325 @@ public static QueryBase GetDefaultQuery(this TermNode node, IQueryVisitorContext
3234
if (context is not IElasticQueryVisitorContext elasticContext)
3335
throw new ArgumentException("Context must be of type IElasticQueryVisitorContext", nameof(context));
3436

35-
QueryBase query;
3637
string field = node.UnescapedField;
3738
string[] defaultFields = node.GetDefaultFields(elasticContext.DefaultFields);
38-
if (field == null && defaultFields != null && defaultFields.Length == 1)
39-
field = defaultFields[0];
4039

41-
if (elasticContext.MappingResolver.IsPropertyAnalyzed(field))
40+
// If a specific field is set, use single-field query
41+
if (!String.IsNullOrEmpty(field))
42+
return GetSingleFieldQuery(node, field, elasticContext);
43+
44+
// If only one default field, use single-field query
45+
if (defaultFields != null && defaultFields.Length == 1)
46+
return GetSingleFieldQuery(node, defaultFields[0], elasticContext);
47+
48+
// Multiple default fields - check if any are nested
49+
if (defaultFields != null && defaultFields.Length > 1)
4250
{
43-
string[] fields = !String.IsNullOrEmpty(field) ? [field] : defaultFields;
51+
// Group fields by nested path (empty string for non-nested)
52+
var fieldsByNestedPath = GroupFieldsByNestedPath(defaultFields, elasticContext);
53+
54+
// If all fields are non-nested (single group with empty key), use multi_match
55+
if (fieldsByNestedPath.Count == 1 && fieldsByNestedPath.ContainsKey(String.Empty))
56+
{
57+
return GetMultiFieldQuery(node, defaultFields, elasticContext);
58+
}
4459

60+
// Otherwise, split into separate queries for each group
61+
return GetSplitNestedQuery(node, fieldsByNestedPath, elasticContext);
62+
}
63+
64+
// Fallback for no fields
65+
return GetMultiFieldQuery(node, defaultFields, elasticContext);
66+
}
67+
68+
private static QueryBase GetSingleFieldQuery(TermNode node, string field, IElasticQueryVisitorContext context)
69+
{
70+
if (context.MappingResolver.IsPropertyAnalyzed(field))
71+
{
4572
if (!node.IsQuotedTerm && node.UnescapedTerm.EndsWith("*"))
4673
{
47-
query = new QueryStringQuery
74+
return new QueryStringQuery
4875
{
49-
Fields = fields,
76+
Fields = Infer.Fields(field),
5077
AllowLeadingWildcard = false,
5178
AnalyzeWildcard = true,
5279
Query = node.UnescapedTerm
5380
};
5481
}
55-
else
82+
83+
if (node.IsQuotedTerm)
5684
{
57-
if (fields != null && fields.Length == 1)
85+
return new MatchPhraseQuery
5886
{
59-
if (node.IsQuotedTerm)
60-
{
61-
query = new MatchPhraseQuery
62-
{
63-
Field = fields[0],
64-
Query = node.UnescapedTerm
65-
};
66-
}
67-
else
68-
{
69-
query = new MatchQuery
70-
{
71-
Field = fields[0],
72-
Query = node.UnescapedTerm
73-
};
74-
}
75-
}
76-
else
77-
{
78-
query = new MultiMatchQuery
79-
{
80-
Fields = fields,
81-
Query = node.UnescapedTerm
82-
};
83-
if (node.IsQuotedTerm)
84-
((MultiMatchQuery)query).Type = TextQueryType.Phrase;
85-
}
87+
Field = field,
88+
Query = node.UnescapedTerm
89+
};
8690
}
91+
92+
return new MatchQuery
93+
{
94+
Field = field,
95+
Query = node.UnescapedTerm
96+
};
8797
}
88-
else
98+
99+
if (!node.IsQuotedTerm && node.UnescapedTerm.EndsWith("*"))
100+
{
101+
return new PrefixQuery
102+
{
103+
Field = field,
104+
Value = node.UnescapedTerm.TrimEnd('*')
105+
};
106+
}
107+
108+
// For non-analyzed fields, try to convert value to appropriate type
109+
object termValue = GetTypedValue(node.UnescapedTerm, field, context);
110+
111+
return new TermQuery
112+
{
113+
Field = field,
114+
Value = termValue
115+
};
116+
}
117+
118+
private static object GetTypedValue(string value, string field, IElasticQueryVisitorContext context)
119+
{
120+
var fieldType = context.MappingResolver.GetFieldType(field);
121+
122+
return fieldType switch
123+
{
124+
FieldType.Integer or FieldType.Short or FieldType.Byte when Int32.TryParse(value, out int intValue) => intValue,
125+
FieldType.Long when Int64.TryParse(value, out long longValue) => longValue,
126+
FieldType.Float or FieldType.HalfFloat when Single.TryParse(value, out float floatValue) => floatValue,
127+
FieldType.Double or FieldType.ScaledFloat when Double.TryParse(value, out double doubleValue) => doubleValue,
128+
FieldType.Boolean when Boolean.TryParse(value, out bool boolValue) => boolValue,
129+
_ => value // Return as string for other types (keyword, date, ip, etc.)
130+
};
131+
}
132+
133+
private static QueryBase GetMultiFieldQuery(TermNode node, string[] fields, IElasticQueryVisitorContext context)
134+
{
135+
// Handle null or empty fields - use default multi_match behavior
136+
if (fields == null || fields.Length == 0)
89137
{
138+
var defaultQuery = new MultiMatchQuery
139+
{
140+
Fields = fields,
141+
Query = node.UnescapedTerm
142+
};
143+
if (node.IsQuotedTerm)
144+
defaultQuery.Type = TextQueryType.Phrase;
145+
return defaultQuery;
146+
}
147+
148+
// Split fields by analyzed vs non-analyzed
149+
var analyzedFields = new List<string>();
150+
var nonAnalyzedFields = new List<string>();
151+
152+
foreach (string field in fields)
153+
{
154+
if (context.MappingResolver.IsPropertyAnalyzed(field))
155+
analyzedFields.Add(field);
156+
else
157+
nonAnalyzedFields.Add(field);
158+
}
159+
160+
// If all fields are of the same type, use simple query
161+
if (nonAnalyzedFields.Count == 0)
162+
{
163+
return GetAnalyzedFieldsQuery(node, analyzedFields.ToArray());
164+
}
165+
166+
if (analyzedFields.Count == 0)
167+
{
168+
return GetNonAnalyzedFieldsQuery(node, nonAnalyzedFields, context);
169+
}
170+
171+
// Mixed types - combine with bool should
172+
var queries = new List<QueryBase>();
173+
174+
// Add query for analyzed fields
175+
queries.Add(GetAnalyzedFieldsQuery(node, analyzedFields.ToArray()));
176+
177+
// Add individual queries for non-analyzed fields
178+
foreach (string field in nonAnalyzedFields)
179+
{
180+
queries.Add(GetSingleFieldQuery(node, field, context));
181+
}
182+
183+
return new BoolQuery
184+
{
185+
Should = queries.Select(q => (QueryContainer)q).ToList()
186+
};
187+
}
188+
189+
private static QueryBase GetAnalyzedFieldsQuery(TermNode node, string[] fields)
190+
{
191+
// For a single field, use match query instead of multi_match
192+
if (fields.Length == 1)
193+
{
194+
string field = fields[0];
90195
if (!node.IsQuotedTerm && node.UnescapedTerm.EndsWith("*"))
91196
{
92-
query = new PrefixQuery
197+
return new QueryStringQuery
93198
{
94-
Field = field,
95-
Value = node.UnescapedTerm.TrimEnd('*')
199+
Fields = Infer.Fields(field),
200+
AllowLeadingWildcard = false,
201+
AnalyzeWildcard = true,
202+
Query = node.UnescapedTerm
96203
};
97204
}
98-
else
205+
206+
if (node.IsQuotedTerm)
99207
{
100-
query = new TermQuery
208+
return new MatchPhraseQuery
101209
{
102210
Field = field,
103-
Value = node.UnescapedTerm
211+
Query = node.UnescapedTerm
104212
};
105213
}
214+
215+
return new MatchQuery
216+
{
217+
Field = field,
218+
Query = node.UnescapedTerm
219+
};
220+
}
221+
222+
// Multiple fields - use multi_match
223+
if (!node.IsQuotedTerm && node.UnescapedTerm.EndsWith("*"))
224+
{
225+
return new QueryStringQuery
226+
{
227+
Fields = fields,
228+
AllowLeadingWildcard = false,
229+
AnalyzeWildcard = true,
230+
Query = node.UnescapedTerm
231+
};
106232
}
107233

234+
var query = new MultiMatchQuery
235+
{
236+
Fields = fields,
237+
Query = node.UnescapedTerm
238+
};
239+
if (node.IsQuotedTerm)
240+
query.Type = TextQueryType.Phrase;
241+
108242
return query;
109243
}
110244

245+
private static QueryBase GetNonAnalyzedFieldsQuery(TermNode node, List<string> fields, IElasticQueryVisitorContext context)
246+
{
247+
// For a single non-analyzed field, use single query
248+
if (fields.Count == 1)
249+
return GetSingleFieldQuery(node, fields[0], context);
250+
251+
// Multiple non-analyzed fields - combine with bool should
252+
var queries = fields.Select(f => GetSingleFieldQuery(node, f, context)).ToList();
253+
return new BoolQuery
254+
{
255+
Should = queries.Select(q => (QueryContainer)q).ToList()
256+
};
257+
}
258+
259+
private static Dictionary<string, List<string>> GroupFieldsByNestedPath(string[] fields, IElasticQueryVisitorContext context)
260+
{
261+
var result = new Dictionary<string, List<string>>();
262+
263+
foreach (string field in fields)
264+
{
265+
// Use empty string for non-nested fields, actual path for nested
266+
string nestedPath = GetNestedPath(field, context) ?? String.Empty;
267+
268+
if (!result.ContainsKey(nestedPath))
269+
result[nestedPath] = new List<string>();
270+
271+
result[nestedPath].Add(field);
272+
}
273+
274+
return result;
275+
}
276+
277+
private static string GetNestedPath(string fullName, IElasticQueryVisitorContext context)
278+
{
279+
string[] nameParts = fullName?.Split('.').ToArray();
280+
281+
if (nameParts == null || nameParts.Length == 0)
282+
return null;
283+
284+
string fieldName = String.Empty;
285+
for (int i = 0; i < nameParts.Length; i++)
286+
{
287+
if (i > 0)
288+
fieldName += ".";
289+
290+
fieldName += nameParts[i];
291+
292+
if (context.MappingResolver.IsNestedPropertyType(fieldName))
293+
return fieldName;
294+
}
295+
296+
return null;
297+
}
298+
299+
private static QueryBase GetSplitNestedQuery(TermNode node, Dictionary<string, List<string>> fieldsByNestedPath, IElasticQueryVisitorContext context)
300+
{
301+
var queryContainers = new List<QueryContainer>();
302+
303+
foreach (var (nestedPath, fields) in fieldsByNestedPath)
304+
{
305+
QueryBase query;
306+
307+
if (fields.Count == 1)
308+
{
309+
query = GetSingleFieldQuery(node, fields[0], context);
310+
}
311+
else
312+
{
313+
query = GetMultiFieldQuery(node, fields.ToArray(), context);
314+
}
315+
316+
// Wrap in NestedQuery if this is a nested path (non-empty string)
317+
if (!String.IsNullOrEmpty(nestedPath))
318+
{
319+
queryContainers.Add(new NestedQuery
320+
{
321+
Path = nestedPath,
322+
Query = query
323+
});
324+
}
325+
else
326+
{
327+
// For non-nested fields, flatten BoolQuery should clauses if present
328+
if (query is BoolQuery boolQuery && boolQuery.Should != null)
329+
{
330+
foreach (var shouldClause in boolQuery.Should)
331+
{
332+
queryContainers.Add(shouldClause);
333+
}
334+
}
335+
else
336+
{
337+
queryContainers.Add(query);
338+
}
339+
}
340+
}
341+
342+
// Combine with OR (should)
343+
if (queryContainers.Count == 1)
344+
{
345+
// Try to unwrap single QueryContainer to QueryBase
346+
// Can't directly cast, so return in a minimal BoolQuery
347+
return new BoolQuery { Should = queryContainers };
348+
}
349+
350+
return new BoolQuery
351+
{
352+
Should = queryContainers
353+
};
354+
}
355+
111356
public static async Task<QueryBase> GetDefaultQueryAsync(this TermRangeNode node, IQueryVisitorContext context)
112357
{
113358
if (context is not IElasticQueryVisitorContext elasticContext)

src/Foundatio.Parsers.ElasticQueries/Extensions/QueryNodeExtensions.cs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,4 +83,18 @@ public static void RemoveSort(this IQueryNode node)
8383
{
8484
node.Data.Remove(SortKey);
8585
}
86+
87+
private const string NestedPathKey = "@NestedPath";
88+
public static string GetNestedPath(this IQueryNode node)
89+
{
90+
if (!node.Data.TryGetValue(NestedPathKey, out object value))
91+
return null;
92+
93+
return value as string;
94+
}
95+
96+
public static void SetNestedPath(this IQueryNode node, string path)
97+
{
98+
node.Data[NestedPathKey] = path;
99+
}
86100
}

0 commit comments

Comments
 (0)