use crate::comparator;
use crate::error::{Result, TqlError};
use crate::field_accessor;
use crate::mutators;
use crate::parser::{
AstNode, CollectionOpNode, ComparisonNode, LogicalOpNode, UnaryOpNode, Value as AstValue,
};
use serde_json::{json, Value as JsonValue};
use std::collections::HashMap;
#[cfg(test)]
thread_local! {
pub(crate) static VALUE_MUTATOR_RESOLUTIONS: std::cell::Cell<usize> =
const { std::cell::Cell::new(0) };
}
fn ast_value_to_json(value: &AstValue) -> JsonValue {
match value {
AstValue::String(s) => json!(s),
AstValue::Integer(i) => json!(i),
AstValue::Float(f) => json!(f),
AstValue::Boolean(b) => json!(b),
AstValue::List(list) => json!(list.iter().map(ast_value_to_json).collect::<Vec<_>>()),
AstValue::Null => json!(null),
}
}
fn json_to_ast_value(value: &JsonValue) -> AstValue {
match value {
JsonValue::String(s) => AstValue::String(s.clone()),
JsonValue::Bool(b) => AstValue::Boolean(*b),
JsonValue::Number(n) => match n.as_i64() {
Some(i) => AstValue::Integer(i),
None => AstValue::Float(n.as_f64().unwrap_or(f64::NAN)),
},
JsonValue::Array(items) => AstValue::List(items.iter().map(json_to_ast_value).collect()),
JsonValue::Null => AstValue::Null,
JsonValue::Object(_) => AstValue::String(value.to_string()),
}
}
fn render_literal(value: &AstValue) -> String {
match value {
AstValue::String(s) => s.clone(),
other => ast_value_to_json(other).to_string(),
}
}
const COLLECTION_OPS_REFUSING_A_LIST: [&str; 4] = ["any", "none", "not_any", "not_none"];
fn refuse_list_operand(field: &str, operator: &str) -> TqlError {
TqlError::TypeError(format!(
"Cannot apply operator '{operator}' to field '{field}' of type 'list operand'. \
Valid operators for list operand fields: in, not in"
))
}
fn collection_operand_is_ill_typed(coll: &CollectionOpNode) -> bool {
COLLECTION_OPS_REFUSING_A_LIST.contains(&coll.operator.as_str())
&& matches!(&coll.value, AstValue::List(items) if items.len() != 1)
}
fn reject_ill_typed_collection_operands(node: &AstNode) -> Result<()> {
match node {
AstNode::CollectionOp(coll) => {
if collection_operand_is_ill_typed(coll) {
return Err(refuse_list_operand(&coll.field, &coll.operator));
}
Ok(())
}
AstNode::LogicalOp(logical) => {
reject_ill_typed_collection_operands(&logical.left)?;
reject_ill_typed_collection_operands(&logical.right)
}
AstNode::UnaryOp(unary) => reject_ill_typed_collection_operands(&unary.operand),
_ => Ok(()),
}
}
fn build_mutator_params(
mutator_spec: &crate::parser::Mutator,
) -> Option<HashMap<String, JsonValue>> {
if !mutator_spec.named_args.is_empty() {
let mut map = HashMap::new();
for (k, v) in &mutator_spec.named_args {
map.insert(k.clone(), ast_value_to_json(v));
}
Some(map)
} else if !mutator_spec.args.is_empty() {
let mut map = HashMap::new();
for (i, v) in mutator_spec.args.iter().enumerate() {
map.insert(i.to_string(), ast_value_to_json(v));
}
Some(map)
} else {
None
}
}
pub struct TqlEvaluator {
max_depth: usize,
}
impl Default for TqlEvaluator {
fn default() -> Self {
Self::new()
}
}
impl TqlEvaluator {
pub const MAX_EVAL_DEPTH: usize = 100;
pub fn new() -> Self {
Self {
max_depth: Self::MAX_EVAL_DEPTH,
}
}
pub fn with_max_depth(max_depth: usize) -> Self {
Self { max_depth }
}
pub fn evaluate(&self, ast: &AstNode, record: &JsonValue) -> Result<bool> {
self.evaluate_with_depth(ast, record, 0)
}
fn evaluate_with_depth(&self, ast: &AstNode, record: &JsonValue, depth: usize) -> Result<bool> {
if depth > self.max_depth {
return Err(TqlError::ExecutionError(format!(
"Evaluation depth exceeds maximum of {}",
self.max_depth
)));
}
match ast {
AstNode::MatchAll => Ok(true),
AstNode::Comparison(comp) => self.evaluate_comparison(comp, record),
AstNode::LogicalOp(logical) => self.evaluate_logical_op(logical, record, depth + 1),
AstNode::UnaryOp(unary) => self.evaluate_unary_op(unary, record, depth + 1),
AstNode::CollectionOp(coll) => self.evaluate_collection_op(coll, record),
AstNode::GeoExpr(geo) => {
let field_exists = field_accessor::field_exists(record, &geo.field)?;
if !field_exists {
return Ok(false);
}
Ok(true)
}
AstNode::NslookupExpr(nslookup) => {
let field_exists = field_accessor::field_exists(record, &nslookup.field)?;
if !field_exists {
return Ok(false);
}
Ok(true)
}
AstNode::StatsExpr(_) | AstNode::QueryWithStats(_) => {
Err(TqlError::ExecutionError(
"Stats expressions must be evaluated with evaluate_stats".to_string(),
))
}
}
}
fn apply_field_mutators(
&self,
field: &str,
mutator_list: &[crate::parser::Mutator],
record: &JsonValue,
value: &JsonValue,
) -> Result<Option<JsonValue>> {
let mut current_value = value.clone();
for mutator_spec in mutator_list {
let params = build_mutator_params(mutator_spec);
let mutator = mutators::create_mutator(&mutator_spec.name, params)?;
current_value = match mutator.apply(field, record, ¤t_value) {
Ok(v) => v,
Err(_) => return Ok(None),
};
if let Some(return_value) = current_value.get("_tql_return_value") {
current_value = return_value.clone();
}
}
Ok(Some(current_value))
}
fn apply_value_mutators(
field: &str,
mutator_list: &[crate::parser::Mutator],
value: &AstValue,
) -> Result<AstValue> {
match value {
AstValue::List(items) => {
let mut out = Vec::with_capacity(items.len());
for item in items {
out.push(Self::apply_value_mutators_scalar(
field,
mutator_list,
item,
)?);
}
Ok(AstValue::List(out))
}
scalar => Self::apply_value_mutators_scalar(field, mutator_list, scalar),
}
}
fn apply_value_mutators_scalar(
field: &str,
mutator_list: &[crate::parser::Mutator],
value: &AstValue,
) -> Result<AstValue> {
#[cfg(test)]
VALUE_MUTATOR_RESOLUTIONS.with(|n| n.set(n.get() + 1));
let scratch = json!({});
let mut current = ast_value_to_json(value);
for mutator_spec in mutator_list {
let params = build_mutator_params(mutator_spec);
let mutator = mutators::create_mutator(&mutator_spec.name, params)?;
current = mutator.apply(field, &scratch, ¤t).map_err(|e| {
TqlError::ValidationError(format!(
"Value mutator '{}' cannot process the literal '{}' it is \
written against: {}",
mutator_spec.name,
render_literal(value),
e
))
})?;
if let Some(return_value) = current.get("_tql_return_value") {
current = return_value.clone();
}
}
Ok(json_to_ast_value(¤t))
}
fn evaluate_comparison(&self, comp: &ComparisonNode, record: &JsonValue) -> Result<bool> {
if comp.operator == "exists" || comp.operator == "not_exists" {
let found = field_accessor::get_field(record, &comp.field)?;
let mutated: Option<JsonValue> = match (found, &comp.field_mutators) {
(Some(value), Some(mutator_list)) => {
self.apply_field_mutators(&comp.field, mutator_list, record, value)?
}
(Some(value), None) => Some(value.clone()),
(None, _) => None,
};
let found = mutated.as_ref();
if let (Some(hint), Some(value)) = (&comp.type_hint, found) {
match field_accessor::apply_type_hint(value, hint, &comp.field, &comp.operator) {
Ok(_) => {}
Err(TqlError::TypeHintCoercion(_)) => return Ok(false),
Err(e) => return Err(e),
}
}
return match (comp.operator.as_str(), found) {
("exists", Some(value)) => Ok(!value.is_null()),
("exists", None) => Ok(false),
(_, Some(value)) => Ok(value.is_null()),
(_, None) => Ok(true),
};
}
let field_value = match field_accessor::get_field(record, &comp.field)? {
Some(value) => value,
None => {
if comp.operator == "is" && matches!(comp.value, Some(AstValue::Null)) {
return Ok(true);
}
return Ok(comparator::negated_matches_absent(&comp.operator));
}
};
let field_value = if let Some(mutator_list) = &comp.field_mutators {
match self.apply_field_mutators(&comp.field, mutator_list, record, field_value)? {
Some(v) => v,
None => return Ok(false),
}
} else {
field_value.clone()
};
let field_value = match &comp.type_hint {
Some(hint) => {
match field_accessor::apply_type_hint(
&field_value,
hint,
&comp.field,
&comp.operator,
) {
Ok(converted) => converted,
Err(TqlError::TypeHintCoercion(_)) => return Ok(false),
Err(e) => return Err(e),
}
}
None => field_value,
};
let compare_value = match &comp.value {
Some(value) => value,
None => {
return Err(TqlError::ExecutionError(format!(
"Operator '{}' requires a comparison value",
comp.operator
)));
}
};
let mutated_operand;
let compare_value = match &comp.value_mutators {
Some(mutator_list) if !mutator_list.is_empty() => {
mutated_operand =
Self::apply_value_mutators(&comp.field, mutator_list, compare_value)?;
&mutated_operand
}
_ => compare_value,
};
comparator::compare(&field_value, &comp.operator, compare_value)
}
fn evaluate_logical_op(
&self,
logical: &LogicalOpNode,
record: &JsonValue,
depth: usize,
) -> Result<bool> {
match logical.operator.as_str() {
"and" | "&&" => {
let left = self.evaluate_with_depth(&logical.left, record, depth)?;
if !left {
reject_ill_typed_collection_operands(&logical.right)?;
return Ok(false);
}
self.evaluate_with_depth(&logical.right, record, depth)
}
"or" | "||" => {
let left = self.evaluate_with_depth(&logical.left, record, depth)?;
if left {
reject_ill_typed_collection_operands(&logical.right)?;
return Ok(true);
}
self.evaluate_with_depth(&logical.right, record, depth)
}
_ => Err(TqlError::OperatorError(format!(
"Unknown logical operator: {}",
logical.operator
))),
}
}
fn evaluate_unary_op(
&self,
unary: &UnaryOpNode,
record: &JsonValue,
depth: usize,
) -> Result<bool> {
match unary.operator.as_str() {
"not" | "!" => {
let result = self.evaluate_with_depth(&unary.operand, record, depth)?;
Ok(!result)
}
_ => Err(TqlError::OperatorError(format!(
"Unknown unary operator: {}",
unary.operator
))),
}
}
fn evaluate_collection_op(&self, coll: &CollectionOpNode, record: &JsonValue) -> Result<bool> {
if collection_operand_is_ill_typed(coll) {
return Err(refuse_list_operand(&coll.field, &coll.operator));
}
let owned_scalar: Vec<JsonValue>;
let array = match field_accessor::get_field(record, &coll.field)? {
Some(JsonValue::Array(arr)) => arr,
Some(JsonValue::Null) | None => return Ok(false),
Some(scalar) => {
owned_scalar = vec![scalar.clone()];
&owned_scalar
}
};
let transformed_array: Vec<JsonValue> = if let Some(mutator_list) = &coll.field_mutators {
let mut result = Vec::with_capacity(array.len());
for element in array {
match self.apply_field_mutators(&coll.field, mutator_list, record, element)? {
Some(value) => result.push(value),
None => return Ok(false),
}
}
result
} else {
array.to_vec()
};
let compare_value = match &coll.value {
AstValue::List(items) if items.len() == 1 => &items[0],
other => other,
};
match coll.operator.as_str() {
"any" => {
for element in &transformed_array {
if comparator::compare(element, &coll.comparison_operator, compare_value)? {
return Ok(true);
}
}
Ok(false)
}
"all" => {
if transformed_array.is_empty() {
return Ok(false);
}
for element in &transformed_array {
if !comparator::compare(element, &coll.comparison_operator, compare_value)? {
return Ok(false);
}
}
Ok(true)
}
"none" => {
for element in &transformed_array {
if comparator::compare(element, &coll.comparison_operator, compare_value)? {
return Ok(false);
}
}
Ok(true)
}
"not_any" => {
for element in &transformed_array {
if comparator::compare(element, &coll.comparison_operator, compare_value)? {
return Ok(false);
}
}
Ok(true)
}
"not_all" => {
if transformed_array.is_empty() {
return Ok(false);
}
for element in &transformed_array {
if !comparator::compare(element, &coll.comparison_operator, compare_value)? {
return Ok(true);
}
}
Ok(false)
}
"not_none" => {
for element in &transformed_array {
if comparator::compare(element, &coll.comparison_operator, compare_value)? {
return Ok(true);
}
}
Ok(false)
}
_ => Err(TqlError::OperatorError(format!(
"Unknown collection operator: {}",
coll.operator
))),
}
}
pub fn filter<'a>(
&self,
ast: &AstNode,
records: &'a [JsonValue],
) -> Result<Vec<&'a JsonValue>> {
let resolved = if records.is_empty() {
None
} else {
Self::resolve_value_mutators(ast)?
};
let ast = resolved.as_ref().unwrap_or(ast);
let mut results = Vec::new();
for record in records {
if self.evaluate(ast, record)? {
results.push(record);
}
}
Ok(results)
}
pub fn filter_and_enrich(
&self,
ast: &AstNode,
records: &[JsonValue],
) -> Result<Vec<JsonValue>> {
let resolved = if records.is_empty() {
None
} else {
Self::resolve_value_mutators(ast)?
};
let ast = resolved.as_ref().unwrap_or(ast);
let mut results = Vec::new();
let mut mutator_cache = std::collections::HashMap::new();
let boolean_predicates: std::collections::HashSet<&'static str> =
mutators::boolean_predicate_names().into_iter().collect();
let mutators_info = self.extract_field_mutators(ast);
for record in records {
if self.evaluate(ast, record)? {
let enriched_record = self.apply_enrichment(
&mutators_info,
record,
&mut mutator_cache,
&boolean_predicates,
)?;
results.push(enriched_record);
}
}
Ok(results)
}
fn apply_enrichment(
&self,
mutators_info: &Option<Vec<(String, Vec<crate::parser::Mutator>)>>,
record: &JsonValue,
mutator_cache: &mut std::collections::HashMap<String, Box<dyn mutators::Mutator>>,
boolean_predicates: &std::collections::HashSet<&'static str>,
) -> Result<JsonValue> {
let mut enriched = record.clone();
if let Some(mutators_info) = mutators_info {
for (field, mutator_list) in mutators_info {
if mutator_list
.last()
.is_some_and(|m| boolean_predicates.contains(m.name.to_lowercase().as_str()))
{
continue;
}
if let Some(field_value) = field_accessor::get_field(record, field)? {
let mut current_value = field_value.clone();
let mut unprocessable = false;
for mutator_spec in mutator_list {
let cache_key = format!(
"{}:{:?}:{:?}",
mutator_spec.name, mutator_spec.args, mutator_spec.named_args
);
if !mutator_cache.contains_key(&cache_key) {
let params = build_mutator_params(mutator_spec);
let mutator = mutators::create_mutator(&mutator_spec.name, params)?;
mutator_cache.insert(cache_key.clone(), mutator);
}
let mutator = mutator_cache.get(&cache_key).unwrap();
current_value = match mutator.apply(field, record, ¤t_value) {
Ok(v) => v,
Err(_) => {
unprocessable = true;
break;
}
};
}
if unprocessable {
continue;
}
if let Some(enrichment_data) = current_value.get("_tql_enrichment") {
self.apply_enrichment_data(&mut enriched, enrichment_data)?;
} else {
if let Some(obj) = enriched.as_object_mut() {
obj.insert(field.clone(), current_value);
}
}
}
}
}
Ok(enriched)
}
fn apply_enrichment_data(
&self,
record: &mut JsonValue,
enrichment_data: &JsonValue,
) -> Result<()> {
let enrichment_type = enrichment_data
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("");
match enrichment_type {
"dns" => {
let domain_field = enrichment_data
.get("domain_field")
.and_then(|v| v.as_str())
.unwrap_or("domain");
let dns_field = enrichment_data
.get("dns_field")
.and_then(|v| v.as_str())
.unwrap_or("dns");
if let Some(domain) = enrichment_data.get("domain") {
if !domain.is_null() {
field_accessor::set_field(record, domain_field, domain.clone())?;
}
}
if let Some(dns) = enrichment_data.get("dns") {
field_accessor::set_field(record, dns_field, dns.clone())?;
}
}
"geo" => {
let geo_field = enrichment_data
.get("geo_field")
.and_then(|v| v.as_str())
.unwrap_or("geo");
if let Some(geo_data) = enrichment_data.get("geo") {
field_accessor::set_field(record, geo_field, geo_data.clone())?;
}
if let Some(as_field) = enrichment_data.get("as_field").and_then(|v| v.as_str()) {
if let Some(as_data) = enrichment_data.get("as") {
field_accessor::set_field(record, as_field, as_data.clone())?;
}
}
}
_ => {
}
}
Ok(())
}
fn extract_field_mutators(
&self,
ast: &AstNode,
) -> Option<Vec<(String, Vec<crate::parser::Mutator>)>> {
use std::collections::HashMap;
let mut mutators_map: HashMap<String, Vec<crate::parser::Mutator>> = HashMap::new();
self.collect_field_mutators(ast, &mut mutators_map);
if mutators_map.is_empty() {
None
} else {
Some(mutators_map.into_iter().collect())
}
}
#[allow(clippy::only_used_in_recursion)]
fn collect_field_mutators(
&self,
ast: &AstNode,
mutators_map: &mut std::collections::HashMap<String, Vec<crate::parser::Mutator>>,
) {
match ast {
AstNode::Comparison(comp) => {
if let Some(mutator_list) = &comp.field_mutators {
if !mutator_list.is_empty() {
mutators_map.insert(comp.field.clone(), mutator_list.clone());
}
}
}
AstNode::LogicalOp(logical) => {
self.collect_field_mutators(&logical.left, mutators_map);
self.collect_field_mutators(&logical.right, mutators_map);
}
AstNode::UnaryOp(unary) => {
self.collect_field_mutators(&unary.operand, mutators_map);
}
AstNode::CollectionOp(coll) => {
if let Some(mutator_list) = &coll.field_mutators {
if !mutator_list.is_empty() {
mutators_map.insert(coll.field.clone(), mutator_list.clone());
}
}
}
_ => {}
}
}
pub(crate) fn resolve_value_mutators(ast: &AstNode) -> Result<Option<AstNode>> {
if !Self::carries_value_mutators(ast) {
return Ok(None);
}
let mut resolved = ast.clone();
Self::resolve_value_mutators_in_place(&mut resolved)?;
Ok(Some(resolved))
}
fn carries_value_mutators(ast: &AstNode) -> bool {
match ast {
AstNode::Comparison(comp) => comp
.value_mutators
.as_ref()
.is_some_and(|list| !list.is_empty()),
AstNode::LogicalOp(logical) => {
Self::carries_value_mutators(&logical.left)
|| Self::carries_value_mutators(&logical.right)
}
AstNode::UnaryOp(unary) => Self::carries_value_mutators(&unary.operand),
AstNode::QueryWithStats(qws) => Self::carries_value_mutators(&qws.filter),
_ => false,
}
}
fn resolve_value_mutators_in_place(ast: &mut AstNode) -> Result<()> {
match ast {
AstNode::Comparison(comp) => {
let has_chain = comp
.value_mutators
.as_ref()
.is_some_and(|list| !list.is_empty());
if !has_chain || comp.value.is_none() {
return Ok(());
}
let chain = comp.value_mutators.take().unwrap_or_default();
let value = comp.value.as_ref().expect("checked is_none above");
comp.value = Some(Self::apply_value_mutators(&comp.field, &chain, value)?);
Ok(())
}
AstNode::LogicalOp(logical) => {
Self::resolve_value_mutators_in_place(&mut logical.left)?;
Self::resolve_value_mutators_in_place(&mut logical.right)
}
AstNode::UnaryOp(unary) => Self::resolve_value_mutators_in_place(&mut unary.operand),
AstNode::QueryWithStats(qws) => Self::resolve_value_mutators_in_place(&mut qws.filter),
_ => Ok(()),
}
}
pub fn count(&self, ast: &AstNode, records: &[JsonValue]) -> Result<usize> {
let resolved = if records.is_empty() {
None
} else {
Self::resolve_value_mutators(ast)?
};
let ast = resolved.as_ref().unwrap_or(ast);
let mut count = 0;
for record in records {
if self.evaluate(ast, record)? {
count += 1;
}
}
Ok(count)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::parser::TqlParser;
use serde_json::json;
fn resolutions_over(query: &str, records: &[JsonValue]) -> (usize, usize) {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse(query).unwrap();
VALUE_MUTATOR_RESOLUTIONS.with(|n| n.set(0));
let hits = evaluator.filter(&ast, records).unwrap();
(hits.len(), VALUE_MUTATOR_RESOLUTIONS.with(|n| n.get()))
}
#[test]
fn value_mutator_chain_resolves_once_per_query_not_once_per_record() {
let records: Vec<JsonValue> = (0..50).map(|_| json!({"f": "abc"})).collect();
let (hits, resolutions) = resolutions_over("f eq 'ABC' | lowercase", &records);
assert_eq!(hits, 50, "every record still matches");
assert_eq!(
resolutions,
1,
"the operand does not depend on the record, so resolving it {} times \
is {} redundant DNS queries / MaxMind mmaps for a `| nslookup` or \
`| geoip` chain",
resolutions,
resolutions.saturating_sub(1)
);
}
#[test]
fn value_mutator_resolution_count_is_independent_of_corpus_size() {
let one: Vec<JsonValue> = vec![json!({"f": "abc"})];
let many: Vec<JsonValue> = (0..500).map(|_| json!({"f": "abc"})).collect();
let (_, small) = resolutions_over("f eq 'ABC' | lowercase", &one);
let (_, large) = resolutions_over("f eq 'ABC' | lowercase", &many);
assert_eq!(
small, large,
"1 record cost {small}, 500 records cost {large}"
);
}
#[test]
fn list_operand_resolves_once_per_element_not_per_record() {
let records: Vec<JsonValue> = (0..20).map(|_| json!({"f": "abc"})).collect();
let (hits, resolutions) = resolutions_over("f in ['ABC', 'XYZ'] | lowercase", &records);
assert_eq!(hits, 20);
assert_eq!(resolutions, 2, "two elements, twenty records");
}
#[test]
fn zero_records_does_not_raise_a_value_mutator_error() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("f eq 'abc' | nosuchmutator").unwrap();
assert_eq!(evaluator.filter(&ast, &[]).unwrap().len(), 0);
assert_eq!(evaluator.count(&ast, &[]).unwrap(), 0);
assert_eq!(evaluator.filter_and_enrich(&ast, &[]).unwrap().len(), 0);
assert!(evaluator.filter(&ast, &[json!({"f": "abc"})]).is_err());
}
#[test]
fn the_hoisted_predicate_set_matches_the_derived_predicate() {
let hoisted: std::collections::HashSet<&'static str> =
mutators::boolean_predicate_names().into_iter().collect();
for name in mutators::mutator_names() {
assert_eq!(
hoisted.contains(name),
mutators::returns_boolean(name),
"`{name}` is classified differently by the hoisted set and by \
`returns_boolean`"
);
}
assert!(!hoisted.contains("nosuchmutator"));
assert!(!mutators::returns_boolean("nosuchmutator"));
}
#[test]
fn the_hoisted_guard_is_case_insensitive() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("ip | IS_LOOPBACK").unwrap();
let records = vec![json!({"ip": "127.0.0.1"})];
let enriched = evaluator.filter_and_enrich(&ast, &records).unwrap();
assert_eq!(enriched.len(), 1);
assert_eq!(
enriched[0]["ip"],
json!("127.0.0.1"),
"an upper-cased predicate is still a predicate and must not project"
);
}
#[test]
fn a_short_circuited_branch_still_refuses_a_bad_value_mutator() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser
.parse("a eq 1 or f eq 'abc' | nosuchmutator")
.unwrap();
let records = vec![json!({"a": 1}), json!({"a": 1})];
assert!(
evaluator.filter(&ast, &records).is_err(),
"an unknown mutator in a skipped branch is still a broken query"
);
}
#[test]
fn test_evaluate_simple_comparison() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("age > 25").unwrap();
let record = json!({"age": 30, "name": "John"});
assert!(evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
fn test_evaluate_equality() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("name eq 'John'").unwrap();
let record = json!({"age": 30, "name": "John"});
assert!(evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
fn test_evaluate_and_operator() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("age > 25 AND name eq 'John'").unwrap();
let record = json!({"age": 30, "name": "John"});
assert!(evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
fn test_evaluate_or_operator() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("age > 40 OR name eq 'John'").unwrap();
let record = json!({"age": 30, "name": "John"});
assert!(evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
fn test_evaluate_not_operator() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("NOT age > 40").unwrap();
let record = json!({"age": 30, "name": "John"});
assert!(evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
fn test_evaluate_between_list_syntax() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("age between [20, 40]").unwrap();
let record = json!({"age": 30});
assert!(evaluator.evaluate(&ast, &record).unwrap());
let record = json!({"age": 50});
assert!(!evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
fn test_evaluate_between_natural_syntax() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("age between 20 and 40").unwrap();
let record = json!({"age": 30});
assert!(evaluator.evaluate(&ast, &record).unwrap());
let record = json!({"age": 50});
assert!(!evaluator.evaluate(&ast, &record).unwrap());
let record = json!({"age": 20});
assert!(evaluator.evaluate(&ast, &record).unwrap());
let record = json!({"age": 40});
assert!(evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
fn test_evaluate_not_between() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("age not between 20 and 40").unwrap();
let record = json!({"age": 50});
assert!(evaluator.evaluate(&ast, &record).unwrap());
let record = json!({"age": 30});
assert!(!evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
fn test_evaluate_nested_fields() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("user.profile.age > 25").unwrap();
let record = json!({
"user": {
"profile": {
"age": 30
}
}
});
assert!(evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
fn test_evaluate_exists() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("name exists").unwrap();
let record = json!({"name": "John", "age": 30});
assert!(evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
fn test_evaluate_not_exists() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("email not_exists").unwrap();
let record = json!({"name": "John", "age": 30});
assert!(evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
fn test_evaluate_contains() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("message contains 'error'").unwrap();
let record = json!({"message": "An error occurred"});
assert!(evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
fn test_evaluate_any_operator() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("ANY tags eq 'urgent'").unwrap();
let record = json!({"tags": ["bug", "urgent", "security"]});
assert!(evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
fn test_evaluate_all_operator() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("ALL scores >= 80").unwrap();
let record = json!({"scores": [85, 90, 95]});
assert!(evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
fn test_evaluate_none_operator() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("NONE tags eq 'wontfix'").unwrap();
let record = json!({"tags": ["bug", "urgent", "security"]});
assert!(evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
fn test_filter_records() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("age > 25").unwrap();
let records = vec![
json!({"name": "John", "age": 30}),
json!({"name": "Jane", "age": 20}),
json!({"name": "Bob", "age": 35}),
];
let results = evaluator.filter(&ast, &records).unwrap();
assert_eq!(results.len(), 2);
}
#[test]
fn test_count_matching_records() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("age > 25").unwrap();
let records = vec![
json!({"name": "John", "age": 30}),
json!({"name": "Jane", "age": 20}),
json!({"name": "Bob", "age": 35}),
];
let count = evaluator.count(&ast, &records).unwrap();
assert_eq!(count, 2);
}
#[test]
fn test_complex_query() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser
.parse("(age > 25 AND status eq 'active') OR role eq 'admin'")
.unwrap();
let record1 = json!({"age": 30, "status": "active", "role": "user"});
let record2 = json!({"age": 20, "status": "active", "role": "admin"});
let record3 = json!({"age": 20, "status": "inactive", "role": "user"});
assert!(evaluator.evaluate(&ast, &record1).unwrap());
assert!(evaluator.evaluate(&ast, &record2).unwrap());
assert!(!evaluator.evaluate(&ast, &record3).unwrap());
}
#[test]
fn test_evaluate_with_mutator() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("name | lowercase eq 'john'").unwrap();
let record = json!({"name": "JOHN", "age": 30});
assert!(evaluator.evaluate(&ast, &record).unwrap());
let ast = parser
.parse("email | uppercase contains 'EXAMPLE'")
.unwrap();
let record = json!({"email": "user@example.com"});
assert!(evaluator.evaluate(&ast, &record).unwrap());
let ast = parser
.parse("message | trim | lowercase eq 'hello'")
.unwrap();
let record = json!({"message": " HELLO "});
assert!(evaluator.evaluate(&ast, &record).unwrap());
}
#[test]
#[cfg(feature = "integration-tests")]
fn test_nslookup_enrichment() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("destination.ip | nslookup").unwrap();
let records = vec![json!({"destination": {"ip": "8.8.8.8"}})];
let enriched = evaluator.filter_and_enrich(&ast, &records).unwrap();
assert_eq!(enriched.len(), 1);
let record = &enriched[0];
let destination = record.get("destination").expect("Should have destination");
assert!(
destination.get("domain").is_some(),
"Should have destination.domain"
);
let dns = destination.get("dns").expect("Should have destination.dns");
assert!(dns.get("question").is_some(), "DNS should have question");
assert!(dns.get("answers").is_some(), "DNS should have answers");
assert!(
dns.get("response_code").is_some(),
"DNS should have response_code"
);
}
#[test]
#[cfg(feature = "integration-tests")]
fn test_nslookup_comparison() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser
.parse("destination.ip | nslookup contains 'google'")
.unwrap();
let record = json!({"destination": {"ip": "8.8.8.8"}});
let result = evaluator.evaluate(&ast, &record).unwrap();
assert!(result, "8.8.8.8 should resolve to dns.google");
let record = json!({"destination": {"ip": "192.168.1.1"}});
let result = evaluator.evaluate(&ast, &record).unwrap();
assert!(!result, "Private IP should not resolve to google");
}
#[test]
fn test_nslookup_expr_evaluation_enrichment_only() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("destination.ip | nslookup").unwrap();
let record = json!({"destination": {"ip": "8.8.8.8"}});
let result = evaluator.evaluate(&ast, &record).unwrap();
assert!(
result,
"NslookupExpr with existing field should return true"
);
let record = json!({"source": {"ip": "8.8.8.8"}});
let result = evaluator.evaluate(&ast, &record).unwrap();
assert!(
!result,
"NslookupExpr with missing field should return false"
);
}
#[test]
fn test_geo_expr_evaluation_enrichment_only() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser.parse("source.ip | geoip").unwrap();
let record = json!({"source": {"ip": "8.8.8.8"}});
let result = evaluator.evaluate(&ast, &record).unwrap();
assert!(result, "GeoExpr with existing field should return true");
let record = json!({"destination": {"ip": "8.8.8.8"}});
let result = evaluator.evaluate(&ast, &record).unwrap();
assert!(!result, "GeoExpr with missing field should return false");
}
#[test]
fn test_compound_query_with_nslookup_expr() {
let parser = TqlParser::new();
let evaluator = TqlEvaluator::new();
let ast = parser
.parse("event.code = 3 AND destination.ip | is_global eq true AND destination.ip | nslookup")
.unwrap();
let record = json!({
"event": {"code": 3},
"destination": {"ip": "8.8.8.8"}
});
let result = evaluator.evaluate(&ast, &record).unwrap();
assert!(
result,
"Compound query should match when all conditions are true"
);
let record = json!({
"event": {"code": 4},
"destination": {"ip": "8.8.8.8"}
});
let result = evaluator.evaluate(&ast, &record).unwrap();
assert!(
!result,
"Compound query should fail when event.code doesn't match"
);
let record = json!({
"event": {"code": 3},
"destination": {"ip": "192.168.1.1"}
});
let result = evaluator.evaluate(&ast, &record).unwrap();
assert!(
!result,
"Compound query should fail when is_global is false"
);
}
}