opengridv0.15.0

Query semantics

The rules every engine has to answer identically, and the conformance cases that pin them.

A query is a JSON AST, not SQL. What it means is fixed by a set of rules, and each rule by conformance cases: one JSON file per case in crates/opengrid-conformance/cases/, each holding a query against the shared orders dataset and the exact expected answer.

{
  "id": "s8-decimal-is-exact",
  "rule": "S8",
  "query": {
    "source": "orders",
    "select": ["id", "amount"],
    "filter": { "field": "amount", "op": "eq", "value": "999999999.99" }
  },
  "expected": { "columns": ["id", "amount"], "rows": [[7, "999999999.99"]] },
  "ordered": false
}

Who answers them

The same case files run against

  • the local engine, natively (crates/opengrid-engine/tests/conformance_engine.rs),
  • the WASM build in headless Chrome (crates/opengrid-wasm/tests/conformance_in_browser.rs): one test per case file, listed when the crate is built, so a new case runs there too,
  • the hybrid path, with a source that refuses what it does not declare (crates/opengrid-engine/tests/conformance_hybrid.rs),
  • PostgreSQL (crates/opengrid-datasource-postgres/tests/conformance_postgres.rs) — this one skips itself without a database; point it at one with OPENGRID_TEST_PG.

The rules, by their cases

Rule What the cases pin
S1 Comparisons with NULL: eq and ne exclude NULL rows, NOT of an unknown stays unknown
S2 in lists, including the empty list, which matches nothing
S3 NULL ordering, ascending and descending
S4 Binary collation
S5 contains and starts_with are case-sensitive
S5a icontains and istarts_with fold both sides with Unicode’s lowercase mapping (no locale, final sigma applied): Müller is found by müller, Σ at the end of a word becomes ς, ß stays (STRASSE is not Straße), and İ becomes i and a combining dot, so a Turkish İstanbul is not found by istanbul. The engine, SQLite (og_fold, registered by the connector) and PostgreSQL (lower under ICU’s und-x-icu collation, so the database needs ICU) agree on fold-cases/. Accent-insensitive matching and locale collation of sorting are not part of it (#391)
S6 Multi-column sort and paging with offset
S7 NaN: not NULL, sorts after numbers, -0.0 equals 0.0
S8 Decimals are exact
S9 Dates have no time zone; timestamps keep microseconds
S10 NULL forms its own group, and the empty string is a different one
S11 Aggregates ignore NULL; count(*) versus count(field); empty sets
S12 Aggregate result types: sum of int64 stays int64, of decimal widens to 38 digits
S13 Unicode normalisation: NFC is not NFD
S14 The empty string is a value, not NULL
S15 Derived date-part columns (year, month) filter, sort and group like stored ones
L1 has: a list holds an element, compared exactly and case-sensitively; a NULL list is unknown and drops, so not has drops it too
L2 has_all: every given element is in the list, in any order and any number of times; no elements is every list but a NULL one
L3 is_empty is true for the empty list only; a NULL list is not empty, and not is_empty keeps it
L4 A list comes back as it went in: order, repeats, empty elements and text with commas or quotes kept; NULL and the empty list stay two things
L5 count of a list field skips NULL; a list is found as NULL with is_null
L6 has and has_all combine with and, or, not like any filter

A list column has no order: it is not sorted, grouped or compared as a whole, and has no min or max — the query is refused with ListNotOrdered — and eq, in and the text operators do not apply to it. Its elements are never NULL, and the empty list is not NULL. The cases are in list-cases/ over lists.csv; the engine, SQLite and PostgreSQL answer them alike.

The file names say the rest — s7-nan-sorts-after-numbers.json, s9-timestamp-microsecond-precision.json — and each file is short enough to read.

Calculated measures in a pivot

A pivot’s measure is an aggregate; with calc it is shown as a calculation over the pivot’s cells instead: { "field": "qty", "fn": "sum", "as": "share", "calc": "share_of_row" }. The calculations are part of the pivot’s contract — the engine, SQLite and PostgreSQL compute them, not the element that shows them — and three rules fix what they mean. Their cases are in pivot-cases/, against the same orders dataset, each with the whole expected pivot.

Rule What the cases pin
P10 A share is defined for sum and count; a share of avg, min or max is refused. A running total takes all five, where the aggregate’s result is a number. A calculated measure is a measure like any other: its own alias, its own column, beside its aggregate when the pivot names both. A sort by it orders by its aggregate
P11 share_of_row, share_of_column, share_of_total: the cell divided by its row’s total, its column’s total or the grand total — a float64 fraction, NULL where the cell is NULL or the total is NULL or zero. Subtotal rows and the total row follow the same rule
P12 running_total: the sum of the row’s cells — the aggregate’s values, whichever aggregate — up to and including the column, in the columns’ order, starting again in every row; subtotal and total rows the same, over their own cells. NULL adds nothing; before the row’s first value the cell stays NULL. The type is the aggregate’s own

Which total. A row’s total is the sum of its cells across every generated column — with two column dimensions, across all of them. A column’s total is that column’s cell in the grand total row; the grand total is the grand total row’s total. There is no share of the parent group: a row two levels down is still divided by the column’s cell in the grand total row. In the grand total row a share of the column is therefore 1 and a share of the total is the column’s part of everything; in a subtotal row each is the subtotal’s own part. Without a column dimension a row is its own total — a share of the row is 1, and a share of the column equals the share of the total.

Why a share is for sum and count only. A share divides by a total, and the total is added up from the cells: only a sum or a count of the parts is the sum or the count of the whole — which keeps every share what the raw rows give (a subtotal is asked for, never rolled up; the average of averages is not an average). A share of avg, min or max is an error that names the measure, before anything runs.

A running total takes every aggregate whose result is a number: it claims no total of the raw rows, it adds up what the cells show. Over avg it is the sum of the averages so far, over min or max the sum of the smallest or largest values so far; a subtotal or total row adds up its own cells, so its running total is not the sum of the rows above it. min or max of a text, a date, a timestamp or a boolean is no number, and its running total is refused, naming the measure.

What a share is. A fraction, not a percentage: 0.25, as float64, whatever the measure’s type; percent signs and digits are the page’s format. A row’s shares of its row total add up to 1, to within floating point — the suite compares a float within a relative 1e-9. A share is plain division, so negative values give shares below 0 or above 1 (50.00 of a total of -12295.67 is -0.0041); only a total of exactly zero, or NULL, gives NULL. An empty cell follows from what it holds: a sum of nothing is NULL and so is its share, a count of nothing is 0 and its share is 0.0. NaN is a value, not NULL: a sum of float64 that is NaN makes the shares of it NaN.

What a running total is. It runs along the generated columns as they are ordered — values ascending, NULL last — across both column dimensions without starting again at the outer one, and anew in every row. A cell no row falls into exists, as in every pivot: for a sum it is NULL and the running total carries on unchanged past it (or stays NULL, before the first value); for a count it is 0, which is a value from the first column on; for avg, min and max it is NULL, as for a sum. The type is the aggregate’s own result type (S12): an integer sum stays int64 and a decimal sum its 38-digit decimal, exact to the last digit; avg is float64; min and max keep their column’s type, a decimal its precision. A running total that outgrows its type — 64 bits, or the digits of its decimal — is an error (limit_exceeded), never a wrapped or widened number.

Order and limits. A calculation changes cells, not rows or columns: a calculated measure counts towards max_columns and max_cells as the one column per column value it is. sort … by a calculated measure orders the rows by its aggregate over the whole row, as for any measure — the order of a pivot never depends on what a measure is shown as.

How much is tested

Counted from the repository when this site was built:

SuiteFiles
Query conformance cases (crates/opengrid-conformance/cases/)54
Pivot conformance cases (crates/opengrid-conformance/pivot-cases/)25
Playwright end-to-end specs (tests/e2e/)106
of them running axe-core over the result64

The end-to-end specs drive the elements in a real browser; the ones that load @axe-core/playwright check the result for accessibility in every state they reach — see Accessibility.

Edit this page on GitHub · Docs for v0.15.0