PureQL is a JSON-based declarative query language for relational data. Queries are plain JSON objects validated against a JSON Schema, making them easy to generate programmatically, serialize, transmit, and validate without a parser.
| Property | Required | Description |
|---|---|---|
from |
yes | Entity (table) to query from, with optional alias |
select |
yes | Array of expressions to return |
where |
no | Boolean filter applied before grouping |
joins |
no | Array of join clauses |
groupBy |
no | Fields to group rows by |
having |
no | Boolean filter applied after grouping |
orderBy |
no | Fields to order results by |
pagination |
no | skip and take for paging |
distinct |
no | When true, deduplicate result rows (default: false) |
| Type name | JSON representation |
|---|---|
string |
"name": "string" |
number |
"name": "number" |
boolean |
"name": "boolean" |
null |
"name": "null" |
date |
"name": "date" — ISO 8601 date string |
time |
"name": "time" — ISO 8601 time string |
datetime |
"name": "datetime" — ISO 8601 date-time string |
uuid |
"name": "uuid" — UUID string |
Each scalar type has a corresponding array variant: stringArray, numberArray, booleanArray, nullArray, dateArray, timeArray, datetimeArray, uuidArray.
A field reference selects a column from an entity. The entity value should match the entity name (or from alias). Fields are typed using the scalar type name.
{ "entity": "users", "field": "email", "type": { "name": "string" } }Fields carry their type as an array type in the schema — they represent a column of values. Use them in select, groupBy, orderBy, as operands of per-row predicates (eachEqual, eachGreaterThan, etc.), and as arguments to aggregate functions.
A scalar is a literal constant value embedded in the query.
{ "type": { "name": "number" }, "value": 42 }
{ "type": { "name": "string" }, "value": "active" }
{ "type": { "name": "date" }, "value": "2024-01-01" }Array scalars hold an array of literal values:
{ "type": { "name": "stringArray" }, "value": ["active", "pending"] }Parameters are named placeholders resolved at execution time, analogous to prepared statement bindings.
{ "param_name": "user_role", "type": { "name": "stringArray" } }
{ "param_name": "min_amount", "type": { "name": "number" } }{ "entity": "orders" }
{ "entity": "orders", "alias": "o" }Each item in select is a value-returning expression (field, scalar, aggregate, arithmetic, boolean expression) with an optional alias.
"select": [
{ "entity": "users", "field": "name", "type": { "name": "string" } },
{ "entity": "users", "field": "email", "type": { "name": "string" }, "alias": "contact_email" },
{ "operator": "count", "arg": { "entity": "orders", "field": "id", "type": { "name": "uuid" } }, "alias": "order_count" }
]They accept different shapes because they evaluate in different scopes:
whereis evaluated per row. It accepts either a boolean-returning expression (single boolean) or a boolean-array-returning expression (per-row boolean column). Use theeach*family for per-row predicates against fields.havingis evaluated per group, aftergroupBy. It accepts only a boolean-returning expression. Operands must reduce to a single value per group — typically aggregates compared withgreaterThan/equal/ etc. Per-roweach*operators do not fit inhaving.
where example using per-row predicates:
"where": {
"operator": "eachAnd",
"conditions": [
{ "operator": "eachEqual",
"left": { "entity": "orders", "field": "status", "type": { "name": "string" } },
"right": { "type": { "name": "string" }, "value": "completed" } },
{ "operator": "eachEqual",
"left": { "entity": "orders", "field": "is_paid", "type": { "name": "boolean" } },
"right": { "type": { "name": "boolean" }, "value": true } }
]
}Each join specifies its type (inner, left, right, full), the entity to join, and an on condition. Like where, on accepts either a boolean-returning or a boolean-array-returning expression. For column-to-column matching (the typical join), use eachEqual:
"joins": [
{
"type": "inner",
"entity": "users",
"on": {
"operator": "eachEqual",
"left": { "entity": "o", "field": "user_id", "type": { "name": "uuid" } },
"right": { "entity": "users", "field": "id", "type": { "name": "uuid" } }
}
}
]groupBy accepts an array of field references. orderBy accepts an array of orderByItem objects, each pairing a field with an optional direction ("asc" | "desc", default "asc").
"groupBy": [
{ "entity": "orders", "field": "user_id", "type": { "name": "uuid" } }
],
"orderBy": [
{ "field": { "entity": "users", "field": "name", "type": { "name": "string" } }, "direction": "asc" },
{ "field": { "entity": "orders", "field": "total", "type": { "name": "number" } }, "direction": "desc" }
]"pagination": { "skip": 0, "take": 25 }Every operator in the schema belongs to one of two parallel families that you choose between based on whether you're working with single values or with columns/rows:
| Family | Operands | Result | Operators |
|---|---|---|---|
| Single-value | reduce to one value per query (or per group) | one value | and, or, not, equal, greaterThan/lessThan/…, add/subtract/multiply/divide, aggregates |
Per-row (each*) |
at least one operand is a field or per-row computed column | one value per row | eachAnd/eachOr/eachNot, eachEqual, eachGreaterThan/eachLessThan/…, eachAdd/eachSubtract/eachMultiply/eachDivide, eachDateAddDays/eachDateDiffDays, eachTimeAddSeconds/eachTimeDiffSeconds, eachDatetimeAddSeconds/eachDatetimeDiffSeconds |
Where each family fits:
| Clause | Accepts |
|---|---|
where / join.on |
per-row boolean (typical) or single-value boolean |
having |
single-value boolean only — non-aggregated fields are structurally rejected |
select |
any value-returning expression, including per-row computed columns |
sum.arg / min_*.arg / max_*.arg / average_*.arg |
any array-returning expression (field, per-row computation) |
right operand of any each* comparison |
matching *Returning (broadcast scalar) or *ArrayReturning (element-wise) |
Do not mix families inside the same boolean operator. and/or/not accept only single-value boolean children; eachAnd/eachOr/eachNot accept only per-row boolean children. The schema enforces this via type.
| Operator | Shape |
|---|---|
and |
{ "operator": "and", "conditions": [ ...booleanReturning ] } |
or |
{ "operator": "or", "conditions": [ ...booleanReturning ] } |
not |
{ "operator": "not", "condition": booleanReturning } |
Conditions must be single-boolean expressions: a boolean scalar, parameter, single-value equality, or single-value comparison. Typical use: combining aggregate comparisons in having.
"having": {
"operator": "and",
"conditions": [
{ "operator": "greaterThan",
"left": { "operator": "count", "arg": { "entity": "orders", "field": "id", "type": { "name": "uuid" } } },
"right": { "type": { "name": "number" }, "value": 5 } }
]
}| Operator | Shape |
|---|---|
eachAnd |
{ "operator": "eachAnd", "conditions": [ ...booleanArrayReturning ] } |
eachOr |
{ "operator": "eachOr", "conditions": [ ...booleanArrayReturning ] } |
eachNot |
{ "operator": "eachNot", "condition": booleanArrayReturning } |
These combine per-row boolean columns element-wise. The result is also a per-row boolean column. Use them to compose multiple each* predicates in where or join.on. There is no dedicated eachNotEqual — express it as eachNot(eachEqual(...)).
Compares two single-value expressions of the same type and returns one boolean. Useful in having against aggregates, or anywhere both operands reduce to scalars.
{
"operator": "equal",
"left": { "operator": "max_number", "arg": { "entity": "orders", "field": "total", "type": { "name": "number" } } },
"right": { "param_name": "target_max", "type": { "name": "number" } }
}The same equal operator, when both sides are array-returning expressions of the same type, asks "are these two sequences equal as wholes?" and returns one boolean. This is rarely needed; most "field equals value" filtering should use eachEqual instead.
{
"operator": "equal",
"left": { "param_name": "expected_ids", "type": { "name": "uuidArray" } },
"right": { "param_name": "received_ids", "type": { "name": "uuidArray" } }
}For each row, returns true when left equals right. The left operand is an array-returning expression (typically a field). The right operand is either a single-value-returning expression (the threshold/literal case) or another array-returning expression (element-wise field-to-field comparison).
Supported types: boolean, number, string, date, time, datetime, uuid.
Field-to-literal:
{
"operator": "eachEqual",
"left": { "entity": "users", "field": "role", "type": { "name": "string" } },
"right": { "type": { "name": "string" }, "value": "admin" }
}Field-to-field (per-row, used in joins or cross-column filters):
{
"operator": "eachEqual",
"left": { "entity": "orders", "field": "user_id", "type": { "name": "uuid" } },
"right": { "entity": "users", "field": "id", "type": { "name": "uuid" } }
}Range comparisons on single-value-returning expressions (scalars, parameters, aggregates). They return a boolean.
| Operator | Meaning |
|---|---|
greaterThan |
> |
lessThan |
< |
greaterThanOrEqual |
>= |
lessThanOrEqual |
<= |
Supported types: number, string, date, time, datetime.
{
"operator": "greaterThan",
"left": { "operator": "sum", "arg": { "entity": "orders", "field": "total", "type": { "name": "number" } } },
"right": { "type": { "name": "number" }, "value": 500 }
}Per-row analogues of the range comparisons. left is array-returning (typically a field), right is either single-value-returning (one threshold for all rows) or array-returning (element-wise field-to-field).
| Operator | Meaning |
|---|---|
eachGreaterThan |
> |
eachLessThan |
< |
eachGreaterThanOrEqual |
>= |
eachLessThanOrEqual |
<= |
Supported types: number, string, date, time, datetime.
Field vs literal:
{
"operator": "eachGreaterThan",
"left": { "entity": "orders", "field": "total", "type": { "name": "number" } },
"right": { "type": { "name": "number" }, "value": 100 }
}Field vs field (per-row):
{
"operator": "eachGreaterThan",
"left": { "entity": "order_items", "field": "unit_price", "type": { "name": "number" } },
"right": { "entity": "order_items", "field": "sale_price", "type": { "name": "number" } }
}Combines single-value-returning numeric expressions. Use aggregates to bridge field data into single-value arithmetic.
| Operator | Meaning |
|---|---|
add |
+ |
subtract |
- |
multiply |
* |
divide |
/ |
values is an array with at least 2 operands. For subtract and divide, evaluation is left-to-right ([a, b, c] means a - b - c / a / b / c).
{
"operator": "multiply",
"values": [
{ "operator": "sum", "arg": { "entity": "orders", "field": "amount", "type": { "name": "number" } } },
{ "type": { "name": "number" }, "value": 0.9 }
],
"alias": "discounted_total"
}Per-row analogues of arithmetic. Each values[i] is numericReturning (broadcast scalar) or numericArrayReturning (zipped per-row column). Result is a numeric column aligned with the row set.
Use in select for computed columns, inside aggregates (sum(eachMultiply(unit_price, quantity))), or on the right side of any each* comparison.
{
"operator": "eachMultiply",
"values": [
{ "entity": "order_items", "field": "unit_price", "type": { "name": "number" } },
{ "entity": "order_items", "field": "quantity", "type": { "name": "number" } }
],
"alias": "subtotal"
}Any each* slot that accepts both *Returning and *ArrayReturning follows the same evaluation rule. The surrounding row set fixes a row count N (from from + joins + where, or from the group size when nested inside an aggregate after groupBy). Then:
- Each
*Returningoperand is broadcast — repeatedNtimes so it has one value per row. - Each
*ArrayReturningoperand is already aligned withNrows by construction (same query context). - The operator runs element-wise across all operands, producing a length-
Nresult vector.
So eachAdd([fieldA, scalar, fieldB]) over three rows with fieldA = [10, 20, 30], scalar = 5, fieldB = [1, 2, 3] evaluates to [16, 27, 38]. This is what makes patterns like eachMultiply(unit_price, 1.05) (5% per-row markup) and eachAdd(base_price, tax, shipping) (sum three columns per row) work naturally.
The return type is always *ArrayReturning even if every operand is a scalar — eachAdd(2, 3) in a select over a 4-row table yields the vector [5, 5, 5, 5], not the scalar 5. Use the single-value add for purely scalar work.
Per-row date arithmetic, days as the unit.
| Operator | Shape | Returns |
|---|---|---|
eachDateAddDays |
{ left: date, right: number } |
date (per row) |
eachDateDiffDays |
{ left: date, right: date } |
number (per row) |
left / right accept the broadcast vs zipped polymorphism (*Returning | *ArrayReturning).
{
"operator": "eachDateAddDays",
"left": { "entity": "orders", "field": "order_date", "type": { "name": "date" } },
"right": { "type": { "name": "number" }, "value": 30 },
"alias": "delivery_eta"
}Per-row datetime arithmetic, seconds as the unit. Larger units are expressed by composing with eachMultiply (e.g. add N hours via eachDatetimeAddSeconds(dt, eachMultiply(n, 3600))).
| Operator | Shape | Returns |
|---|---|---|
eachDatetimeAddSeconds |
{ left: datetime, right: number } |
datetime (per row) |
eachDatetimeDiffSeconds |
{ left: datetime, right: datetime } |
number (per row) |
{
"operator": "eachGreaterThan",
"left": {
"operator": "eachDatetimeDiffSeconds",
"left": { "entity": "orders", "field": "shipped_at", "type": { "name": "datetime" } },
"right": { "entity": "orders", "field": "ordered_at", "type": { "name": "datetime" } }
},
"right": { "type": { "name": "number" }, "value": 172800 }
}Per-row time-of-day arithmetic, seconds as the unit. Same broadcast / zip rules as the rest of the each* family.
| Operator | Shape | Returns |
|---|---|---|
eachTimeAddSeconds |
{ left: time, right: number } |
time (per row) |
eachTimeDiffSeconds |
{ left: time, right: time } |
number (per row) |
eachTimeAddSeconds overflow behaviour around 00:00:00 (wrap, saturate, error) is interpreter-defined — the schema doesn't constrain it.
{
"operator": "eachTimeAddSeconds",
"left": { "entity": "shifts", "field": "clock_in", "type": { "name": "time" } },
"right": { "type": { "name": "number" }, "value": 1800 },
"alias": "break_start"
}Aggregates reduce an array of field values to a single value. The arg is any array-returning expression (typically a field reference).
| Operator | Input type | Returns |
|---|---|---|
count |
any array | number |
sum |
number | number |
average_number |
number | number |
min_number |
number | number |
max_number |
number | number |
min_string |
string | string |
max_string |
string | string |
min_date |
date | date |
max_date |
date | date |
average_date |
date | date |
min_time |
time | time |
max_time |
time | time |
average_time |
time | time |
min_datetime |
datetime | datetime |
max_datetime |
datetime | datetime |
average_datetime |
datetime | datetime |
{ "operator": "sum", "arg": { "entity": "items", "field": "price", "type": { "name": "number" } } }
{ "operator": "count", "arg": { "entity": "items", "field": "id", "type": { "name": "uuid" } } }
{ "operator": "max_date","arg": { "entity": "orders","field": "order_date","type": { "name": "date" } } }The samples/ directory contains query examples ordered by complexity.
| File | Description |
|---|---|
01_simple_select.json |
Select several fields from a single entity |
02_aliases_and_pagination.json |
from alias, field aliases, and pagination |
03_where_equality.json |
Filter rows with a single eachEqual condition |
04_boolean_logic.json |
Nested eachAnd / eachOr / eachNot over field equalities |
05_joins.json |
inner and left joins using eachEqual for per-row key matching |
06_count_aggregate.json |
count aggregate with a where filter |
07_group_by.json |
Multiple aggregates with groupBy |
08_having.json |
having clause with and of two aggregate comparisons |
09_arithmetic.json |
add, multiply, divide on aggregate results |
10_parameters.json |
Named scalar parameters in per-row predicates |
11_distinct.json |
distinct: true to deduplicate results |
12_complex_query.json |
Full query: joins, per-row where, groupBy, single-value having, arithmetic, parameters, orderBy with direction, pagination |
13_range_filter.json |
eachGreaterThan and eachLessThan combined with eachAnd |
14_each_field_to_field.json |
Per-row range comparison between two fields (no scalar threshold) |
15_each_not_equal.json |
eachNot wrapping eachEqual — the idiom for "field ≠ literal" |
16_each_or_composition.json |
Mixing eachEqual and eachGreaterThan via eachAnd + eachOr |
17_each_arithmetic_select.json |
eachMultiply of two fields as a computed select column (subtotal) |
18_aggregate_of_each_multiply.json |
sum(eachMultiply(unit_price, quantity)) grouped by user — line-item revenue |
19_each_date_add_days.json |
eachDateAddDays to derive a delivery_eta column from order_date + 30 days |
20_each_datetime_diff_where.json |
eachDatetimeDiffSeconds inside eachGreaterThan to filter orders by ship-time |
21_each_time_math.json |
eachTimeAddSeconds (time + offset) and eachTimeDiffSeconds (shift duration) |