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
27 changes: 26 additions & 1 deletion docs/src/guide/querying.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ For deeper insights, use `firm query` which supports a SQL-like query language.
### Query syntax

```
from <type> | <operation> | <operation> | ...
from <type> | <operation> | <operation> | ... | <aggregation>
```

### Available operations
Expand All @@ -53,6 +53,16 @@ from <type> | <operation> | <operation> | ...
- `order <field> [asc|desc]` - Sort results
- `limit <n>` - Limit the number of results

### Aggregations

An optional final clause that summarizes the result set:

- `select <field>, ...` - Extract specific field values
- `count [<field>]` - Count entities (optionally only those with the field)
- `sum <field>` - Sum a numeric field
- `average <field>` - Compute the mean of a numeric field
- `median <field>` - Compute the median of a numeric field

### Examples

**Find all incomplete tasks:**
Expand All @@ -75,6 +85,21 @@ $ firm query 'from invoice | where status == "draft" or status == "sent"'
$ 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'
```

**Count incomplete tasks:**
```bash
$ firm query 'from task | where is_completed == false | count'
```

**Sum invoice amounts:**
```bash
$ firm query 'from invoice | where status == "sent" | sum amount'
```

**Extract specific fields:**
```bash
$ firm query 'from task | where is_completed == false | select @id, name, due_date'
```

### Query operators

You can filter by any field or metadata (`@type`, `@id`), traverse relationships multiple degrees deep, and compose operations to build the exact query you need.
Expand Down
121 changes: 114 additions & 7 deletions docs/src/reference/query-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,17 @@ Firm queries always operate on a "bag of entities". At every stage in query exec

The `from` clause selects the initial set of entities, and every subsequent operation filters, expands, limits, or orders that entity set. This keeps the query language simple and focused on navigating the entity graph.

Optionally, a final **aggregation** clause can be added at the end of a query to compute a summary value (like a count or sum) or extract specific fields from the final entity set. Aggregations are the only operation that transforms the result from entities into a different shape.

## Basic syntax

All queries follow this structure:

```
from <entity_selector> | <operation> | <operation> | ...
from <entity_selector> | <operation> | <operation> | ... | <aggregation>
```

Start with a `from` clause, then chain operations using the pipe symbol `|`.
Start with a `from` clause, then chain operations using the pipe symbol `|`. Optionally end with an aggregation clause.

## Entity selector

Expand Down Expand Up @@ -200,6 +202,85 @@ from task | where priority > 8 | order priority desc | limit 5

**Syntax:** `limit <number>`

## Aggregations

Aggregations are optional clauses that go at the end of a query. They transform the entity set into a summary value or extracted fields. Only one aggregation can be used per query.

### select

Extract specific field values from entities:

```bash
# Select a single field
from person | select name

# Select multiple fields
from task | select name, status, due_date

# Include metadata fields
from task | where is_completed == false | select @id, name, due_date
```

**Syntax:** `select <field>, <field>, ...`

Fields can be regular field names or metadata fields (`@id`, `@type`). Missing fields appear as empty values.

### count

Count entities, optionally filtering by field presence:

```bash
# Count all matching entities
from task | where is_completed == false | count

# Count entities that have a specific field
from person | count email
```

**Syntax:**
- `count` - Count all entities in the result set
- `count <field>` - Count entities that have the specified field

### sum

Sum numeric field values across entities:

```bash
# Sum integer or float fields
from line_item | sum quantity

# Sum currency fields
from invoice | where status == "sent" | sum amount
```

**Syntax:** `sum <field>`

Works with integer, float, and currency fields. Entities missing the field are skipped. Currency values must all share the same currency code — mixed currencies produce an error.

### average

Compute the mean of a numeric field:

```bash
from task | average estimated_hours
```

**Syntax:** `average <field>`

Works with integer, float, and currency fields. Entities missing the field are skipped. Returns an error if no entities have the field.

### median

Compute the median of a numeric field:

```bash
from task | median estimated_hours
```

**Syntax:** `median <field>`

Works with integer, float, and currency fields. Entities missing the field are skipped. For an even number of values, returns the average of the two middle values. Returns an error if no entities have the field.

## Examples

### Find incomplete tasks
Expand All @@ -226,6 +307,24 @@ from opportunity | where value >= 10000.00 USD | order value desc
from project | where status == "active" | related task
```

### Count incomplete tasks

```bash
from task | where is_completed == false | count
```

### Total invoice amount

```bash
from invoice | where status == "sent" | sum amount
```

### Task summary with select

```bash
from task | where is_completed == false | order due_date | select @id, name, due_date
```

### Complex multi-hop query

```bash
Expand All @@ -244,11 +343,19 @@ This query:
Queries are executed left to right, with each operation transforming the result set:

```
from task → [all tasks]
| where status → [filtered tasks]
| related project → [related projects]
| order name → [sorted projects]
| limit 5 → [top 5 projects]
from task → [all tasks]
| where is_completed == false → [filtered tasks]
| related project → [related projects]
| order name → [sorted projects]
| limit 5 → [top 5 projects]
```

Each operation receives the output of the previous operation and produces a new result set.

If an aggregation is present, it runs last and transforms the entity set into a result value:

```
from invoice → [all invoices]
| where status == "sent" → [filtered invoices]
| sum amount → 15000.00 USD
```
23 changes: 13 additions & 10 deletions firm_cli/src/commands/query.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use std::path::PathBuf;

use firm_core::graph::Query;
use firm_core::graph::{Query, QueryResult};
use firm_lang::parser::query::parse_query;

use crate::errors::CliError;
Expand Down Expand Up @@ -30,21 +30,24 @@ pub fn query_entities(

// Execute the query
ui::debug("Executing query");
let results = query.execute(&graph).map_err(|e| {
let result = query.execute(&graph).map_err(|e| {
ui::error(&format!("Query execution failed: {}", e));
CliError::QueryError
})?;

ui::success(&format!("Query returned {} entities", results.len()));

// Output results
match output_format {
OutputFormat::Pretty => {
ui::pretty_output_entity_list(&results);
}
OutputFormat::Json => {
ui::json_output(&results);
match result {
QueryResult::Entities(entities) => {
ui::success(&format!("Query returned {} entities", entities.len()));
match output_format {
OutputFormat::Pretty => ui::pretty_output_entity_list(&entities),
OutputFormat::Json => ui::json_output(&entities),
}
}
QueryResult::Aggregation(agg_result) => match output_format {
OutputFormat::Pretty => ui::raw_output(&agg_result.to_string()),
OutputFormat::Json => ui::json_output(&agg_result),
},
}

Ok(())
Expand Down
113 changes: 113 additions & 0 deletions firm_core/src/graph/query/aggregation/average.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
//! Average aggregation: compute the mean of a numeric field

use super::super::filter::FieldRef;
use super::super::types::AggregationResult;
use super::super::QueryError;
use super::{collect_numeric_values, require_regular_field};
use crate::Entity;

pub fn execute(
field: &FieldRef,
entities: &[&Entity],
) -> Result<AggregationResult, QueryError> {
let field_id = require_regular_field(field, "average")?;
let values = collect_numeric_values(field_id, entities)?;

if values.is_empty() {
return Err(QueryError::InvalidAggregation {
message: "Cannot compute average of empty result set".to_string(),
});
}

let sum: f64 = values.iter().map(|v| v.as_f64()).sum();
let avg = sum / values.len() as f64;

Ok(AggregationResult::Average(avg))
}

#[cfg(test)]
mod tests {
use super::*;
use crate::{Entity, EntityId, EntityType, FieldId, FieldValue};

#[test]
fn test_average_integers() {
let entities = vec![
Entity::new(EntityId::new("a"), EntityType::new("item"))
.with_field(FieldId::new("val"), FieldValue::Integer(10)),
Entity::new(EntityId::new("b"), EntityType::new("item"))
.with_field(FieldId::new("val"), FieldValue::Integer(20)),
Entity::new(EntityId::new("c"), EntityType::new("item"))
.with_field(FieldId::new("val"), FieldValue::Integer(30)),
];
let refs: Vec<&Entity> = entities.iter().collect();
let field = FieldRef::Regular(FieldId::new("val"));
let result = execute(&field, &refs).unwrap();
assert_eq!(result, AggregationResult::Average(20.0));
}

#[test]
fn test_average_floats() {
let entities = vec![
Entity::new(EntityId::new("a"), EntityType::new("item"))
.with_field(FieldId::new("val"), FieldValue::Float(1.0)),
Entity::new(EntityId::new("b"), EntityType::new("item"))
.with_field(FieldId::new("val"), FieldValue::Float(2.0)),
];
let refs: Vec<&Entity> = entities.iter().collect();
let field = FieldRef::Regular(FieldId::new("val"));
let result = execute(&field, &refs).unwrap();
assert_eq!(result, AggregationResult::Average(1.5));
}

#[test]
fn test_average_mixed_integer_and_float() {
let entities = vec![
Entity::new(EntityId::new("a"), EntityType::new("item"))
.with_field(FieldId::new("val"), FieldValue::Integer(10)),
Entity::new(EntityId::new("b"), EntityType::new("item"))
.with_field(FieldId::new("val"), FieldValue::Float(20.0)),
];
let refs: Vec<&Entity> = entities.iter().collect();
let field = FieldRef::Regular(FieldId::new("val"));
let result = execute(&field, &refs).unwrap();
assert_eq!(result, AggregationResult::Average(15.0));
}

#[test]
fn test_average_skips_missing_fields() {
let entities = vec![
Entity::new(EntityId::new("a"), EntityType::new("item"))
.with_field(FieldId::new("val"), FieldValue::Integer(10)),
Entity::new(EntityId::new("b"), EntityType::new("item")),
];
let refs: Vec<&Entity> = entities.iter().collect();
let field = FieldRef::Regular(FieldId::new("val"));
let result = execute(&field, &refs).unwrap();
// Only 1 entity has the field, so average = 10/1
assert_eq!(result, AggregationResult::Average(10.0));
}

#[test]
fn test_average_empty_result_set_error() {
let refs: Vec<&Entity> = vec![];
let field = FieldRef::Regular(FieldId::new("val"));
let result = execute(&field, &refs);
assert!(matches!(
result,
Err(QueryError::InvalidAggregation { .. })
));
}

#[test]
fn test_average_no_entities_with_field_error() {
let entities = vec![Entity::new(EntityId::new("a"), EntityType::new("item"))];
let refs: Vec<&Entity> = entities.iter().collect();
let field = FieldRef::Regular(FieldId::new("nonexistent"));
let result = execute(&field, &refs);
assert!(matches!(
result,
Err(QueryError::InvalidAggregation { .. })
));
}
}
Loading