Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions docs/src/guide/querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
31 changes: 30 additions & 1 deletion docs/src/reference/query-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
178 changes: 178 additions & 0 deletions firm_core/src/graph/query/filter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<FilterCondition>,
pub combinator: Combinator,
}

impl CompoundFilterCondition {
/// Create a new compound filter condition
pub fn new(conditions: Vec<FilterCondition>, 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<bool, QueryError> {
let results: Result<Vec<bool>, 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,
Expand Down Expand Up @@ -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());
}
}
8 changes: 8 additions & 0 deletions firm_core/src/graph/query/filter/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
30 changes: 18 additions & 12 deletions firm_core/src/graph/query/types.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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),
),
)),
);

Expand All @@ -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();
Expand Down
25 changes: 20 additions & 5 deletions firm_lang/src/convert/to_query.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -55,16 +55,31 @@ impl TryFrom<ParsedQuery> for Query {

fn convert_operation(parsed: ParsedOperation) -> Result<QueryOperation, QueryConversionError> {
match parsed {
ParsedOperation::Where(condition) => {
let filter_condition = convert_condition(condition)?;
Ok(QueryOperation::Where(filter_condition))
ParsedOperation::Where(compound) => {
let conditions: Result<Vec<FilterCondition>, _> = 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),
ParsedOperation::Related { degree, selector } => convert_related(degree, selector),
}
}

fn convert_combinator(parsed: ParsedCombinator) -> Combinator {
match parsed {
ParsedCombinator::And => Combinator::And,
ParsedCombinator::Or => Combinator::Or,
}
}

fn convert_condition(parsed: ParsedCondition) -> Result<FilterCondition, QueryConversionError> {
let field = convert_field(parsed.field);
let operator = convert_operator(parsed.operator);
Expand Down
13 changes: 10 additions & 3 deletions firm_lang/src/parser/query/grammar.pest
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = @{
Expand Down
Loading