Query Engine
This document explains the governed SQL interface: request/response semantics, validation rules, dataset resolution, and performance guardrails.
Endpoints
POST /query— run a governed SQL statement (this document)POST /dps/public/query— the same body, authorised by a DPS pull token (see dps-data-plane.md)GET /catalogue/{dataset_id}/schema— JSON Schema of a dataset's rowsGET /catalogue,GET /catalogue/{dataset_id}— dataset metadata (see catalogue-management.md)
Query Request Model
{
"sql": "SELECT ...",
"limit": 100,
"offset": 0,
"skip_count": false
}
Notes:
- limit/offset are applied server-side.
- limit defaults to 100; a value ≤ 0 means 100, and anything above MAX_LIMIT
(10 000, fixed in executor.py) is clamped to it. A negative offset is read as 0.
- skip_count: true skips the COUNT(*) query; total is then null.
- A missing or blank sql is a 400 ("sql query not provided").
Response Model
{
"items": [
{"col_a": 1, "col_b": "x"},
{"col_a": 2, "col_b": "y"}
],
"limit": 100,
"offset": 0,
"count": 2,
"total": 2
}
count is the number of rows on this page; total is the number of rows the
statement yields after row filters (null with skip_count); limit and
offset echo the values actually applied.
SQL Validation Rules
Statement restrictions
- one statement; a trailing
;is tolerated SELECTonly, with CTEs and subqueries;UNION,INTERSECTandEXCEPTare refused- no comments (
--,/* */) - no top-level
LIMIT/OFFSET: the request'slimit/offsetpage the result - an AST deeper than 200 nodes is refused ("Query too complex")
- a tautology (
x = x) inside anORis refused; on its own (WHERE 1=1) it is logged and allowed
Table restrictions
- references must map to catalogued datasets; an unknown reference is a
400("Query references unknown datasets: [...]") - if a query contains multiple table references, each must be resolvable
- a two-part reference (
schema.table) also matches a three-part catalogue id by literal suffix (_is not a wildcard); more than one match is a400 - a statement that references no table (
SELECT 1) is a400 - a dataset that is unexposed,
secret, or carries an unreadable access level is a403("Dataset not available"), the same answer for all three - a dataset whose backend has no SQL table (
s3,fs, …) is a400("not queryable")
QE-05 — A table reference is a CTE only where PostgreSQL would resolve it to one
Every table reference is a dataset reference — and must resolve to the catalogue — unless
it is an unqualified name of a CTE visible at that point, resolved per scope as
PostgreSQL does. So a name is physical inside the body of the non-recursive CTE it names,
outside the subquery that defines the CTE, and in an earlier CTE that names a later one.
The same resolution decides which references are rewritten to physical tables. A query
whose scopes cannot be resolved is a 400.
Function and expression allowlist
- only allow safe scalar functions
- block functions that can access filesystem, network, or server internals
Safe means pure and read-only: the construct reaches nothing beyond the rows the query already reads, and costs no more than an aggregate, which the statement timeout bounds. On that basis the parser admits:
- the functions in
ALLOWED_FUNCTIONS(parser.py), whether sqlglot parses them as a typed node (COALESCE,FLOOR,EXTRACT, …) or as an anonymous call. Any of a function's SQL names counts, soIFNULLisCOALESCE. The set covers the PostGISST_*functions listed in the README,LOWER,UPPER,LENGTH,TRIM,LTRIM,RTRIM,SUBSTRING,REPLACE,ABS,ROUND,CEIL,FLOOR,COALESCE,GREATEST,LEAST,NULLIF,DATE,DATE_TRUNC,EXTRACT - aggregates
COUNT,SUM,AVG,MIN,MAX,ARRAY_AGG, withDISTINCTandFILTER (WHERE …) CASTand::,INTERVAL,CURRENT_DATE,CURRENT_TIMESTAMP,NOW(), arithmetic+ - * /IN(list, tuple or subquery),BETWEEN,IS [NOT] NULL,NOT/AND/OR- joins, subqueries and CTEs
CASEexpressions andIF(...)- window functions (
… OVER (…)) and the ranking functionsROW_NUMBER,RANK,DENSE_RANK. The function inside a window is still checked on its own PERCENTILE_CONT/PERCENTILE_DISCwithWITHIN GROUP, andBOOL_OR/BOOL_AND- a
VALUESlist, typically as a CTE of constant rows
Hash and crypto functions (MD5, SHA256, …) and string concatenation (||)
are not admitted. No read path needs them, so derive such values in the caller.
Also refused, for want of a reader: LIKE/ILIKE, %, ~, IS DISTINCT FROM,
AT TIME ZONE, ANY/ALL, EXISTS, and any function outside
ALLOWED_FUNCTIONS (LAG, STDDEV, TO_CHAR, CONCAT, …). A refusal is a 400
naming the node: "SQL function not allowed: X" or "Unsupported SQL construct: X".
Tests: tests/api/dataset_query/sql_parser/test_accept_analytics.py.
A clause below marked planned describes a widening decided but not yet in the code: the parser still rejects it. The change that lands it adds its tests and removes the status line.
QE-01 — ST_AsGeoJSON and ST_Simplify are admitted
st_asgeojson and st_simplify join ALLOWED_FUNCTIONS, so a caller can ask
for a shape as GeoJSON at a bounded size in one statement:
ST_AsGeoJSON(ST_Simplify(geometry, <tolerance>)). The first reader is the
Digital Twin's boundary-shape fetcher, which draws a community's reference
areas on a map.
Both pass the test above. They read only the geometry the query already
selects: a raw geometry column is already answered as GeoJSON, converted after
execution, one extra database round trip per row and never simplified. Neither
reaches beyond the row. ST_Simplify is the reason to admit the pair: a
published reference boundary is a detailed polygon, and
simplifying in the statement bounds the payload before it leaves the database.
Its cost on a large multipolygon is CPU, which the statement timeout and
MAX_LIMIT bound like any aggregate. What a hostile caller gains is a
serialisation of data they could already read.
Admitting the call admits nothing about its arguments: a function nested inside
either one is checked on its own, and the other PostGIS serialisers and
simplifiers (ST_AsText, ST_AsBinary, ST_SimplifyPreserveTopology, …) are
still refused until a reader needs them.
Tests: tests/api/dataset_query/sql_parser/test_accept_geojson.py (each
function, its argument forms, nested as above; neighbours and server-reaching
functions still refused, alone and as arguments),
tests/api/dataset_query/sql_parser/test_security_resource_abuse.py
(ST_Simplify over a whole table parses; nesting without end is refused),
tests/routes/test_boundary_query.py (a point-in-shape lookup and a simplified
GeoJSON shape executed on PostGIS through POST /query; a point on the edge two
shapes share is covered by both under ST_Intersects, and with limit 1 the
statement's ORDER BY cod_ac answers the lowest id).
QE-02 — A unary minus is admitted on a numeric literal, and on nothing else
-1, -0.5 and -1e3 parse, so a coordinate west of Greenwich or south of the
equator can be written where it is used: ST_Point(-0.05, 0.05). Before, every
unary minus was an unsupported Neg and a caller had to write 0 - 0.05.
The widening is exp.Neg over a numeric exp.Literal, checked where the node is
met. A negated number is a constant: it reads nothing and costs nothing. A minus
over anything else is still refused with "Unary minus is allowed only on a
numeric literal": a column (-value, also inside an admitted call such as
ST_Point(-lon, lat)), a parenthesised expression or literal (-(value + 1),
-(1)), a subquery, a function call, a string (-'1'), a cast (-CAST(1 AS INT),
and -1::int, which sqlglot parses as a minus over a cast), and a double
negation (- -1). None of those is dangerous in itself — each operand is still
checked on its own — but none is a constant number and no reader needs one, so
they stay outside the allowlist until one does. Subtraction is a different node
and unaffected: value - -1 parses, the right operand being a negated literal.
--1 is not a double negation at all; it opens a comment and is refused as one.
Tests: tests/api/dataset_query/sql_parser/test_accept_negative_literals.py
(accepted forms, including inside ST_Point; every refused form above),
tests/routes/test_boundary_query.py (negative coordinates executed on PostGIS
through POST /query).
Row filters
A dataset's governance can declare row filters (rowFilters or row_filters; the
legacy userFilterColumn becomes a direct_user_match filter). An unauthenticated
caller of a filtered dataset gets 401; a holder of the platform-admin realm
role is not filtered. Filters are applied to
the validated AST after physical table names are substituted, and the query is then
rendered once, as PostgreSQL. The SQL is never re-parsed from text in between: a
text round trip changes the dialect (INTERVAL '30 minutes' becomes
INTERVAL '30' MINUTES) and splits schema.table, so a filter keyed on the
physical table would stop matching and be dropped.
On a request arriving through the dataspace both row-policy layers are still read
from the local declaration: the person layer's filter comes from ds's decision and
carries the consenting subjects with it, and the organization layer is applied to
the decision's organizations (GS-13 in
governance-security.md) — see
dataspace-row-filters.md.
Logging
QE-03 — The query log carries a statement's shape, never its literals
A statement's literals are the caller's data, not its structure: a boundary
lookup's point is a supply address's coordinates, and the IN list a
rec_registry row filter adds names a member's sensor ids. Wherever the engine
logs a statement — the raw SQL on arrival, the SQL after table mapping and after
row filters, the statement a failed execution ran — it logs its shape: every
literal replaced by ?, comments dropped, rendered as PostgreSQL
(log_safety.sql_shape). Text that cannot be parsed is withheld whole rather
than logged raw. No hash of the statement is logged either: coordinates and ids
are low-entropy inside a known shape, and a hash could be reversed by
enumerating candidates.
The same holds for the parser's own warnings. A syntax error is logged by its
position (description, line, column) without the context sqlglot quotes from the
statement, and a refused or tolerated tautology by its shape. Any other parser
failure (sqlglot's tokenizer error on an unterminated literal, say, whose text
quotes the statement) is logged by its type only, without message or traceback;
so is a failure to apply row filters. The 400 the
caller receives still quotes their own statement back: that goes to the caller,
not to a log.
On the dataspace path the SQL after row filters stays withheld entirely (the
predicate names the consenting subjects). The driver's own error message is
still logged at WARNING so a failure leaves a trace; PostgreSQL may quote a
malformed value in it (invalid input syntax …), never the statement. An
error that is not the driver's (a SQLAlchemy StatementError, say) is logged by
its type and the statement's shape, without its message or a traceback: both
repeat the statement and its bound parameters.
Tests: tests/api/dataset_query/test_log_safety.py (numbers, negative numbers,
strings, comments and the executor's wrapper statement masked; unparseable text
withheld; a tokenizer error in the parser logs no literal and no traceback),
tests/routes/test_boundary_query.py (a coordinate literal reaches no
log at any level for an executed, a failed, an unparseable and a refused
statement, nor for a count or data query failing with a non-driver error, while
the shape still does).
Projection safety
SELECT * is admitted. Column-level restriction is not implemented: a dataset
that must hide columns needs a view as its physical table.
Dataset Resolution
The engine enforces a separation between: - logical ids: what the client references (dataset identifiers) - physical names: actual table/view names
Resolution steps:
- parse SQL and extract table identifiers
- map identifiers to catalogue entries
- substitute the physical name into the AST:
backend_config.table, or for a postgres entry that states none, the table its id names (datasets.<schema>.<table>→<schema>.<table>) - reject (
400) any identifier that matches no catalogue entry
Every referenced dataset is mapped, access-checked and row-filtered; none skips a step.
This prevents “escaping” to arbitrary tables.
Pagination, Limits & Timeouts
The engine guards the storage backend, for open datasets too:
limitis capped at 10 000 (MAX_LIMIT, not configurable); there is no offset cap- the count query and the page query each run under
statement_timeout = QUERY_STATEMENT_TIMEOUT_MS(default 5000 ms); a timeout is a400("Query exceeded time limit") - the parser refuses an AST deeper than 200 nodes
- there is no join, subquery or pattern-cost limit;
skip_count: truesaves the fullCOUNT(*)scan
The row cap is the request's limit: a LIMIT or OFFSET in the top-level
statement is refused. The engine wraps the statement as
SELECT * FROM (<statement>) AS q [ORDER BY …] LIMIT :limit OFFSET :offset, so an
ORDER BY in the statement decides which rows the page holds — the Digital
Twin's boundary lookup relies on ORDER BY cod_ac with limit 1 to answer the
lowest id of several covering shapes. tests/routes/test_boundary_query.py pins
it (ascending and descending answer different rows).
QE-04 — The statement's top-level ORDER BY is carried onto the page query
The SQL standard does not promise that a subquery's order survives the outer
SELECT, and a page cut before ordering is a different page. So the engine
repeats the statement's top-level ORDER BY on the outer query, each key
rewritten to name a column of q, with its direction and NULLS placement as
the statement renders them. The statement keeps its own ORDER BY inside the
subquery. Only the top level: an ORDER BY in a CTE, a subquery or a window is
the statement's business. The count query is not ordered.
The outer query sees only q's output columns, so a key is rewritten only where
it provably names one of them:
- a bare column that names an output column (an alias or a selected column) —
as PostgreSQL itself reads a bare name in
ORDER BY, so inSELECT a AS b, b AS a … ORDER BY ait is the outputa; - a key written exactly as a selected expression:
ORDER BY t.tsbesideSELECT t.ts,ORDER BY SUM(value)besideSELECT SUM(value) AS total; - a position (
ORDER BY 2) pointing at a named output column; - with a lone
SELECT *over one source and no join, any column reference:qthen carries that source's columns under their own names.
If any key is none of these — a column or expression that is not selected, an
unnamed output (SELECT lower(name) … ORDER BY lower(name)), an output name
selected twice (SELECT t.a, u.a), a star beside other projections or over a
join (where q may hold one name twice and naming it would be ambiguous) — no
outer ORDER BY is added at all, never a partial one, which would order by a
prefix of the keys. The page query is then what it was before this clause and
PostgreSQL's plain outer SELECT keeps the subquery's order, as it does in
practice. No statement that ran before fails because of this clause.
Note that the statement is parsed in sqlglot's default dialect and rendered as
PostgreSQL, so an ascending key without NULLS is rendered NULLS FIRST (and a
descending one NULLS LAST) on both the inner and the outer query; the two
agree.
Code: src/celine/dataset/api/dataset_query/pagination.py.
Tests: tests/api/dataset_query/test_outer_order_by.py (each rewritten form,
both directions, NULLS and identifier case kept; each form that adds no outer
ORDER BY; the page query's shape), tests/routes/test_paginated_order.py
(executed on PostgreSQL through POST /query: pages in descending, alias,
position, star, aggregate and join-column order with the executed page query
carrying ORDER BY q.…; a column not selected and a star over a join whose
tables share column names still run, with no outer ORDER BY).
Join Policy
Joins (inner, outer, cross, comma) are admitted. Each joined table must resolve to
a catalogued dataset and pass that dataset's access check and row filters. On the
dataspace path every joined dataset must be dataspace_expose, and ds judges the
join as a whole: one refused dataset refuses the query. There is no join-count,
namespace or cartesian-product limit.
Error Semantics
400 — the statement
- invalid SQL, a refused construct or function, a top-level
LIMIT/OFFSET - unknown or ambiguous dataset reference, or no dataset referenced
- a dataset whose backend has no SQL table
- statement timeout ("Query exceeded time limit"), or any other database error ("Database query failed")
401 — identity
- the dataset requires authentication and none was given
- a row-filtered dataset queried without a user
- an EDR token missing or invalid on the dataspace path
403 — authorization
- identity not permitted by the policy engine
- dataset unexposed,
secret, or with an unreadable access level ("Dataset not available") - dataspace path: dataset not offered, refused by ds, or a row filter this instance cannot enforce
500 — the service
- a row filter that cannot be built or applied (unknown handler, bad spec, handler failure)
- an unexpected execution error
502 / 503 — upstream
502: ds-connector unreachable or answering an error503: policy engine unavailable, or a pooled database connection lost (retry)
/query never answers 404: an unknown dataset is a 400.
Every 401 and 403, and a 400 that refuses a reference or a construct, is also
written to the access audit with the caller and a reason code; a read that succeeds
is written with the datasets it read (GS-01, GS-02 in
governance-security.md).
Examples
Query a dataset
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
--data @- https://host/query <<'JSON'
{"sql": "SELECT * FROM datasets.gold.example WHERE ts >= now() - interval '1 day'", "limit": 100, "offset": 0}
JSON
Paginate
curl -X POST \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"sql":"SELECT id, ts, value FROM datasets.gold.example ORDER BY ts DESC","limit":100,"offset":100}' \
https://host/query
Performance Recommendations for Clients
- always filter by time windows where possible
- request only needed columns
- prefer indexed predicates
- avoid deep offsets; paginate with stable ordering
- send
skip_count: truewhentotalis not needed