Skip to content

Commit cb55935

Browse files
authored
refactor: Proper AST Parsing for Elastic DSL (#293)
* Initial updated parsing utilities w/ new AST module (Elastic DSL). * Add modded opensearch-dsl crate Cargo.toml. * Cargo format. * Export updated DSL utilities and remove old code. Rename ElasticDSLQuery -> ElasticDSLQueryInfo to reflect its role as query metadata carrier. * Fix issue where ES DSL parser does not extract predicates (e.g. filter for time range query) when it is not enclosed in a bool context. Refactor Elastic DSL handler to use new parsing utilities. * Cargo fmt and clippy. Point elastic_dsl_utilities to custom copy of opensearch_dsl. * Point elastic_dsl_utilities to renamed DSL parser crate.
1 parent 244d579 commit cb55935

13 files changed

Lines changed: 750 additions & 1201 deletions

File tree

Cargo.lock

Lines changed: 12 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

asap-common/dependencies/rs/elastic_dsl_utilities/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@ version.workspace = true
77
serde.workspace = true
88
serde_json.workspace = true
99
chrono.workspace = true
10+
elasticsearch-dsl-ast = { version = "0.1.0", git = "https://github.com/ProjectASAP/elasticsearch-dsl-ast" }
Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
use crate::ast_parsing::query_info::{
2+
AggregationType, ElasticDSLQueryInfo, FieldName, GroupBySpec, Predicate, TermValue,
3+
};
4+
use crate::helpers::strip_keyword_suffix;
5+
use elasticsearch_dsl_ast::{self as dsl};
6+
use serde_json;
7+
8+
pub fn extract_query_info(query: &str) -> Option<ElasticDSLQueryInfo> {
9+
// Main entry point for extracting relevant information from the parsed query pattern.
10+
let search_request = serde_json::from_str(query).ok()?;
11+
walk_ast_and_extract_info(&search_request)
12+
}
13+
14+
pub fn parse_query_to_ast(query: &str) -> Option<dsl::Search> {
15+
serde_json::from_str(query).ok()?
16+
}
17+
18+
pub fn walk_ast_and_extract_info(ast: &dsl::Search) -> Option<ElasticDSLQueryInfo> {
19+
// Traverses the AST and extracts relevant information for answering sketchable aggregations within ASAPQuery.
20+
// This would involve traversing the AST nodes and applying logic to determine query patterns, labels, statistics, etc.
21+
let query = ast.query.clone();
22+
let predicates = match query {
23+
Some(dsl::Query::Bool(bool_query)) => {
24+
// Extract information from the bool query
25+
walk_bool_query_and_extract_info(&bool_query)
26+
}
27+
Some(other) => {
28+
// Predicates may just be specified directly without enclosing bool context.
29+
if let Some(predicate) = extract_predicates_from_query(&other) {
30+
vec![predicate]
31+
} else {
32+
Vec::new()
33+
}
34+
}
35+
None => Vec::new(), // Return an empty vector of predicates if no query is specified
36+
};
37+
let (target_field, aggregation_type, group_by_spec) =
38+
walk_aggregations_and_extract_info(&ast.aggs)?;
39+
Some(ElasticDSLQueryInfo::new(
40+
target_field,
41+
predicates,
42+
group_by_spec,
43+
aggregation_type,
44+
))
45+
}
46+
47+
fn walk_bool_query_and_extract_info(bool_query: &dsl::BoolQuery) -> Vec<Predicate> {
48+
// Placeholder for walking the filter context of the AST and extracting relevant information
49+
// This would involve traversing the filter nodes and applying logic to determine label filters, time ranges, etc.
50+
let dsl::QueryCollection(filters) = bool_query.filter.clone();
51+
let mut predicates = Vec::new();
52+
for query in filters {
53+
if let Some(predicate) = extract_predicates_from_query(&query) {
54+
predicates.push(predicate);
55+
}
56+
}
57+
predicates
58+
}
59+
60+
fn extract_predicates_from_query(query: &dsl::Query) -> Option<Predicate> {
61+
// Extract predicate information from a given query node, if it matches supported patterns (term or range queries).
62+
match query {
63+
dsl::Query::Term(term_query) => {
64+
// Extract information from the term query
65+
let field = strip_keyword_suffix(&term_query.field).to_owned();
66+
let Some(value) = term_query.value.clone() else {
67+
return None; // Skip if term query does not have a value
68+
};
69+
let Some(term_value) = map_term_to_json_value(&value) else {
70+
return None; // Skip if term query value cannot be mapped to a JSON value
71+
};
72+
// Process the term query information as needed
73+
Some(Predicate::Term {
74+
field,
75+
value: term_value,
76+
})
77+
}
78+
79+
dsl::Query::Range(range_query) => {
80+
// Extract information from the range query
81+
let field = strip_keyword_suffix(&range_query.field).to_owned();
82+
let gte = range_query.gte.clone();
83+
let lte = range_query.lte.clone();
84+
// Process the range query information as needed
85+
let gte_value = gte.as_ref().and_then(map_term_to_json_value);
86+
let lte_value = lte.as_ref().and_then(map_term_to_json_value);
87+
Some(Predicate::Range {
88+
field,
89+
gte: gte_value,
90+
lte: lte_value,
91+
})
92+
}
93+
_ => {
94+
// Handle other query types
95+
None // Skip unsupported query types
96+
}
97+
}
98+
}
99+
100+
fn walk_aggregations_and_extract_info(
101+
aggregations: &dsl::Aggregations,
102+
) -> Option<(FieldName, AggregationType, Option<GroupBySpec>)> {
103+
// Traverse the aggregations in the AST and extracting relevant information. Extract the first valid aggregation type found, along with any associated group by specifications.
104+
for agg in aggregations.values() {
105+
match agg {
106+
dsl::Aggregation::MultiTerms(terms_agg) => {
107+
// Extract information from the terms aggregation
108+
let field_names: Vec<String> = terms_agg
109+
.multi_terms
110+
.terms
111+
.iter()
112+
.filter_map(|multi_term| multi_term.field.clone())
113+
.collect();
114+
let field_names: Vec<String> = field_names
115+
.iter()
116+
.map(|s| strip_keyword_suffix(s).to_owned())
117+
.collect();
118+
if field_names.is_empty() {
119+
return None; // Return None if no valid field names are found in the multi-terms aggregation.
120+
}
121+
let group_by_spec = Some(GroupBySpec::Fields(field_names));
122+
let (target_field, aggregation_type) =
123+
find_aggregation_info(&terms_agg.aggs.clone())?;
124+
return Some((target_field, aggregation_type, group_by_spec));
125+
}
126+
dsl::Aggregation::Terms(terms_agg) => {
127+
// Extract information from the terms aggregation
128+
if let Some(field) = terms_agg.terms.field.clone() {
129+
let field = strip_keyword_suffix(&field).to_owned();
130+
// Process the terms aggregation information as needed
131+
let group_by_spec = Some(GroupBySpec::Fields(vec![field]));
132+
let (target_field, aggregation_type) =
133+
find_aggregation_info(&terms_agg.aggs.clone())?;
134+
return Some((target_field, aggregation_type, group_by_spec));
135+
}
136+
}
137+
other => {
138+
// Handle other aggregation types
139+
let (target_field, aggregation_type) = extract_aggregation_info(other)?;
140+
return Some((target_field, aggregation_type, None));
141+
}
142+
}
143+
}
144+
None // Return None if no relevant aggregation information is found
145+
}
146+
147+
fn find_aggregation_info(aggregations: &dsl::Aggregations) -> Option<(FieldName, AggregationType)> {
148+
// Placeholder for extracting specific information from an aggregation node
149+
if let Some((_, agg)) = aggregations.iter().next() {
150+
let (field, aggregation_type) = extract_aggregation_info(agg)?;
151+
return Some((field, aggregation_type));
152+
}
153+
None // Return None if no relevant aggregation information is found
154+
}
155+
156+
fn extract_aggregation_info(agg: &dsl::Aggregation) -> Option<(FieldName, AggregationType)> {
157+
// Extracts the specific aggregation type and target field from the given aggregation node, if it matches supported types (avg, sum, min, max, percentiles).
158+
match agg {
159+
dsl::Aggregation::Avg(avg_agg) => {
160+
let field = strip_keyword_suffix(&avg_agg.avg.field).to_owned();
161+
let aggregation_type = AggregationType::Avg;
162+
Some((field, aggregation_type))
163+
}
164+
dsl::Aggregation::Sum(sum_agg) => {
165+
let field = strip_keyword_suffix(&sum_agg.sum.field.clone()?).to_owned();
166+
let aggregation_type = AggregationType::Sum;
167+
Some((field, aggregation_type))
168+
}
169+
dsl::Aggregation::Min(min_agg) => {
170+
let field = strip_keyword_suffix(&min_agg.min.field.clone()?).to_owned();
171+
let aggregation_type = AggregationType::Min;
172+
Some((field, aggregation_type))
173+
}
174+
dsl::Aggregation::Max(max_agg) => {
175+
let field = strip_keyword_suffix(&max_agg.max.field.clone()?).to_owned();
176+
let aggregation_type = AggregationType::Max;
177+
Some((field, aggregation_type))
178+
}
179+
dsl::Aggregation::Percentiles(percentiles_agg) => {
180+
let field = percentiles_agg.percentiles.field.clone();
181+
let percents = percentiles_agg
182+
.percentiles
183+
.percents
184+
.clone()
185+
.unwrap_or_default();
186+
let aggregation_type = AggregationType::Percentiles(percents);
187+
Some((field, aggregation_type))
188+
}
189+
_ => None, // Return None for unsupported aggregation types
190+
}
191+
}
192+
193+
fn map_term_to_json_value(term: &dsl::Term) -> Option<TermValue> {
194+
// Placeholder for extracting field and value from a term query
195+
match term {
196+
dsl::Term::String(value) => {
197+
let value_str = value.to_string(); // Convert the term value to a string representation
198+
Some(TermValue::String(value_str))
199+
}
200+
dsl::Term::Float32(value) => Some(TermValue::Float(*value as f64)),
201+
dsl::Term::Float64(value) => Some(TermValue::Float(*value)),
202+
dsl::Term::PositiveNumber(value) => Some(TermValue::UnsignedInt(*value)),
203+
dsl::Term::NegativeNumber(value) => Some(TermValue::Int(*value)),
204+
dsl::Term::Boolean(value) => Some(TermValue::Boolean(*value)),
205+
}
206+
}
207+
208+
#[cfg(test)]
209+
mod tests {
210+
use super::*;
211+
212+
#[test]
213+
fn parse_query_to_ast_parses_valid_search_request() {
214+
let query = r#"
215+
{
216+
"query": {
217+
"bool": {
218+
"filter": [
219+
{ "term": { "service.keyword": { "value": "frontend" } } }
220+
]
221+
}
222+
},
223+
"aggs": {
224+
"avg_latency": { "avg": { "field": "latency_ms" } }
225+
}
226+
}
227+
"#;
228+
229+
let ast = parse_query_to_ast(query);
230+
assert!(ast.is_some());
231+
}
232+
233+
#[test]
234+
fn parse_query_to_ast_returns_none_for_invalid_json() {
235+
let query = r#"{ "query": { "bool": { "filter": [ } }"#;
236+
assert!(parse_query_to_ast(query).is_none());
237+
}
238+
239+
#[test]
240+
fn walk_bool_query_and_extract_info_extracts_term_and_range_predicates() {
241+
let bool_query = dsl::Query::bool()
242+
.filter(dsl::Query::term("service.keyword", "frontend"))
243+
.filter(dsl::Query::term("is_canary", true))
244+
.filter(dsl::Query::range("@timestamp").gte("now-30s").lte("now"));
245+
246+
let predicates = walk_bool_query_and_extract_info(&bool_query);
247+
assert_eq!(predicates.len(), 3);
248+
assert_eq!(
249+
predicates[0],
250+
Predicate::Term {
251+
field: "service".to_string(),
252+
value: TermValue::String("frontend".to_string()),
253+
}
254+
);
255+
assert_eq!(
256+
predicates[1],
257+
Predicate::Term {
258+
field: "is_canary".to_string(),
259+
value: TermValue::Boolean(true),
260+
}
261+
);
262+
assert_eq!(
263+
predicates[2],
264+
Predicate::Range {
265+
field: "@timestamp".to_string(),
266+
gte: Some(TermValue::String("now-30s".to_string())),
267+
lte: Some(TermValue::String("now".to_string())),
268+
}
269+
);
270+
}
271+
272+
#[test]
273+
fn walk_aggregations_and_extract_info_extracts_terms_group_by_and_metric() {
274+
let query = r#"
275+
{
276+
"aggs": {
277+
"by_service": {
278+
"terms": { "field": "service.keyword" },
279+
"aggs": {
280+
"avg_latency": { "avg": { "field": "latency_ms" } }
281+
}
282+
}
283+
}
284+
}
285+
"#;
286+
let ast = parse_query_to_ast(query).expect("query should parse");
287+
288+
let (target_field, agg_type, group_by) =
289+
walk_aggregations_and_extract_info(&ast.aggs).expect("aggregation info should parse");
290+
assert_eq!(target_field, "latency_ms");
291+
assert_eq!(agg_type, AggregationType::Avg);
292+
assert_eq!(
293+
group_by,
294+
Some(GroupBySpec::Fields(vec!["service".to_string()]))
295+
);
296+
}
297+
298+
#[test]
299+
fn walk_aggregations_and_extract_info_extracts_multi_terms_and_percentiles() {
300+
let query = r#"
301+
{
302+
"aggs": {
303+
"by_labels": {
304+
"multi_terms": {
305+
"terms": [
306+
{ "field": "service.keyword" },
307+
{ "field": "env.keyword" }
308+
]
309+
},
310+
"aggs": {
311+
"latency_percentiles": {
312+
"percentiles": {
313+
"field": "latency_ms",
314+
"percents": [50.0, 95.0]
315+
}
316+
}
317+
}
318+
}
319+
}
320+
}
321+
"#;
322+
let ast = parse_query_to_ast(query).expect("query should parse");
323+
324+
let (target_field, agg_type, group_by) =
325+
walk_aggregations_and_extract_info(&ast.aggs).expect("aggregation info should parse");
326+
assert_eq!(target_field, "latency_ms");
327+
assert_eq!(agg_type, AggregationType::Percentiles(vec![50.0, 95.0]));
328+
assert_eq!(
329+
group_by,
330+
Some(GroupBySpec::Fields(vec![
331+
"service".to_string(),
332+
"env".to_string()
333+
]))
334+
);
335+
}
336+
337+
#[test]
338+
fn walk_ast_and_extract_info_builds_elastic_dsl_query() {
339+
let ast = dsl::Search::new()
340+
.query(
341+
dsl::Query::bool()
342+
.filter(dsl::Query::term("service.keyword", "frontend"))
343+
.filter(dsl::Query::range("@timestamp").gte("now-30s").lte("now")),
344+
)
345+
.aggregate(
346+
"by_service",
347+
dsl::Aggregation::terms("service.keyword")
348+
.aggregate("max_latency", dsl::Aggregation::max("latency_ms")),
349+
);
350+
let info = walk_ast_and_extract_info(&ast).expect("info should parse");
351+
352+
assert_eq!(info.target_field, "latency_ms");
353+
assert_eq!(info.aggregation, AggregationType::Max);
354+
assert_eq!(info.predicates.len(), 2);
355+
assert_eq!(
356+
info.group_by_buckets,
357+
Some(GroupBySpec::Fields(vec!["service".to_string()]))
358+
);
359+
}
360+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
pub mod extract_info;
2+
pub mod query_info;
3+
4+
pub use extract_info::*;
5+
pub use query_info::*;

0 commit comments

Comments
 (0)