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, Serialize, Deserialize)]
pub struct ExecuteResult {
pub results: Vec<JsonValue>,
pub total: usize,
pub opensearch_total: usize,
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,
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),
..Default::default()
}
}
}
#[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,
}
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,
}
}
}
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
}
}
#[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);
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 = response_body
.get("hits")
.and_then(|h| h.get("total"))
.and_then(|t| {
if let Some(obj) = t.as_object() {
obj.get("value").and_then(|v| v.as_u64())
} else {
t.as_u64()
}
})
.unwrap_or(0) as usize;
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 {
match self
.post_processor
.process_results(source_docs.clone(), tql_query)
{
Ok(filtered) => (filtered, true),
Err(_e) => {
(source_docs, false)
}
}
} else {
(source_docs, false)
};
let total = final_results.len();
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,
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);
}
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 = response_body
.get("hits")
.and_then(|h| h.get("total"))
.and_then(|t| {
if let Some(obj) = t.as_object() {
obj.get("value").and_then(|v| v.as_u64())
} else {
t.as_u64()
}
})
.unwrap_or(0) as usize;
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 {
match self
.post_processor
.process_results(source_docs.clone(), tql_query)
{
Ok(filtered) => (filtered, true),
Err(_e) => {
(source_docs, false)
}
}
} else {
(source_docs, false)
};
let total = final_results.len();
let aggregations = response_body.get("aggregations").cloned();
Ok(ExecuteResult {
results: final_results,
total,
opensearch_total,
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 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()));
}
}