SQL Capability Gate
Ontul validates every planned query against what the execution engine can actually evaluate, and rejects the rest with an explicit error instead of running it. A query either produces a correct answer or fails loudly — it never quietly returns wrong rows.
ERROR: Subqueries (SCALAR_QUERY) is not supported by the Ontul execution engine: rewrite as a join,
or materialise the inner query first
ERROR: INTERSECT is not supported by the Ontul execution engine: rewrite as an inner join on the shared columns
ERROR: This JOIN condition is not supported by the Ontul execution engine: only a single equality is
executable; this join has 2 conjuncts
Why a gate exists
Ontul's master plans a query with Apache Calcite and ships the resulting expressions to workers as
Calcite RexNode text. Each worker re-parses that text into its own expression tree.
That design is what makes the plan small and language-neutral, but it has a sharp edge: a construct
the worker's parser does not recognise could degrade instead of raising. An unknown function used to
evaluate to NULL, and an unrecognised literal became a string. The worker now fails on an unknown
function, but a construct it parses and evaluates by the wrong rules still returns rows. Without a
gate, an unsupported feature surfaces as a silently wrong answer:
| Without the gate | What the user saw |
|---|---|
SUM(x) OVER (PARTITION BY k) (before window functions were executed) |
a column of NULL |
WHERE id = (SELECT MAX(id) FROM t) |
zero rows |
a EXCEPT b |
all of a |
a UNION b |
duplicates (behaved as UNION ALL) |
LEFT JOIN |
unmatched rows dropped (behaved as INNER) |
ON a.x = b.x AND a.y = b.y |
the second equality ignored → extra rows |
TRIM(name) |
NULL |
Wrong numbers that look plausible are far more expensive than a failed query, so the engine is fail-closed: if it cannot prove it will evaluate a construct correctly, it refuses to run it.
Where it runs
The gate runs on the master, immediately after planning and before the plan is converted to text or
dispatched. It inspects the RelNode tree, so it sees real column types, join conditions and
aggregate flags rather than re-parsed strings.
SQL → Calcite parse/validate → RelNode → [ capability gate ] → PhysicalPlan → workers
│
└─ UnsupportedSqlException → client error
Two consequences worth knowing:
- Cached plans are already validated. A plan-cache hit skips the gate because the miss that built it passed the gate.
EXPLAINis gated too. Explaining a query that cannot run reports the same error, soEXPLAINnever shows a plan the engine would not execute.
What is rejected
| Construct | Rewrite |
|---|---|
DISTINCT inside a window function, aggregates other than COUNT/SUM/AVG/MIN/MAX over a window, RANGE offsets over a non-numeric key — see Window Functions |
GROUP BY first, or ROWS |
Scalar subquery, IN (SELECT …), EXISTS, correlated subqueries |
A join, or materialise the inner query into a table first |
INTERSECT |
Inner join on the shared columns |
EXCEPT / MINUS |
Anti-join |
UNION (de-duplicating) |
UNION ALL, wrapped in SELECT DISTINCT if needed |
SEMI / ANTI join |
— |
Join ON that is not one equality — composite (AND), non-equi (>), or a cross join |
Keep one equality in ON, move the rest to WHERE |
COUNT(DISTINCT x), SUM(DISTINCT x) |
GROUP BY x, then count the groups |
FILTER (WHERE …), WITHIN GROUP, GROUPING SETS / CUBE / ROLLUP |
Separate queries, or CASE WHEN inside the aggregate's input |
Aggregates other than COUNT/SUM/AVG/MIN/MAX (e.g. STDDEV_POP) |
A UDF |
SUM/AVG over a non-numeric column |
CAST first |
A LIMIT / OFFSET that is not a number (a parameter) |
Inline the number |
Scalar functions the gate does not admit — POWER, SQRT, CHAR_LENGTH, POSITION, TRIM, … |
A UDF, or a built-in extension (see below) |
Unary minus (-x) |
(0 - x) |
IN (...) / BETWEEN in the SELECT list (they survive as SEARCH(Sarg[…])) |
Explicit OR / AND comparisons. Both are fine in WHERE |
String literal containing ' |
— |
LIKE … ESCAPE |
Plain LIKE; % and _ are the only wildcards, everything else is literal |
Dynamic parameters (?) |
Inline the value — the plan is shipped as text |
Functions the gate admits
The allow list is the planner's, not the worker's, and the two are not the same list. A function is admitted when it is either in the planner's scalar set or recognised as an extension:
| Family | Admitted because |
|---|---|
Comparison, logic, arithmetic, CAST, CASE, ITEM, ARRAY |
in the planner's scalar set |
UPPER, LOWER, SUBSTRING, REPLACE, CONCAT / \|\| |
in the planner's scalar set |
ABS, CEIL, CEILING, FLOOR, ROUND |
in the planner's scalar set |
EXTRACT (and YEAR(), MONTH(), … which Calcite turns into it), FLOOR/CEIL(x TO unit), CURRENT_DATE, CURRENT_TIMESTAMP, LOCALTIMESTAMP, CURRENT_TIME, and the Reinterpret / /INT that TIMESTAMPDIFF expands into |
in the planner's scalar set — see Data Types |
ST_* |
matched by prefix — see Geospatial |
embed_passage(), embed_query() |
named extensions, evaluated per Arrow batch |
ai_verdict(), ai_verdict_one() |
named extensions, evaluated per Arrow batch — see ai_verdict |
| Any registered UDF | the session's own function set |
The last three rows are why the allow list exists as a separate thing at all: those functions have no row-wise implementation to find. They are intercepted at the projection operator and evaluated a whole batch at a time, so the planner has to be told they are real even though the row-wise evaluator would not recognise them. Without an entry the query fails with "not supported by the Ontul execution engine" one step after the planner has already accepted the signature — which reads as if the function did not exist.
Admitted and implemented are different questions
A few functions are implemented in the worker's expression evaluator but not
in the planner's allow list — TRIM is the clearest case. The outcome is a
clean refusal rather than a wrong answer, which is the gate doing its job,
but the cause is an omission from the list rather than a missing
implementation. If you need one of these, that is a one-line change to the
allow list, not a new operator.
What is not gated
DELETE,UPDATE,MERGE INTOpredicates. These never reach the operator pipeline: they are evaluated row-by-row by a separate evaluator that handles composite and non-equi conditions, typed literals and n-aryAND/OR. ADELETE FROM t WHERE a = 1 AND b = 2 AND c = 3or aMERGE … ON t.id = s.id AND t.region = s.regionworks exactly as before.CREATE VIEW. Only the view's output schema is derived at creation time. An Iceberg view is a catalog object other engines (Trino, Spark) also read, so creating one does not require the body to be Ontul-executable. The gate applies when the view is queried through Ontul.- Data lineage extraction, which only reads scan nodes out of the plan.
CALL <catalog>.<schema>.<action>(…). ACALLis not planned as a query at all — it is routed to the ontology action path before planning, where its own governance (authorization, argument validation, idempotency, approval gate, audit, lineage) applies. See Ontology → Calling an action from SQL.
Configuration
Resolved in the standard order — -Dontul.sql.strict=…, then the ONTUL_SQL_STRICT environment
variable, then conf/ontul.properties. Default true.
Setting it to false restores the previous behaviour, in which the queries listed above run and
return wrong results. It exists to triage a regression (to confirm a query is blocked by the gate
rather than by something else), never for production use. The master logs a warning on every
validation while it is off.
Reading an error
The message always names the construct, and adds the rewrite when there is a mechanical one:
INTERSECT is not supported by the Ontul execution engine: rewrite as an inner join on the shared columns
If a query you expect to work is rejected, that is a bug in the gate — the rule list is meant to be exactly the set of constructs the engine mis-evaluates, no wider. Report the SQL and the message.
Roadmap
The gate is deliberately a capability contract, not a permanent limitation. Each rule is removed
in the same change that adds real support — outer joins, non-numeric MIN/MAX, n-ary AND/OR,
regex-unsafe LIKE patterns, and comparisons against DATE / TIMESTAMP literals with EXTRACT
and the other date functions (when DATE and TIMESTAMP became real types),
window functions and OFFSET were all gated at one point and are now executed.
The next candidates are COUNT(DISTINCT), set operations and subqueries.