Skip to content

Repository files navigation

PureQL Specification

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.

Quick Reference

PropertyRequiredDescription
fromyesEntity (table) to query from, with optional alias
selectyesArray of expressions to return
wherenoBoolean filter applied before grouping
joinsnoArray of join clauses
groupBynoFields to group rows by
havingnoBoolean filter applied after grouping
orderBynoFields to order results by
paginationnoskip and take for paging
distinctnoWhen true, deduplicate result rows (default: false)

Type System

Scalar types

Type nameJSON 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

Array types

Each scalar type has a corresponding array variant: stringArray, numberArray, booleanArray, nullArray, dateArray, timeArray, datetimeArray, uuidArray.


Core Concepts

Field references

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.

Scalars

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

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" } }

Query Clauses

from

{ "entity": "orders" }
{ "entity": "orders", "alias": "o" }

select

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" }
]

where / having

They accept different shapes because they evaluate in different scopes:

  • where is evaluated per row. It accepts either a boolean-returning expression (single boolean) or a boolean-array-returning expression (per-row boolean column). Use the each* family for per-row predicates against fields.
  • having is evaluated per group, after groupBy. It accepts only a boolean-returning expression. Operands must reduce to a single value per group — typically aggregates compared with greaterThan / equal / etc. Per-row each* operators do not fit in having.

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 } }
]
}

joins

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 / orderBy

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

"pagination": { "skip": 0, "take": 25 }

Operations

Two operator families

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:

FamilyOperandsResultOperators
Single-valuereduce to one value per query (or per group)one valueand, or, not, equal, greaterThan/lessThan/…, add/subtract/multiply/divide, aggregates
Per-row (each*)at least one operand is a field or per-row computed columnone value per roweachAnd/eachOr/eachNot, eachEqual, eachGreaterThan/eachLessThan/…, eachAdd/eachSubtract/eachMultiply/eachDivide, eachDateAddDays/eachDateDiffDays, eachTimeAddSeconds/eachTimeDiffSeconds, eachDatetimeAddSeconds/eachDatetimeDiffSeconds

Where each family fits:

ClauseAccepts
where / join.onper-row boolean (typical) or single-value boolean
havingsingle-value boolean only — non-aggregated fields are structurally rejected
selectany value-returning expression, including per-row computed columns
sum.arg / min_*.arg / max_*.arg / average_*.argany array-returning expression (field, per-row computation)
right operand of any each* comparisonmatching *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.

Single-value boolean operations

OperatorShape
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 } }
]
}

Per-row boolean operations (eachAnd / eachOr / eachNot)

OperatorShape
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(...)).

Single-value equality (equal)

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" } }
}

Whole-sequence equality (equal on arrays)

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" } }
}

Per-row equality (eachEqual)

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" } }
}

Single-value range comparisons

Range comparisons on single-value-returning expressions (scalars, parameters, aggregates). They return a boolean.

OperatorMeaning
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 range comparisons (each*)

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

OperatorMeaning
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" } }
}

Single-value arithmetic

Combines single-value-returning numeric expressions. Use aggregates to bridge field data into single-value arithmetic.

OperatorMeaning
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 arithmetic (eachAdd / eachSubtract / eachMultiply / eachDivide)

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"
}

Broadcast and zip: mixing single-value and array operands

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 *Returning operand is broadcast — repeated N times so it has one value per row.
  • Each *ArrayReturning operand is already aligned with N rows by construction (same query context).
  • The operator runs element-wise across all operands, producing a length-N result 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.

Date math (eachDateAddDays / eachDateDiffDays)

Per-row date arithmetic, days as the unit.

OperatorShapeReturns
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"
}

Datetime math (eachDatetimeAddSeconds / eachDatetimeDiffSeconds)

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

OperatorShapeReturns
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 }
}

Time math (eachTimeAddSeconds / eachTimeDiffSeconds)

Per-row time-of-day arithmetic, seconds as the unit. Same broadcast / zip rules as the rest of the each* family.

OperatorShapeReturns
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

Aggregates reduce an array of field values to a single value. The arg is any array-returning expression (typically a field reference).

OperatorInput typeReturns
countany arraynumber
sumnumbernumber
average_numbernumbernumber
min_numbernumbernumber
max_numbernumbernumber
min_stringstringstring
max_stringstringstring
min_datedatedate
max_datedatedate
average_datedatedate
min_timetimetime
max_timetimetime
average_timetimetime
min_datetimedatetimedatetime
max_datetimedatetimedatetime
average_datetimedatetimedatetime
{ "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" } } }

Samples

The samples/ directory contains query examples ordered by complexity.

FileDescription
01_simple_select.jsonSelect several fields from a single entity
02_aliases_and_pagination.jsonfrom alias, field aliases, and pagination
03_where_equality.jsonFilter rows with a single eachEqual condition
04_boolean_logic.jsonNested eachAnd / eachOr / eachNot over field equalities
05_joins.jsoninner and left joins using eachEqual for per-row key matching
06_count_aggregate.jsoncount aggregate with a where filter
07_group_by.jsonMultiple aggregates with groupBy
08_having.jsonhaving clause with and of two aggregate comparisons
09_arithmetic.jsonadd, multiply, divide on aggregate results
10_parameters.jsonNamed scalar parameters in per-row predicates
11_distinct.jsondistinct: true to deduplicate results
12_complex_query.jsonFull query: joins, per-row where, groupBy, single-value having, arithmetic, parameters, orderBy with direction, pagination
13_range_filter.jsoneachGreaterThan and eachLessThan combined with eachAnd
14_each_field_to_field.jsonPer-row range comparison between two fields (no scalar threshold)
15_each_not_equal.jsoneachNot wrapping eachEqual — the idiom for "field ≠ literal"
16_each_or_composition.jsonMixing eachEqual and eachGreaterThan via eachAnd + eachOr
17_each_arithmetic_select.jsoneachMultiply of two fields as a computed select column (subtotal)
18_aggregate_of_each_multiply.jsonsum(eachMultiply(unit_price, quantity)) grouped by user — line-item revenue
19_each_date_add_days.jsoneachDateAddDays to derive a delivery_eta column from order_date + 30 days
20_each_datetime_diff_where.jsoneachDatetimeDiffSeconds inside eachGreaterThan to filter orders by ship-time
21_each_time_math.jsoneachTimeAddSeconds (time + offset) and eachTimeDiffSeconds (shift duration)

About

PureQL is a JSON-based declarative query language for relational data, validated against a JSON Schema — easy to generate, serialize, transmit, and validate.

Resources

Code of conduct

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors