use super::error::{OpenSearchError, Result};
use super::field_mappings::FieldMappings;
use super::post_processor::PostProcessor;
use super::query_builder::QueryBuilder;
use super::OpenSearchConfig;
use crate::Tql;
use opensearch::OpenSearch;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value as JsonValue};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum MappingSource {
Provided {
field_count: usize,
},
FetchedFromIndex {
index: String,
field_count: usize,
},
None {
reason: String,
},
}
impl Default for MappingSource {
fn default() -> Self {
Self::None {
reason: "Not configured".to_string(),
}
}
}
impl std::fmt::Display for MappingSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
MappingSource::Provided { field_count } => {
write!(f, "Provided ({} fields)", field_count)
}
MappingSource::FetchedFromIndex { index, field_count } => {
write!(f, "FetchedFromIndex '{}' ({} fields)", index, field_count)
}
MappingSource::None { reason } => {
write!(f, "None ({})", reason)
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum TrackTotalHits {
#[default]
Exact,
UpTo(u64),
}
impl TrackTotalHits {
fn to_json(self) -> JsonValue {
match self {
Self::Exact => json!(true),
Self::UpTo(n) => json!(n),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum TotalRelation {
Eq,
#[default]
Gte,
}
impl TotalRelation {
pub fn as_wire_str(self) -> &'static str {
match self {
Self::Eq => "eq",
Self::Gte => "gte",
}
}
fn from_wire(s: &str) -> Self {
if s.eq_ignore_ascii_case("eq") {
Self::Eq
} else {
Self::Gte
}
}
}
fn apply_total_tracking(body: &mut JsonValue, options: &ExecuteOptions) {
body["track_total_hits"] = options.track_total_hits.to_json();
}
fn parse_hits_total(response_body: &JsonValue) -> (usize, TotalRelation) {
let Some(total) = response_body.get("hits").and_then(|h| h.get("total")) else {
return (0, TotalRelation::Eq);
};
match total {
JsonValue::Object(obj) => (
obj.get("value").and_then(|v| v.as_u64()).unwrap_or(0) as usize,
obj.get("relation")
.and_then(|r| r.as_str())
.map_or(TotalRelation::Eq, TotalRelation::from_wire),
),
other => (other.as_u64().unwrap_or(0) as usize, TotalRelation::Eq),
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecuteResult {
pub results: Vec<JsonValue>,
pub total: usize,
pub opensearch_total: usize,
#[serde(default)]
pub opensearch_total_relation: TotalRelation,
pub post_processing_applied: bool,
pub health_status: String,
pub health_reasons: Vec<String>,
pub scan_info: Option<ScanInfo>,
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub dsl_query: Option<JsonValue>,
#[serde(default)]
pub mapping_source: MappingSource,
#[serde(skip_serializing_if = "Option::is_none")]
pub aggregations: Option<JsonValue>,
}
impl Default for ExecuteResult {
fn default() -> Self {
Self {
results: Vec::new(),
total: 0,
opensearch_total: 0,
opensearch_total_relation: TotalRelation::default(),
post_processing_applied: false,
health_status: "green".to_string(),
health_reasons: Vec::new(),
scan_info: None,
error: None,
dsl_query: None,
mapping_source: MappingSource::default(),
aggregations: None,
}
}
}
impl ExecuteResult {
pub fn error(message: impl Into<String>) -> Self {
let msg = message.into();
Self {
health_status: "red".to_string(),
health_reasons: vec![msg.clone()],
error: Some(msg),
opensearch_total_relation: TotalRelation::Eq,
..Default::default()
}
}
pub fn opensearch_total_is_exact(&self) -> bool {
self.opensearch_total_relation == TotalRelation::Eq
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScanInfo {
pub batches: usize,
pub total_scrolled: usize,
pub scroll_size: usize,
pub scroll_used: bool,
}
#[derive(Debug, Clone)]
pub struct ExecuteOptions {
pub scan_all: bool,
pub scroll_size: usize,
pub scroll_timeout: String,
pub sort: Option<Vec<JsonValue>>,
pub time_range: Option<TimeRange>,
pub timestamp_field: String,
pub size: usize,
pub track_total_hits: TrackTotalHits,
}
impl Default for ExecuteOptions {
fn default() -> Self {
Self {
scan_all: false,
scroll_size: 10000,
scroll_timeout: "5m".to_string(),
sort: None,
time_range: None,
timestamp_field: "@timestamp".to_string(),
size: 10000,
track_total_hits: TrackTotalHits::default(),
}
}
}
impl ExecuteOptions {
pub fn with_scan_all(mut self, scan_all: bool) -> Self {
self.scan_all = scan_all;
self
}
pub fn with_scroll_size(mut self, size: usize) -> Self {
self.scroll_size = size;
self
}
pub fn with_scroll_timeout(mut self, timeout: impl Into<String>) -> Self {
self.scroll_timeout = timeout.into();
self
}
pub fn with_time_range(mut self, gte: impl Into<String>, lt: impl Into<String>) -> Self {
self.time_range = Some(TimeRange {
gte: gte.into(),
lt: lt.into(),
});
self
}
pub fn with_timestamp_field(mut self, field: impl Into<String>) -> Self {
self.timestamp_field = field.into();
self
}
pub fn with_sort(mut self, sort: Vec<JsonValue>) -> Self {
self.sort = Some(sort);
self
}
pub fn with_size(mut self, size: usize) -> Self {
self.size = size;
self
}
pub fn with_track_total_hits(mut self, track: TrackTotalHits) -> Self {
self.track_total_hits = track;
self
}
}
#[derive(Debug, Clone)]
pub struct TimeRange {
pub gte: String,
pub lt: String,
}
pub struct TqlExecutor {
client: OpenSearch,
tql: Tql,
query_builder: QueryBuilder,
post_processor: PostProcessor,
field_mappings: Option<FieldMappings>,
mapping_source: MappingSource,
}
impl TqlExecutor {
pub fn new(config: OpenSearchConfig) -> Result<Self> {
let client = config.create_client()?;
Ok(Self {
client,
tql: Tql::new(),
query_builder: QueryBuilder::new(None),
post_processor: PostProcessor::new(),
field_mappings: None,
mapping_source: MappingSource::default(),
})
}
pub fn from_client(client: OpenSearch) -> Self {
Self {
client,
tql: Tql::new(),
query_builder: QueryBuilder::new(None),
post_processor: PostProcessor::new(),
field_mappings: None,
mapping_source: MappingSource::default(),
}
}
pub fn with_field_mappings(mut self, mappings: FieldMappings) -> Self {
let field_count = mappings.len();
self.field_mappings = Some(mappings.clone());
self.query_builder = QueryBuilder::new(Some(mappings));
self.mapping_source = MappingSource::Provided { field_count };
self
}
pub async fn with_mappings_from_index(mut self, index: &str) -> Self {
match self.fetch_field_mappings(index).await {
Ok(mappings) => {
let field_count = mappings.len();
self.field_mappings = Some(mappings.clone());
self.query_builder = QueryBuilder::new(Some(mappings));
self.mapping_source = MappingSource::FetchedFromIndex {
index: index.to_string(),
field_count,
};
}
Err(e) => {
self.mapping_source = MappingSource::None {
reason: format!("Failed to fetch from '{}': {}", index, e),
};
tracing::warn!(
"Failed to fetch field mappings for index '{}': {}. Using default query generation.",
index, e
);
}
}
self
}
pub fn get_mapping_source(&self) -> &MappingSource {
&self.mapping_source
}
pub async fn fetch_field_mappings(&self, index: &str) -> Result<FieldMappings> {
let response = self
.client
.indices()
.get_mapping(opensearch::indices::IndicesGetMappingParts::Index(&[index]))
.send()
.await
.map_err(|e| OpenSearchError::MappingError(e.to_string()))?;
let response_body = response.json::<JsonValue>().await.map_err(|e| {
OpenSearchError::MappingError(format!("Failed to parse mapping response: {}", e))
})?;
FieldMappings::from_opensearch_response(response_body)
.map_err(|e| OpenSearchError::MappingError(format!("Failed to parse mappings: {}", e)))
}
pub async fn execute_opensearch(
&self,
query: &str,
index: &str,
options: ExecuteOptions,
) -> Result<ExecuteResult> {
let ast = match self.tql.parse(query) {
Ok(ast) => ast,
Err(e) => {
return Ok(ExecuteResult::error(format!("Failed to parse TQL: {}", e)));
}
};
let needs_post_processing = self.tql.ast_has_post_processing_mutators(&ast);
let use_scroll = options.scan_all || needs_post_processing;
let dsl_query = match self.query_builder.build_query(&ast) {
Ok(q) => q,
Err(e) => {
return Ok(ExecuteResult::error(format!(
"Failed to build DSL query: {}",
e
)));
}
};
let final_query = self.add_time_range_filter(dsl_query, &options);
tracing::debug!(
"Generated OpenSearch DSL:\n{}",
serde_json::to_string_pretty(&final_query)
.unwrap_or_else(|_| "Failed to serialize".to_string())
);
if use_scroll {
self.execute_with_scroll(query, index, final_query, options, needs_post_processing)
.await
} else {
self.execute_simple(query, index, final_query, options, needs_post_processing)
.await
}
}
fn add_time_range_filter(&self, mut query: JsonValue, options: &ExecuteOptions) -> JsonValue {
if let Some(ref time_range) = options.time_range {
let range_filter = json!({
"range": {
&options.timestamp_field: {
"gte": &time_range.gte,
"lt": &time_range.lt,
"format": "strict_date_optional_time"
}
}
});
if let Some(query_obj) = query.get_mut("query") {
if let Some(bool_query) = query_obj.get_mut("bool") {
if let Some(filter_arr) = bool_query.get_mut("filter") {
if let Some(arr) = filter_arr.as_array_mut() {
arr.push(range_filter);
}
} else {
bool_query["filter"] = json!([range_filter]);
}
} else {
let existing = query_obj.take();
*query_obj = json!({
"bool": {
"must": [existing],
"filter": [range_filter]
}
});
}
} else {
query["query"] = json!({
"bool": {
"filter": [range_filter]
}
});
}
}
query
}
async fn execute_with_scroll(
&self,
tql_query: &str,
index: &str,
query: JsonValue,
options: ExecuteOptions,
needs_post_processing: bool,
) -> Result<ExecuteResult> {
let mut all_hits: Vec<JsonValue> = Vec::new();
let mut batches = 0;
let mut scroll_query = query.clone();
scroll_query["size"] = json!(options.scroll_size);
scroll_query["track_total_hits"] = TrackTotalHits::Exact.to_json();
if let Some(ref sort) = options.sort {
scroll_query["sort"] = json!(sort);
} else {
scroll_query["sort"] = json!([{&options.timestamp_field: {"order": "desc"}}]);
}
let response = self
.client
.search(opensearch::SearchParts::Index(&[index]))
.scroll(&options.scroll_timeout)
.body(scroll_query)
.send()
.await
.map_err(|e| OpenSearchError::SearchError(e.to_string()))?;
let response_body = response.json::<JsonValue>().await.map_err(|e| {
OpenSearchError::SearchError(format!("Failed to parse response: {}", e))
})?;
if let Some(error) = response_body.get("error") {
return Ok(ExecuteResult::error(format!("OpenSearch error: {}", error)));
}
let mut scroll_id = response_body
.get("_scroll_id")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
let (opensearch_total, opensearch_total_relation) = parse_hits_total(&response_body);
if let Some(hits) = response_body
.get("hits")
.and_then(|h| h.get("hits"))
.and_then(|h| h.as_array())
{
all_hits.extend(hits.clone());
batches += 1;
}
while let Some(ref current_scroll_id) = scroll_id {
let last_batch_size = response_body
.get("hits")
.and_then(|h| h.get("hits"))
.and_then(|h| h.as_array())
.map(|a| a.len())
.unwrap_or(0);
if last_batch_size == 0 {
break;
}
let scroll_response = self
.client
.scroll(opensearch::ScrollParts::None)
.scroll(&options.scroll_timeout)
.body(json!({
"scroll_id": current_scroll_id
}))
.send()
.await
.map_err(|e| OpenSearchError::ScrollError(e.to_string()))?;
let scroll_body = scroll_response.json::<JsonValue>().await.map_err(|e| {
OpenSearchError::ScrollError(format!("Failed to parse scroll response: {}", e))
})?;
if scroll_body.get("error").is_some() {
break;
}
let hits = scroll_body
.get("hits")
.and_then(|h| h.get("hits"))
.and_then(|h| h.as_array());
match hits {
Some(batch) if !batch.is_empty() => {
all_hits.extend(batch.clone());
batches += 1;
scroll_id = scroll_body
.get("_scroll_id")
.and_then(|s| s.as_str())
.map(|s| s.to_string());
}
_ => {
break;
}
}
}
if let Some(ref final_scroll_id) = scroll_id {
let _ = self
.client
.clear_scroll(opensearch::ClearScrollParts::None)
.body(json!({ "scroll_id": [final_scroll_id] }))
.send()
.await;
}
let source_docs: Vec<JsonValue> = all_hits
.iter()
.filter_map(|hit| {
let mut doc = hit.get("_source").cloned()?;
if let Some(obj) = doc.as_object_mut() {
if let Some(id) = hit.get("_id") {
obj.insert("_id".to_string(), id.clone());
}
if let Some(score) = hit.get("_score") {
obj.insert("_score".to_string(), score.clone());
}
}
Some(doc)
})
.collect();
let (final_results, post_processing_applied) = if needs_post_processing {
(
self.post_processor
.process_results(source_docs, tql_query)?,
true,
)
} else {
(source_docs, false)
};
let total = final_results.len();
let opensearch_total_relation = if all_hits.len() < opensearch_total {
TotalRelation::Gte
} else {
opensearch_total_relation
};
let (health_status, health_reasons) = if needs_post_processing {
(
"yellow".to_string(),
vec!["Query requires post-processing mutators".to_string()],
)
} else {
("green".to_string(), Vec::new())
};
Ok(ExecuteResult {
results: final_results,
total,
opensearch_total,
opensearch_total_relation,
post_processing_applied,
health_status,
health_reasons,
scan_info: Some(ScanInfo {
batches,
total_scrolled: all_hits.len(),
scroll_size: options.scroll_size,
scroll_used: true,
}),
error: None,
dsl_query: Some(query.clone()),
mapping_source: self.mapping_source.clone(),
aggregations: None, })
}
async fn execute_simple(
&self,
tql_query: &str,
index: &str,
query: JsonValue,
options: ExecuteOptions,
needs_post_processing: bool,
) -> Result<ExecuteResult> {
let original_query = query.clone();
let mut final_query = query;
let is_stats_query = final_query.get("aggs").is_some();
if !is_stats_query {
final_query["size"] = json!(options.size);
}
apply_total_tracking(&mut final_query, &options);
if let Some(ref sort) = options.sort {
final_query["sort"] = json!(sort);
}
let response = self
.client
.search(opensearch::SearchParts::Index(&[index]))
.body(final_query)
.send()
.await
.map_err(|e| OpenSearchError::SearchError(e.to_string()))?;
let response_body = response.json::<JsonValue>().await.map_err(|e| {
OpenSearchError::SearchError(format!("Failed to parse response: {}", e))
})?;
if let Some(error) = response_body.get("error") {
return Ok(ExecuteResult::error(format!("OpenSearch error: {}", error)));
}
let (opensearch_total, opensearch_total_relation) = parse_hits_total(&response_body);
let source_docs: Vec<JsonValue> = response_body
.get("hits")
.and_then(|h| h.get("hits"))
.and_then(|h| h.as_array())
.map(|hits| {
hits.iter()
.filter_map(|hit| {
let mut doc = hit.get("_source").cloned()?;
if let Some(obj) = doc.as_object_mut() {
if let Some(id) = hit.get("_id") {
obj.insert("_id".to_string(), id.clone());
}
if let Some(score) = hit.get("_score") {
obj.insert("_score".to_string(), score.clone());
}
}
Some(doc)
})
.collect()
})
.unwrap_or_default();
let (final_results, post_processing_applied) = if needs_post_processing {
(
self.post_processor
.process_results(source_docs, tql_query)?,
true,
)
} else {
(source_docs, false)
};
let total = final_results.len();
let aggregations = response_body.get("aggregations").cloned();
Ok(ExecuteResult {
results: final_results,
total,
opensearch_total,
opensearch_total_relation,
post_processing_applied,
health_status: "green".to_string(),
health_reasons: Vec::new(),
scan_info: None,
error: None,
dsl_query: Some(original_query),
mapping_source: self.mapping_source.clone(),
aggregations,
})
}
pub fn analyze_query(&self, query: &str) -> QueryAnalysis {
match self.tql.parse(query) {
Ok(ast) => {
let has_post_processing = self.tql.ast_has_post_processing_mutators(&ast);
let mutators = Tql::extract_mutators_from_ast(&ast);
QueryAnalysis {
has_post_processing,
health_status: if has_post_processing {
"yellow".to_string()
} else {
"green".to_string()
},
health_reasons: if has_post_processing {
vec!["Query contains post-processing mutators".to_string()]
} else {
Vec::new()
},
post_processing_mutators: mutators
.into_iter()
.flat_map(|(_, muts)| muts.into_iter().map(|m| m.name))
.collect(),
error: None,
}
}
Err(e) => QueryAnalysis {
has_post_processing: false,
health_status: "red".to_string(),
health_reasons: vec![format!("Parse error: {}", e)],
post_processing_mutators: Vec::new(),
error: Some(e.to_string()),
},
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct QueryAnalysis {
pub has_post_processing: bool,
pub health_status: String,
pub health_reasons: Vec<String>,
pub post_processing_mutators: Vec<String>,
pub error: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_execute_options_default() {
let options = ExecuteOptions::default();
assert!(!options.scan_all);
assert_eq!(options.scroll_size, 10000);
assert_eq!(options.scroll_timeout, "5m");
assert_eq!(options.timestamp_field, "@timestamp");
}
#[test]
fn test_execute_options_builder() {
let options = ExecuteOptions::default()
.with_scan_all(true)
.with_scroll_size(5000)
.with_scroll_timeout("10m")
.with_timestamp_field("timestamp")
.with_time_range("2024-01-01T00:00:00Z", "2024-01-02T00:00:00Z");
assert!(options.scan_all);
assert_eq!(options.scroll_size, 5000);
assert_eq!(options.scroll_timeout, "10m");
assert_eq!(options.timestamp_field, "timestamp");
assert!(options.time_range.is_some());
}
#[test]
fn the_simple_body_asks_for_an_exact_total_by_default() {
let mut body = json!({"query": {"match_all": {}}});
apply_total_tracking(&mut body, &ExecuteOptions::default());
assert_eq!(body["track_total_hits"], json!(true));
}
#[test]
fn a_bounded_count_is_sent_as_the_integer() {
let mut body = json!({"query": {"match_all": {}}});
let opts = ExecuteOptions::default().with_track_total_hits(TrackTotalHits::UpTo(50_000));
apply_total_tracking(&mut body, &opts);
assert_eq!(body["track_total_hits"], json!(50_000));
}
#[test]
fn applying_total_tracking_leaves_the_rest_of_the_body_alone() {
let mut body = json!({"query": {"match_all": {}}, "size": 10, "aggs": {"a": {}}});
apply_total_tracking(&mut body, &ExecuteOptions::default());
assert_eq!(body["size"], json!(10));
assert!(body.get("aggs").is_some());
assert_eq!(body["query"], json!({"match_all": {}}));
}
#[test]
fn a_capped_total_is_read_as_a_floor_not_a_count() {
let (total, relation) =
parse_hits_total(&json!({"hits": {"total": {"value": 10000, "relation": "gte"}}}));
assert_eq!(total, 10_000);
assert_eq!(relation, TotalRelation::Gte);
}
#[test]
fn an_exact_total_is_read_as_exact() {
let (total, relation) =
parse_hits_total(&json!({"hits": {"total": {"value": 11500, "relation": "eq"}}}));
assert_eq!(total, 11_500);
assert_eq!(relation, TotalRelation::Eq);
}
#[test]
fn a_total_with_no_relation_is_exact() {
assert_eq!(
parse_hits_total(&json!({"hits": {"total": {"value": 7}}})),
(7, TotalRelation::Eq)
);
assert_eq!(
parse_hits_total(&json!({"hits": {"total": 7}})),
(7, TotalRelation::Eq)
);
}
#[test]
fn a_missing_total_is_zero_and_exact() {
assert_eq!(
parse_hits_total(&json!({"hits": {}})),
(0, TotalRelation::Eq)
);
assert_eq!(parse_hits_total(&json!({})), (0, TotalRelation::Eq));
}
#[test]
fn an_unrecognised_relation_fails_closed_to_a_floor() {
let (_, relation) = parse_hits_total(
&json!({"hits": {"total": {"value": 5, "relation": "something_new"}}}),
);
assert_eq!(relation, TotalRelation::Gte);
}
#[test]
fn the_wire_spelling_round_trips() {
assert_eq!(TotalRelation::Eq.as_wire_str(), "eq");
assert_eq!(TotalRelation::Gte.as_wire_str(), "gte");
assert_eq!(
serde_json::to_value(TotalRelation::Eq).unwrap(),
json!("eq")
);
assert_eq!(
serde_json::to_value(TotalRelation::Gte).unwrap(),
json!("gte")
);
}
#[test]
fn a_payload_from_an_older_tql_is_not_assumed_exact() {
let mut payload = serde_json::to_value(ExecuteResult {
opensearch_total: 10_000,
opensearch_total_relation: TotalRelation::Eq,
..Default::default()
})
.expect("serialize");
payload
.as_object_mut()
.expect("object")
.remove("opensearch_total_relation");
let parsed: ExecuteResult =
serde_json::from_value(payload).expect("an older payload must still deserialize");
assert_eq!(parsed.opensearch_total_relation, TotalRelation::Gte);
assert!(!parsed.opensearch_total_is_exact());
}
#[test]
fn an_error_result_reports_exactly_zero() {
let result = ExecuteResult::error("boom");
assert_eq!(result.opensearch_total, 0);
assert!(result.opensearch_total_is_exact());
}
#[test]
fn test_execute_result_error() {
let result = ExecuteResult::error("Test error");
assert_eq!(result.health_status, "red");
assert_eq!(result.error, Some("Test error".to_string()));
assert!(result.health_reasons.contains(&"Test error".to_string()));
}
}