Skip to content

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

yaml
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.

yaml
from: entities # or data.items; omit for the whole step output

join

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.

yaml
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 groupall, any, or not, each nesting more conditions.

The operator catalog and nesting rules are in Filtering.

yaml
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.

yaml
groupBy: type # or [type, status] for a composite key

aggregate

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.

yaml
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.

yaml
having: { field: n, gt: 1 } # references an aggregate column

select

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.

yaml
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.

yaml
sort: [{ field: score, dir: desc }]

limit

An integer cap on the number of rows, applied after sort.

yaml
limit: 3

distinct

true dedupes the result rows by deep equality. Works in both modes. See distinct.

yaml
select: type
distinct: true

jsonata

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.

yaml
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.

Released under the MIT License.