The query AST
A StepQL query is a single object. Every field is optional; which ones you set decide what the query does. This page breaks down each field — what it accepts and where the full rules live.
Shape at a glance
from: <path> # base path into the step output ($ if omitted)
join: { step, on, select, type } # graft another step's columns onto each row
where: <condition> # leaf comparison(s), optionally nested with all/any/not
groupBy: <field> | [<field>, …] # group rows by one or more fields
aggregate: count | { op, field } | { alias: spec, … } # count/sum/avg/min/max
having: <condition> # filter the grouped/aggregated rows
select: <fields> # project — single field, list, or alias map
sort: <field-or-list> # asc by default; { field, dir } for desc
limit: <integer>
distinct: true # dedupe the result rows
jsonata: <string> # escape hatch (mutually exclusive with structured fields)The order you write the keys in doesn't matter — the compiler emits stages in a fixed order (see Evaluation order below).
The fields
from
The base path into the queried step's output — a dotted path like entities or data.items. Omit it and the base is the whole step output ($). Every other field operates on the rows from selects.
from: entities # or data.items; omit for the whole step outputjoin
Graft another step's columns onto each row before filtering and projection, so later fields can use them. Shape: { step, on, select, type }. Full semantics — key matching, left vs inner, collisions — in Cross-step queries.
join: { step: classify, on: id, select: [label], type: left }where
Keep only rows that match a condition. A condition is either:
- a leaf —
{ field, <operator> }, exactly one operator per leaf, or - a group —
all,any, ornot, each nesting more conditions.
The operator catalog and nesting rules are in Filtering.
where:
all:
- { field: type, eq: Actor }
- { field: score, gte: 0.5 }groupBy
Collapse rows into groups by one field, or a list of fields for a composite key. Setting it puts the query in aggregate mode.
groupBy: type # or [type, status] for a composite keyaggregate
Reduce each group (or the whole set) to a value: count, { op, field } for sum/avg/min/max, or an { alias: spec, … } map for one column per alias. Setting it puts the query in aggregate mode. See Grouping & aggregation.
aggregate: { n: count, total: { op: sum, field: score } }having
Filter grouped rows by their aggregate columns — the aggregate-mode counterpart to where, with the same condition grammar. Only valid in aggregate mode. See Multiple aggregates, and having.
having: { field: n, gt: 1 } # references an aggregate columnselect
Project the output: a single field, a list of fields, or an alias map that renames columns and can apply computed ops (upper, round, count, …). See select shapes.
select: { who: { upper: name }, tags: { count: tags } }sort
Order rows: a field name (ascending), { field, dir: desc } for descending, or a list to sort by several keys in turn. See sort shapes.
sort: [{ field: score, dir: desc }]limit
An integer cap on the number of rows, applied after sort.
limit: 3distinct
true dedupes the result rows by deep equality. Works in both modes. See distinct.
select: type
distinct: truejsonata
The escape hatch: a raw JSONata string, run verbatim. Mutually exclusive with every structured field above — set jsonata or the structured fields, never both. See Raw JSONata.
jsonata: '$sort(entities, function($a, $b) { $a.score < $b.score }).name'Two modes
A query is in aggregate mode when it sets groupBy and/or aggregate; otherwise it's in row mode. The mode decides which fields apply and the order they run in — having exists only in aggregate mode, and grouping happens after where but before projection.
Evaluation order
Whatever order you write the keys, the compiler emits the stages like this:
- Row mode:
base → where → sort → limit → select → distinct - Aggregate mode:
base → where → groupBy/aggregate → having → sort → limit → distinct
(base is from applied to the queried step, with any join grafted on first.)
The schema
The AST is one Zod schema (QueryAst), which is also exported as a JSON Schema (queryAstJsonSchema) from @stepbook/query — so the same definition drives the compiler, runtime validation, and editor tooling. An invalid combination (a where leaf with no operator, jsonata alongside structured fields) is rejected up front rather than producing a broken query.