Escape

Selecting rows

Intent

A JSONPath selects the record set, and filtering, ordering, and ranking are separate structured steps on top. Four keys do the work, and the order they run in decides what every aggregate in the document means.

Design decisions

data is a path, never an expression. A = in data is a literal character. The one selection mechanism is JSONPath, so the shape of the query stays analysable and the engine can bound it.

where sees one row, so it can’t rank. It’s a per-row boolean and nothing more. Ranking is take, which is the only key that can see the set.

A filter selector reaches where where can’t. A selector sits at any segment of the path, including segments above the row set. That’s how a report selects rows by a property of their container.

A filter selector and where don’t mean the same thing by the same words. RFC 9535 reads a bare relative query as an existence test, while where is an ordinary boolean and tests truthiness.

take runs after the sort, and a path slice runs before it. A slice is selection, so it picks rows by their position in the input. A negative step can reverse that order, but never ranks it. That’s why a slice isn’t a top-N.

Rows take drops leave the aggregates too. That’s the same removal where performs, and it’s what separates take from visible, which is presentation and keeps its rows in the totals.

A group ranks its instances apart from their rows. A group’s sort and take work inside each instance. instances orders and cuts the instances themselves, so “the five largest regions” is one declaration rather than a pre-sorted query.

An instance instances.take drops still counts in the totals around it. It never renders, but the group aggregates around it and the report aggregates include its rows, as they include a row a group’s take drops. A top five of regions therefore reads against the grand total of every region.

The query carries a traversal budget by default. Render input may be untrusted and unbounded descent would exhaust the call stack, so maxDepth defaults to 500 rather than to nothing.

API walkthrough

Choosing the record set

data is an RFC 9535 JSONPath selecting the row array from your input.

selection
{ "data": "$.orders[*]" }

A path may carry a filter selector, which is JSONPath’s own expression dialect rather than quario’s.

filtering the containment path
{ "data": "$.regions[[email protected] == true].orders[*]" }

That takes the orders of active regions only. No where expresses it, because @ binds to the order and an order carries no reference to its region.

Write the comparison out. [[email protected]] keeps a region whose active is false, because the member exists, where where: "[email protected]" would drop it.

Selectors may use match(value, pattern) for a full match and search(value, pattern) for a substring match. Patterns follow bounded RFC 9485 I-Regexp semantics, not JavaScript RegExp, so \d, lookarounds, and backreferences fall outside it. The compiler checks a pattern written as a literal. A pattern arriving from data produces no match at render rather than throwing.

Dropping rows

where is a boolean evaluated per row with @ bound to that row. The report drops rows returning falsy. It’s report-level only.

filtering rows
{ "where": "[email protected] != 'void'" }

A report aggregate read inside where is null, because where runs before the aggregates exist. There is no earlier moment that could supply one.

Ordering

sort takes keys applied left to right. The first key is the primary key. dir defaults to "asc", and the sort is stable, so rows whose keys compare equal keep the order they arrived in.

ordering
{ "sort": [{ "by": "[email protected]" }, { "by": "[email protected]", "dir": "desc" }] }

Keys compare with JavaScript ordering, so a mixed-type or nullish key orders by that ordering. Keep a key one type where the order matters.

A sort on the report orders the whole set. A sort on a group orders the rows within each instance.

A report aggregate read in a report sort key is null, for the same reason it’s null in where. A group’s sort key is the one exception. The engine computes a group instance’s aggregates over its partition before it orders the rows, so =region.subtotal - @.amount works. Declaring take on that group withdraws the exception, since ranking has to happen first.

Ranking

take keeps the first N rows after that node’s sort. It’s a literal integer of one or more.

a top five
{ "sort": [{ "by": "[email protected]", "dir": "desc" }], "take": 5 }

Sort descending and take N for a top-N, ascending for a bottom-N. Without a sort it keeps the first N as they arrived, and a count larger than the set keeps every row.

A take on the report ranks the whole set. A take on a group ranks within each instance.

To show five rows and still total over all of them, use visible with a count runner instead. Suppressed rows stay in the aggregates.

Ranking the instances

instances sits on a group beside its sort and take, and holds an optional sort and take of its own.

the five largest regions
{
  "name": "region",
  "by": "[email protected]",
  "aggregates": { "subtotal": "sum:[email protected]" },
  "instances": { "sort": [{ "by": "=region.subtotal", "dir": "desc" }], "take": 5 }
}

An instances.sort key reads what a group band reads. That’s the group’s own handle, the handles of the groups around it, and $. @ is unbound, so @.amount throws there. run is unbound too. Without instances.sort, instances appear in the order their first row arrived, and keys that compare equal keep that order. instances.take keeps the first N in the resulting order. A hollow instance still uses one of the N places, because the cut happens before anything renders.

The order of operations

Select with data, drop with where, apply the report sort, apply the report take, then partition into groups, each group applying its own sort then take. Each group then orders and cuts its instances by instances. An instances.sort key therefore sees the aggregates of the rows each instance keeps.

Aggregates and running totals cover the rows that remain, in the resulting render order, so a running total follows what the reader sees.

Bounding the traversal

Budgets are instance options, never schema fields.

report.ts
import { quario } from 'quario'

const q = quario({ query: { maxDepth: 64, maxNodes: 10_000, maxResults: 1_000 } })

An explicit maxDepth replaces the default of 500, and Infinity disables a budget. Exceeding one throws a located RangeError carrying code, limit and actual.