diff --git a/docs/src/guide/querying.md b/docs/src/guide/querying.md index 60459cb..70f6bc0 100644 --- a/docs/src/guide/querying.md +++ b/docs/src/guide/querying.md @@ -65,6 +65,11 @@ $ firm query 'from task | where is_completed == false' $ firm query 'from task | where assignee_ref == person.john_doe' ``` +**Find invoices that are draft or sent:** +```bash +$ firm query 'from invoice | where status == "draft" or status == "sent"' +``` + **Find recent incomplete tasks related to active projects, sorted by due date:** ```bash $ firm query 'from project | where status == "in progress" | related(2) task | where is_completed == false | where due_date > 2025-01-01 | order due_date | limit 10' diff --git a/docs/src/reference/query-reference.md b/docs/src/reference/query-reference.md index a1ce9cf..4607d0b 100644 --- a/docs/src/reference/query-reference.md +++ b/docs/src/reference/query-reference.md @@ -52,9 +52,38 @@ from task | where is_completed == false # Filter by metadata from * | where @type == "task" +``` + +**Compound conditions:** + +Combine multiple conditions in a single `where` clause using `and` or `or`: + +```bash +# Match any of multiple values (OR) +from invoice | where status == "draft" or status == "sent" + +# Require all conditions (AND) +from task | where is_completed == false and priority > 5 + +# Multiple OR conditions +from opportunity | where status == enum"open" or status == enum"negotiation" or status == enum"proposal" +``` -# Multiple conditions +You cannot mix `and` and `or` in the same `where` clause. Use separate `where` clauses to combine them: + +```bash +# (status is draft OR sent) AND (amount > 1000) +from invoice | where status == "draft" or status == "sent" | where amount > 1000 +``` + +**Chaining where clauses:** + +Multiple `where` clauses joined by pipes act as implicit AND: + +```bash +# These are equivalent: from task | where is_completed == false | where priority > 5 +from task | where is_completed == false and priority > 5 ``` **Supported operators:** diff --git a/firm_core/src/graph/query/filter/mod.rs b/firm_core/src/graph/query/filter/mod.rs index fe9e73c..d9519af 100644 --- a/firm_core/src/graph/query/filter/mod.rs +++ b/firm_core/src/graph/query/filter/mod.rs @@ -40,6 +40,48 @@ impl FilterCondition { FieldRef::Regular(field_id) => self.matches_field(entity, field_id), } } +} + +/// A compound filter condition combining multiple conditions with a logical operator +#[derive(Debug, Clone, PartialEq)] +pub struct CompoundFilterCondition { + pub conditions: Vec, + pub combinator: Combinator, +} + +impl CompoundFilterCondition { + /// Create a new compound filter condition + pub fn new(conditions: Vec, combinator: Combinator) -> Self { + Self { + conditions, + combinator, + } + } + + /// Create a compound condition with a single filter (AND by default) + pub fn single(condition: FilterCondition) -> Self { + Self { + conditions: vec![condition], + combinator: Combinator::default(), + } + } + + /// Check if an entity matches this compound condition + pub fn matches(&self, entity: &Entity) -> Result { + let results: Result, QueryError> = self + .conditions + .iter() + .map(|c| c.matches(entity)) + .collect(); + + Ok(match self.combinator { + Combinator::And => results?.iter().all(|&r| r), + Combinator::Or => results?.iter().any(|&r| r), + }) + } +} + +impl FilterCondition { fn matches_metadata( &self, @@ -88,3 +130,139 @@ impl FilterCondition { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::{Entity, EntityId, EntityType, FieldId, FieldValue}; + + fn make_test_entity(name: &str, age: i64, active: bool) -> Entity { + Entity::new(EntityId::new("test"), EntityType::new("person")) + .with_field(FieldId::new("name"), name) + .with_field(FieldId::new("age"), FieldValue::Integer(age)) + .with_field(FieldId::new("active"), active) + } + + #[test] + fn test_compound_condition_single() { + let entity = make_test_entity("Alice", 30, true); + let condition = CompoundFilterCondition::single(FilterCondition::new( + FieldRef::Regular(FieldId::new("name")), + FilterOperator::Equal, + FilterValue::String("Alice".to_string()), + )); + + assert!(condition.matches(&entity).unwrap()); + } + + #[test] + fn test_compound_condition_and_all_match() { + let entity = make_test_entity("Alice", 30, true); + let condition = CompoundFilterCondition::new( + vec![ + FilterCondition::new( + FieldRef::Regular(FieldId::new("name")), + FilterOperator::Equal, + FilterValue::String("Alice".to_string()), + ), + FilterCondition::new( + FieldRef::Regular(FieldId::new("age")), + FilterOperator::GreaterThan, + FilterValue::Integer(25), + ), + ], + Combinator::And, + ); + + assert!(condition.matches(&entity).unwrap()); + } + + #[test] + fn test_compound_condition_and_one_fails() { + let entity = make_test_entity("Alice", 30, true); + let condition = CompoundFilterCondition::new( + vec![ + FilterCondition::new( + FieldRef::Regular(FieldId::new("name")), + FilterOperator::Equal, + FilterValue::String("Alice".to_string()), + ), + FilterCondition::new( + FieldRef::Regular(FieldId::new("age")), + FilterOperator::GreaterThan, + FilterValue::Integer(35), // Alice is 30, so this fails + ), + ], + Combinator::And, + ); + + assert!(!condition.matches(&entity).unwrap()); + } + + #[test] + fn test_compound_condition_or_one_matches() { + let entity = make_test_entity("Alice", 30, true); + let condition = CompoundFilterCondition::new( + vec![ + FilterCondition::new( + FieldRef::Regular(FieldId::new("name")), + FilterOperator::Equal, + FilterValue::String("Bob".to_string()), // Doesn't match + ), + FilterCondition::new( + FieldRef::Regular(FieldId::new("age")), + FilterOperator::Equal, + FilterValue::Integer(30), // Matches + ), + ], + Combinator::Or, + ); + + assert!(condition.matches(&entity).unwrap()); + } + + #[test] + fn test_compound_condition_or_none_match() { + let entity = make_test_entity("Alice", 30, true); + let condition = CompoundFilterCondition::new( + vec![ + FilterCondition::new( + FieldRef::Regular(FieldId::new("name")), + FilterOperator::Equal, + FilterValue::String("Bob".to_string()), + ), + FilterCondition::new( + FieldRef::Regular(FieldId::new("age")), + FilterOperator::Equal, + FilterValue::Integer(25), + ), + ], + Combinator::Or, + ); + + assert!(!condition.matches(&entity).unwrap()); + } + + #[test] + fn test_compound_condition_or_multiple_values_same_field() { + // This is the primary use case: where status = "draft" or status = "sent" + let entity = make_test_entity("Alice", 30, true); + let condition = CompoundFilterCondition::new( + vec![ + FilterCondition::new( + FieldRef::Regular(FieldId::new("name")), + FilterOperator::Equal, + FilterValue::String("Alice".to_string()), + ), + FilterCondition::new( + FieldRef::Regular(FieldId::new("name")), + FilterOperator::Equal, + FilterValue::String("Bob".to_string()), + ), + ], + Combinator::Or, + ); + + assert!(condition.matches(&entity).unwrap()); + } +} diff --git a/firm_core/src/graph/query/filter/types.rs b/firm_core/src/graph/query/filter/types.rs index 06b6889..17f725b 100644 --- a/firm_core/src/graph/query/filter/types.rs +++ b/firm_core/src/graph/query/filter/types.rs @@ -2,6 +2,14 @@ use crate::FieldId; +/// Logical operator for combining multiple filter conditions +#[derive(Debug, Clone, PartialEq, Default)] +pub enum Combinator { + #[default] + And, + Or, +} + /// Reference to a field (either metadata or regular field) #[derive(Debug, Clone, PartialEq)] pub enum FieldRef { diff --git a/firm_core/src/graph/query/types.rs b/firm_core/src/graph/query/types.rs index 55cc211..ad93dc5 100644 --- a/firm_core/src/graph/query/types.rs +++ b/firm_core/src/graph/query/types.rs @@ -1,7 +1,7 @@ //! Core query types for executing queries against the entity graph use super::QueryError; -use super::filter::FilterCondition; +use super::filter::CompoundFilterCondition; use super::order::compare_entities_by_field; use crate::{Entity, EntityType}; @@ -114,8 +114,8 @@ pub enum EntitySelector { /// Operations that can be applied to entity collections #[derive(Debug, Clone)] pub enum QueryOperation { - /// Filter entities by a condition - Where(FilterCondition), + /// Filter entities by a compound condition + Where(CompoundFilterCondition), /// Traverse to related entities Related { degrees: usize, @@ -187,10 +187,12 @@ mod tests { fn test_query_with_where() { let graph = create_test_graph(); let query = Query::new(EntitySelector::Type(EntityType::new("task"))).with_operation( - QueryOperation::Where(super::super::FilterCondition::new( - super::super::FieldRef::Regular(FieldId::new("is_completed")), - super::super::FilterOperator::Equal, - super::super::FilterValue::Boolean(false), + QueryOperation::Where(super::super::CompoundFilterCondition::single( + super::super::FilterCondition::new( + super::super::FieldRef::Regular(FieldId::new("is_completed")), + super::super::FilterOperator::Equal, + super::super::FilterValue::Boolean(false), + ), )), ); @@ -212,11 +214,15 @@ mod tests { fn test_query_with_where_and_limit() { let graph = create_test_graph(); let query = Query::new(EntitySelector::Type(EntityType::new("person"))) - .with_operation(QueryOperation::Where(super::super::FilterCondition::new( - super::super::FieldRef::Regular(FieldId::new("age")), - super::super::FilterOperator::GreaterThan, - super::super::FilterValue::Integer(20), - ))) + .with_operation(QueryOperation::Where( + super::super::CompoundFilterCondition::single( + super::super::FilterCondition::new( + super::super::FieldRef::Regular(FieldId::new("age")), + super::super::FilterOperator::GreaterThan, + super::super::FilterValue::Integer(20), + ), + ), + )) .with_operation(QueryOperation::Limit(1)); let results = query.execute(&graph).unwrap(); diff --git a/firm_lang/src/convert/to_query.rs b/firm_lang/src/convert/to_query.rs index 212e8d2..1c63b7c 100644 --- a/firm_lang/src/convert/to_query.rs +++ b/firm_lang/src/convert/to_query.rs @@ -1,8 +1,8 @@ //! Conversion from ParsedQuery to executable Query use firm_core::graph::{ - EntitySelector, FieldRef, FilterCondition, FilterOperator, FilterValue, MetadataField, Query, - QueryOperation, SortDirection, + Combinator, CompoundFilterCondition, EntitySelector, FieldRef, FilterCondition, FilterOperator, + FilterValue, MetadataField, Query, QueryOperation, SortDirection, }; use firm_core::{EntityType, FieldId}; @@ -55,9 +55,17 @@ impl TryFrom for Query { fn convert_operation(parsed: ParsedOperation) -> Result { match parsed { - ParsedOperation::Where(condition) => { - let filter_condition = convert_condition(condition)?; - Ok(QueryOperation::Where(filter_condition)) + ParsedOperation::Where(compound) => { + let conditions: Result, _> = compound + .conditions + .into_iter() + .map(convert_condition) + .collect(); + let combinator = convert_combinator(compound.combinator); + Ok(QueryOperation::Where(CompoundFilterCondition::new( + conditions?, + combinator, + ))) } ParsedOperation::Limit(n) => Ok(QueryOperation::Limit(n)), ParsedOperation::Order { field, direction } => convert_order(field, direction), @@ -65,6 +73,13 @@ fn convert_operation(parsed: ParsedOperation) -> Result Combinator { + match parsed { + ParsedCombinator::And => Combinator::And, + ParsedCombinator::Or => Combinator::Or, + } +} + fn convert_condition(parsed: ParsedCondition) -> Result { let field = convert_field(parsed.field); let operator = convert_operator(parsed.operator); diff --git a/firm_lang/src/parser/query/grammar.pest b/firm_lang/src/parser/query/grammar.pest index 19ff884..9af1559 100644 --- a/firm_lang/src/parser/query/grammar.pest +++ b/firm_lang/src/parser/query/grammar.pest @@ -16,8 +16,14 @@ operation = { | limit_clause } -// WHERE clause: "where field_name == value" or "where @type == 'task'" -where_clause = { "where" ~ condition } +// WHERE clause: "where field == value" or "where a == 1 and b == 2" +where_clause = { "where" ~ compound_condition } + +compound_condition = { condition ~ (combinator ~ condition)* } + +combinator = { and_kw | or_kw } +and_kw = @{ ^"and" } +or_kw = @{ ^"or" } condition = { metadata_field ~ operator ~ value @@ -74,8 +80,9 @@ number = @{ "-"? ~ ASCII_DIGIT+ ~ ("." ~ ASCII_DIGIT+)? } boolean = { "true" | "false" } // Currency: number followed by currency code (e.g., "100.50 USD") +// Negative lookahead prevents matching combinator keywords (and/or) currency = { number ~ currency_code } -currency_code = @{ ASCII_ALPHA{3} } +currency_code = @{ !(and_kw | or_kw) ~ ASCII_ALPHA{3} } // DateTime: ISO format (e.g., "2025-01-15" or "2025-01-15 at 14:30 UTC") datetime = @{ diff --git a/firm_lang/src/parser/query/parsed_query.rs b/firm_lang/src/parser/query/parsed_query.rs index 62b143f..a24600d 100644 --- a/firm_lang/src/parser/query/parsed_query.rs +++ b/firm_lang/src/parser/query/parsed_query.rs @@ -25,7 +25,7 @@ pub enum ParsedEntitySelector { /// Operations that can be chained in a query #[derive(Debug, Clone, PartialEq)] pub enum ParsedOperation { - Where(ParsedCondition), + Where(ParsedCompoundCondition), Related { degree: Option, selector: Option, @@ -37,7 +37,22 @@ pub enum ParsedOperation { Limit(usize), } -/// A condition in a WHERE clause +/// A compound condition combining multiple conditions with AND/OR +#[derive(Debug, Clone, PartialEq)] +pub struct ParsedCompoundCondition { + pub conditions: Vec, + pub combinator: ParsedCombinator, +} + +/// Logical combinator for compound conditions +#[derive(Debug, Clone, PartialEq, Default)] +pub enum ParsedCombinator { + #[default] + And, + Or, +} + +/// A single condition in a WHERE clause #[derive(Debug, Clone, PartialEq)] pub struct ParsedCondition { pub field: ParsedField, diff --git a/firm_lang/src/parser/query/parser.rs b/firm_lang/src/parser/query/parser.rs index 7259524..9372552 100644 --- a/firm_lang/src/parser/query/parser.rs +++ b/firm_lang/src/parser/query/parser.rs @@ -109,9 +109,9 @@ fn parse_where_clause( pair: pest::iterators::Pair, ) -> Result { for inner_pair in pair.into_inner() { - if inner_pair.as_rule() == Rule::condition { - let condition = parse_condition(inner_pair)?; - return Ok(ParsedOperation::Where(condition)); + if inner_pair.as_rule() == Rule::compound_condition { + let compound = parse_compound_condition(inner_pair)?; + return Ok(ParsedOperation::Where(compound)); } } Err(QueryParseError::SyntaxError( @@ -119,6 +119,55 @@ fn parse_where_clause( )) } +fn parse_compound_condition( + pair: pest::iterators::Pair, +) -> Result { + let mut conditions = Vec::new(); + let mut combinators = Vec::new(); + + for inner_pair in pair.into_inner() { + match inner_pair.as_rule() { + Rule::condition => { + conditions.push(parse_condition(inner_pair)?); + } + Rule::combinator => { + let combinator = match inner_pair.as_str().to_lowercase().as_str() { + "and" => ParsedCombinator::And, + "or" => ParsedCombinator::Or, + _ => { + return Err(QueryParseError::SyntaxError(format!( + "Unknown combinator: {}", + inner_pair.as_str() + ))) + } + }; + combinators.push(combinator); + } + _ => {} + } + } + + // Validate that all combinators are the same type + let combinator = if combinators.is_empty() { + ParsedCombinator::default() + } else { + let first = &combinators[0]; + for c in &combinators[1..] { + if c != first { + return Err(QueryParseError::SyntaxError( + "Cannot mix AND and OR in the same WHERE clause. Use separate WHERE clauses instead.".to_string(), + )); + } + } + first.clone() + }; + + Ok(ParsedCompoundCondition { + conditions, + combinator, + }) +} + fn parse_condition(pair: pest::iterators::Pair) -> Result { let mut inner = pair.into_inner(); diff --git a/firm_lang/tests/convert_query_tests.rs b/firm_lang/tests/convert_query_tests.rs index 7be947e..8e7d7e9 100644 --- a/firm_lang/tests/convert_query_tests.rs +++ b/firm_lang/tests/convert_query_tests.rs @@ -34,7 +34,9 @@ fn test_convert_where_with_regular_field() { let query: Query = parsed.try_into().unwrap(); assert_eq!(query.operations.len(), 1); - if let QueryOperation::Where(condition) = &query.operations[0] { + if let QueryOperation::Where(compound) = &query.operations[0] { + assert_eq!(compound.conditions.len(), 1); + let condition = &compound.conditions[0]; assert!(matches!(condition.field, FieldRef::Regular(_))); assert!(matches!(condition.operator, FilterOperator::Equal)); assert!(matches!(condition.value, FilterValue::Boolean(true))); @@ -50,7 +52,9 @@ fn test_convert_where_with_metadata_field() { let query: Query = parsed.try_into().unwrap(); assert_eq!(query.operations.len(), 1); - if let QueryOperation::Where(condition) = &query.operations[0] { + if let QueryOperation::Where(compound) = &query.operations[0] { + assert_eq!(compound.conditions.len(), 1); + let condition = &compound.conditions[0]; assert!(matches!( condition.field, FieldRef::Metadata(MetadataField::Type) @@ -168,9 +172,10 @@ fn test_convert_currency_value() { let parsed = parse_query(query_str).unwrap(); let query: Query = parsed.try_into().unwrap(); - if let QueryOperation::Where(condition) = &query.operations[0] { + if let QueryOperation::Where(compound) = &query.operations[0] { + let condition = &compound.conditions[0]; if let FilterValue::Currency { amount, code } = &condition.value { - assert_eq!(*amount, 5000.50); + assert!((amount - 5000.50).abs() < f64::EPSILON); assert_eq!(code, "USD"); } else { panic!("Expected Currency value"); @@ -186,7 +191,8 @@ fn test_convert_reference_value() { let parsed = parse_query(query_str).unwrap(); let query: Query = parsed.try_into().unwrap(); - if let QueryOperation::Where(condition) = &query.operations[0] { + if let QueryOperation::Where(compound) = &query.operations[0] { + let condition = &compound.conditions[0]; if let FilterValue::Reference(ref_str) = &condition.value { assert_eq!(ref_str, "person.john_doe"); } else { diff --git a/firm_lang/tests/parser_query_tests.rs b/firm_lang/tests/parser_query_tests.rs index 6d5f391..5a40e87 100644 --- a/firm_lang/tests/parser_query_tests.rs +++ b/firm_lang/tests/parser_query_tests.rs @@ -1,8 +1,8 @@ //! Tests for query language parsing use firm_lang::parser::query::{ - ParsedDirection, ParsedEntitySelector, ParsedField, ParsedOperation, ParsedQueryValue, - parse_query, + ParsedCombinator, ParsedDirection, ParsedEntitySelector, ParsedField, ParsedOperation, + ParsedQueryValue, parse_query, }; #[test] @@ -69,9 +69,10 @@ fn test_parse_currency_value() { assert!(result.is_ok()); let query = result.unwrap(); - if let Some(ParsedOperation::Where(condition)) = query.operations.first() { + if let Some(ParsedOperation::Where(compound)) = query.operations.first() { + let condition = &compound.conditions[0]; if let ParsedQueryValue::Currency { amount, code } = &condition.value { - assert_eq!(*amount, 5000.50); + assert!((amount - 5000.50).abs() < f64::EPSILON); assert_eq!(code, "USD"); } else { panic!("Expected Currency value"); @@ -86,7 +87,8 @@ fn test_parse_datetime_value() { assert!(result.is_ok()); let query = result.unwrap(); - if let Some(ParsedOperation::Where(condition)) = query.operations.first() { + if let Some(ParsedOperation::Where(compound)) = query.operations.first() { + let condition = &compound.conditions[0]; assert!(matches!(condition.value, ParsedQueryValue::DateTime(_))); } } @@ -98,7 +100,8 @@ fn test_parse_reference_value() { assert!(result.is_ok()); let query = result.unwrap(); - if let Some(ParsedOperation::Where(condition)) = query.operations.first() { + if let Some(ParsedOperation::Where(compound)) = query.operations.first() { + let condition = &compound.conditions[0]; if let ParsedQueryValue::Reference(ref_str) = &condition.value { assert_eq!(ref_str, "person.john_doe"); } else { @@ -114,7 +117,8 @@ fn test_parse_enum_value() { assert!(result.is_ok()); let query = result.unwrap(); - if let Some(ParsedOperation::Where(condition)) = query.operations.first() { + if let Some(ParsedOperation::Where(compound)) = query.operations.first() { + let condition = &compound.conditions[0]; if let ParsedQueryValue::Enum(enum_val) = &condition.value { assert_eq!(enum_val, "completed"); } else { @@ -130,7 +134,8 @@ fn test_parse_path_value() { assert!(result.is_ok()); let query = result.unwrap(); - if let Some(ParsedOperation::Where(condition)) = query.operations.first() { + if let Some(ParsedOperation::Where(compound)) = query.operations.first() { + let condition = &compound.conditions[0]; if let ParsedQueryValue::Path(path_str) = &condition.value { assert_eq!(path_str, "./file.pdf"); } else { @@ -138,3 +143,73 @@ fn test_parse_path_value() { } } } + +#[test] +fn test_parse_compound_condition_or() { + let query_str = "from invoice | where status == \"draft\" or status == \"sent\""; + let result = parse_query(query_str); + assert!(result.is_ok()); + + let query = result.unwrap(); + if let Some(ParsedOperation::Where(compound)) = query.operations.first() { + assert_eq!(compound.conditions.len(), 2); + assert_eq!(compound.combinator, ParsedCombinator::Or); + } else { + panic!("Expected Where operation"); + } +} + +#[test] +fn test_parse_compound_condition_and() { + let query_str = "from task | where is_completed == true and priority > 5"; + let result = parse_query(query_str); + assert!(result.is_ok()); + + let query = result.unwrap(); + if let Some(ParsedOperation::Where(compound)) = query.operations.first() { + assert_eq!(compound.conditions.len(), 2); + assert_eq!(compound.combinator, ParsedCombinator::And); + } else { + panic!("Expected Where operation"); + } +} + +#[test] +fn test_parse_compound_condition_multiple_or() { + let query_str = + "from invoice | where status == \"draft\" or status == \"sent\" or status == \"overdue\""; + let result = parse_query(query_str); + assert!(result.is_ok()); + + let query = result.unwrap(); + if let Some(ParsedOperation::Where(compound)) = query.operations.first() { + assert_eq!(compound.conditions.len(), 3); + assert_eq!(compound.combinator, ParsedCombinator::Or); + } else { + panic!("Expected Where operation"); + } +} + +#[test] +fn test_parse_compound_condition_case_insensitive() { + // Test uppercase OR + let query_str = "from invoice | where status == \"draft\" OR status == \"sent\""; + let query = parse_query(query_str).unwrap(); + if let Some(ParsedOperation::Where(compound)) = query.operations.first() { + assert_eq!(compound.combinator, ParsedCombinator::Or); + } + + // Test uppercase AND + let query_str = "from task | where a == 1 AND b == 2"; + let query = parse_query(query_str).unwrap(); + if let Some(ParsedOperation::Where(compound)) = query.operations.first() { + assert_eq!(compound.combinator, ParsedCombinator::And); + } +} + +#[test] +fn test_parse_compound_condition_mixed_error() { + let query_str = "from task | where a == 1 or b == 2 and c == 3"; + let result = parse_query(query_str); + assert!(result.is_err()); +} diff --git a/firm_mcp/src/tools/dsl_reference_content.rs b/firm_mcp/src/tools/dsl_reference_content.rs index f33d4fc..c1c0266 100644 --- a/firm_mcp/src/tools/dsl_reference_content.rs +++ b/firm_mcp/src/tools/dsl_reference_content.rs @@ -175,7 +175,20 @@ from * # Select all entities (wildcard) ```bash from task | where is_completed == false from * | where @type == "task" -from task | where is_completed == false | where priority > 5 +``` + +**Compound conditions** - combine with `and` or `or`: + +```bash +from invoice | where status == "draft" or status == "sent" +from task | where is_completed == false and priority > 5 +``` + +You cannot mix `and` and `or` in the same clause. Use separate `where` clauses: + +```bash +# (draft OR sent) AND (amount > 1000) +from invoice | where status == "draft" or status == "sent" | where amount > 1000 ``` **Operators:** `==`, `!=`, `>`, `<`, `>=`, `<=`, `contains`, `startswith`, `endswith`, `in`