The SQL compiler — tier 2 on OmniSQL¶
Authoritative design for the implemented OmniSQL compiler (historical rationale at the bottom).
Read with CONTRACT_NOTES §3.5/§3.6 (the wire truth, live-pinned 2026-08-14), §2.7 (grain
__raw sidecars), §6 items 11–13 (open residue), and HYBRID.md (the splitter/local engine
it integrates with). Every DECISION is marked with its rationale.
Tier 2 avoids the unlimited raw scan feeding a local aggregate by pushing supported
operations into a warehouse query. A tier-2 job is one OmniSQL
statement — userEditedSQL with rewriteSql ABSENT — that the server parses, binds against
the model (model-level joins, measures expanded to their governed SQL,
row-level policies applied), and plans as a governed model job. There is no reference core, no
staticQueryReferences, no second query object. The statement IS the plan.
Topic scans bind to the catalog's base view, including when topic and view names differ. Preservation of topic-specific join overrides and topic filters remains unverified; see CONTRACT_NOTES §6 item 13 before relying on those semantics on this path.
SELECT ${users.state}, ${order_items.sale_price_sum},
COUNT(DISTINCT ${users.id}) AS of_expr_1
FROM ${order_items}
GROUP BY 1
HAVING COUNT(DISTINCT ${users.id}) > 10
ORDER BY 3 DESC
LIMIT 50000
1. What upgrades, what doesn't¶
Splitter invariant unchanged: tier 1 (try_semantic) → tier 2 (try_sql) → local, at every
node. Shapes try_sql accepts:
| Shape (logical plan) | OmniSQL rendering |
|---|---|
Aggregate(keys=dims/grains, aggs=ad-hoc and/or MEASURES) |
SELECT ${key}…, ${measure}…, AGG(${field}) AS a_n FROM ${base_view} GROUP BY 1…k |
Tier-1-incompatible Filter below the aggregate (cross-field OR, arithmetic, string-pred mixes) — AND every tier-1-compilable filter that used to ride the reference core |
WHERE, one conjunct per underlying column (§3.3) |
Filter above the aggregate over ad-hoc agg or MEASURE outputs |
HAVING with the ad-hoc aggregate substituted inline (verified P5b) or the ${measure} ref expanded server-side (CONTRACT_NOTES §3.6) |
Project/WithColumn computed columns |
select-list expressions AS <alias> |
Sort/Limit/offset on top |
ORDER BY <position> / LIMIT/OFFSET in the text (verified P1/P10) |
Mixed aggregation uses one statement. Governed measures are legal select items
(${view.measure} expands server-side, mixes with ad-hoc aggregates — CONTRACT_NOTES §3.6,
P2/P3). A mixed agg() becomes ONE tier-2 statement; aggregate decomposition
(tier-1 measures + raw scan + LocalAggregate + AlignJoin) survives only as the fallback
when try_sql declines. See §4.
The following shapes refuse tier 2. The splitter then attempts local execution or aggregate decomposition, subject to the constraints in HYBRID §6:
- UDFs /
map_pandas; user-facingJoin/Union; anything aboveSqlScan/SavedQueryScan;with_totals()on non-tier-1 plans (all unchanged). - Relative date literals — ⊖ previously rode the reference core as typed filters; OmniSQL text has no rendering for Omni's date grammar. Tier 3 still answers them (its raw scan carries them as tier-1 filters).
- A string literal compared against a timestamp-grain field (unchanged rule, same reason).
- Grain refs in
WHERE— the §3.3 hazard: mixing a bare ref and a grain ref of the same field is where the live predicate loss occurs (CONTRACT_NOTES §3.6). Grain refs stay legal as SELECT items and GROUP BY keys. - Predicates over columns the aggregate consumed, double limits, sorts below aggregates —
unchanged
_matchrules. SELECT DISTINCTis silently stripped (CONTRACT_NOTES §3.6), so the compiler never emits it. There is noDataFrame.drop_duplicates()method; usemap_pandas()for local deduplication.- A field, topic, or view whose name fails the sentinel charset (§3.1) — refuse, tier 3.
2. Envelope and guards¶
{
"query": {
"modelId": "<model>",
"userEditedSQL": "SELECT ${users.state} … FROM ${order_items} … LIMIT 50000",
// NO rewriteSql key — absent selects the parsed-OmniSQL path (§3.5/§3.6)
// NO staticQueryReferences, NO sqlSortsEnabled
"fields": [], "table": "", "limit": 50000, // limit: client bookkeeping only, see below
"version": 9, ...
}, ...
}
DECISION —
Query.for_omnisql(model_id, sql, *, limit, offset): setsuser_edited_sql=sql,rewrite_sql=None(key absent on the wire), and a non-wire flagomnisql=True(excluded fromto_wire()).Query.validate()requires a SQL job to haverewrite_sql is False(verbatim,for_sql) XOR (omnisqlflag andrewrite_sql is None).RemoteStep.__post_init__mirrors it off the envelope:userEditedSQLpresent requires eitherrewriteSql: falsein the bytes, or the compilation'somnisqlflag with the key absent. The structural guarantee survives both ways: user-provided SQL (read.sql) is built only byfor_sqland can never ride the parsed path; compiler-generated OmniSQL is built only byfor_omnisqlfrom plan nodes and can never ride the verbatim path.DECISION — the envelope
limitmirrors the SQLLIMIT(ornullforlimit(None)) but is client bookkeeping only: the server IGNORES the query-object limit on this path and a statement withoutLIMITruns unlimited (§3.6). The always-explicit-limit invariant therefore lives in the TEXT:LIMIT DEFAULT_FETCH_LIMITwhen the user never called.limit(n),LIMIT nwhen they did, no LIMIT clause only forlimit(None).user_limit/TruncationWarning plumbing reads the mirrored envelope value unchanged.
3. compile/sqlgen.py¶
try_sql/compile_sql take only the plan and return the SemanticCompilation(tier=2) carrier.
The plan-matching machinery survives verbatim: _match, _Shape, _core_selects (minus the
measure refusal), _substitute, _partition, _conjuncts, _has_aggregate,
_references_keys. What is deleted: _reference, _split_pushable (no reference to push
into — §3.3 replaces it), _refuse_dialect_sensitive_literals, REFERENCE_PREFIX,
reference_summary. What changes: rendering (§3.1), naming (§3.2), WHERE discipline (§3.3).
3.1 Rendering: sentinel substitution¶
sqlglot cannot carry ${users.state} as an identifier (the . splits into table.column, the
$/{ invite quoting). The AST is still built programmatically — values are still typed
literal nodes, injection stance unchanged — but every model reference renders through an
opaque sentinel:
DECISION — sentinel substitution.
FieldRef/MeasureRef/the FROM target render asexp.column("__OF_REF_<n>__", quoted=False)sentinels; after.sql(pretty=True)the sentinels are string-replaced with${wire_name}tokens. Sentinels are[A-Z0-9_]-only so no dialect quotes or rewrites them, the mapping is positional and deterministic, and no user VALUE ever travels through the replacement (values are literals in the AST; only model IDENTIFIERS become sentinels). Charset gate: a substituted name must match^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+)?(\[[A-Za-z0-9_]+\])?$(topic/view: the first group alone) — anything else refuses to tier 3, so a hostile name can never smuggle text into the statement.
Emission dialect: sqlglot default (ANSI-ish), no dialect knob — the server re-renders the parsed statement per warehouse (§3.6), which is what makes §6 possible.
Expr → sqlglot table: unchanged for literals, comparisons, arithmetic, boolean ops, IS NULL,
IN, BETWEEN, LIKE (! escape, LOWER() for case-insensitive) — except:
| Expr | v2 rendering |
|---|---|
FieldRef(name, grain) |
sentinel → ${view.field} / ${view.field[grain]} |
MeasureRef(name) |
sentinel → ${view.measure} — legal as a select item (bare or inside select-item arithmetic, e.g. ${m}/NULLIF(${m2},0) and ${m}/COUNT(DISTINCT ${f})) and in HAVING, all expanded server-side (CONTRACT_NOTES §3.6); still refused in WHERE |
AdHocAgg |
unchanged (COUNT(DISTINCT <sentinel>) …) |
GROUP BY and ORDER BY are positional (GROUP BY 1, 2, ORDER BY 3 DESC) — the only
verified form, and positional ORDER BY over a grain item sorts by its __raw value
(chronological, P4). Non-aggregated sorts over an unselected column render the expression
inline — verified: the server wraps the statement in a subquery whose omni_sort_expr_<n>
sidecars never reach the result columns (CONTRACT_NOTES §3.6).
exp.Ordered(nulls_first=False) stays — NULLS LAST verified (P1).
3.2 Naming — two regimes, predict only what is guaranteed¶
The server names result columns itself; live probing (CONTRACT_NOTES §3.6) splits select items into exactly two regimes:
- Bare refs —
${view.field},${view.field[grain]},${view.measure}: the wire name is exactly the canonicalview.fieldname. SQL aliases are IGNORED, and duplicate bare refs are DEDUPLICATED to one column. This is tier-1 semantics to the letter. - Expression items — everything else: the SQL alias is honored, prefixed with a scope
view the client CANNOT predict (first-ref-wins is REFUTED:
COALESCE(${products.brand}, ${users.country})scopes touserswhile${users.age} + ${products.cost}also scopes tousers— no consistent positional rule exists). Duplicate expression items survive with their aliases.
DECISION — lean on what each regime guarantees, predict nothing more: - Bare refs: emit WITHOUT aliases, and DEDUP the select list at emission (the server would collapse duplicates and shift positions otherwise); the tier-1 client-side alias map, keyed by exact wire name, handles renames — including two user aliases over one field, resolved client-side after the single wire column arrives. - Expression items: emit with globally unique generated aliases
of_expr_<n>(n in select order). Normalize matches the result column whose name ENDS WITH.of_expr_<n>— unique in the column set by construction — and renames it to its user-facing name; the scope prefix is never guessed.SemanticCompilation.aliasescarries both kinds of entry (exact wire names, andof_expr_<n>suffix keys marked as such);columnscarries the user-facing output order.
The tier-2-vs-tier-3 differential (§9) remains the regression net for naming drift.
3.3 WHERE discipline¶
The live predicate loss in the bare-plus-grain filter probe is specific to mixing a BARE ref and a GRAIN ref of the
same field in one WHERE. Plain same-column conjunct composition and parenthesized compound
ranges — (${a} >= x AND ${a} < y), the BETWEEN shapes — survive intact
(CONTRACT_NOTES §3.6). Rules:
- Emit WHERE conjuncts naturally, BETWEEN shapes included; no per-column grouping needed.
- Grain refs never appear in WHERE — the one live-observed loss shape; a grain predicate tier 1 could express never reaches tier 2 anyway (tier 1 wins first), and one it could not refuses.
- ⊖ Tier-1-compilable filters below the aggregate — v1 pushed them into the reference core as governed typed filters — now render as WHERE conjuncts; the ones with no SQL rendering (relative dates, grained-date strings) refuse tier 2 entirely.
4. Splitter integration¶
SplitOptions.disable_sqllets the differential tests exercise the local fallback. There is nosql_dialectoption (§6).split(): unchanged —try_semantic→try_sql→_local._aggregate()(mixed path): FIRST attempt the whole node — keys + measures + ad-hoc — as one tier-2 statement viatry_sql. On success: singleRemoteStep(label="sql", tier=2), noAlignJoin, no decomposition, anddecomposition_row_capdoes not apply (nothing decomposed). On decline: aggregate decomposition (governed tier-1 step + ad-hoc half tier-2-else-raw-scan-plus-LocalAggregate+ localAlignJoin).
DECISION — tier 1 still wins measure-only aggregates (native governed path, measure-filter map,
omni_url()eligibility, no parse layer); tier 2 takes what tier 1 declines. The tier ORDER is untouched; only tier 2's reach grew.
explain() for a tier-2 step: keep Remote [tier 2 · sql → POST /api/v1/query/run], the
topic:/model: line, and the sql: block (sql_lines, 8-line budget) — the ${…} tokens
make it self-documenting. The references: block is deleted with the mechanism.
5. Grain __raw normalization (tier 1 AND tier 2 — fixes LV#12)¶
Live, BOTH tiers return a formatted grain as a pair: X__raw (DATE_TRUNC timestamp, at the
item's select position) + X (formatted STRING, appended last) — CONTRACT_NOTES §2.7.
DECISION —
__rawwins. WhenXandX__raware both present andX__rawwas not itself requested, normalize keeps the__rawVALUES under the nameXand drops both the formatted column and the sidecar name. Rationale: type-stable schema (a month grain is a TIMESTAMP whether or not the model formats it), correct local sort/join/group semantics downstream (formatted strings do not order chronologically in general), dataframe-native values. The formatted string is display formatting, not data; users who want it can format locally. Divergence from the Omni UI's rendering is documented in the user guide.df.schemaapplies the same collapse tosummary.fields(reportXwithX__raw's type, drop the sidecar entry). FakeOmniAPI emits the pair for month grains on BOTH the semantic path and the OmniSQL path (bench model formats month asYYYY-MM), so the collapse is exercised offline; other grains stay raw-only, matching an unformatted model.
Grain refs are regime-(a) bare refs (§3.2): view.field[grain] and its __raw twin are
exact, alias-proof names, so the collapse works on predicted names and needs no suffix
matching.
6. Dialect handling¶
The server renders parsed OmniSQL for the warehouse dialect. SessionBuilder has no
sql_dialect() method, and neither EnvelopeOptions nor SplitOptions carries a dialect
override. Backslash-containing literals pass through as values. The compiler uses ! for
LIKE escaping; the server renders the warehouse-specific escape syntax (CONTRACT_NOTES §3.6).
Verbatim read.sql() remains the caller's responsibility for dialect portability.
7. FakeOmniAPI: the OmniSQL resolver¶
tests/fakes/omnisql.py is dispatched from the sql_job handler when rewrite_sql is
absent (verbatim path with rewrite_sql: false keeps its current handler):
- Substitute
${base_view}→ the bench base table with LEFT JOINs from the bench relationships, pruned to the views the statement references (mirrors P1/P7 join pruning).${view.field}→ qualified DuckDB column;${view.field[grain]}→DATE_TRUNCexpression (+ the month-format pair per §5);${view.measure}→ the measure's SQL from the bench model definitions. Unknown topic/field → the server's error text verbatim (Could not substitute Omni SQL … No such view/field "<name>"— §3.6) so the client's error mapping is exercised offline. - Rename result columns per the §3.2 regimes: bare refs surface under their canonical names
with duplicates collapsed to one column and aliases ignored (matching the server);
expression items keep their
of_expr_<n>aliases under a scope prefix the fake chooses arbitrarily — deliberately not a rule the client could learn, so only suffix matching can work against it. The bare-ref rule being shared between fake and client is self-fulfilling by construction — the live re-verification step (§10) is the external check, and both regimes are probe-pinned in CONTRACT_NOTES §3.6. - Stricter than the server on the hazard shapes: the fake REJECTS (400-style error, loud)
SELECT DISTINCT, a duplicate bare select item (the server dedups silently; the client must dedup at emission), a bare-plus-grain WHERE mix on one field, arewriteSql-absent job that carries sorts/calculations, and anystaticQueryReferenceson an OmniSQL job. The server silently rewrites these; a loud fake failure turns an emission bug into a red test instead of a silent live divergence.
Grow-only-what-tests-need still applies: the resolver implements exactly the constructs §1 emits, nothing speculative.
8. Error mapping¶
A job-error line matching Could not substitute Omni SQL on a tier-2 step means omniframes
emitted a statement the model no longer binds (schema drift between planOnly and run, or an
emission bug). The executor wraps it: QueryError with the offending statement attached and
a message saying the tier-2 statement was rejected by the server, naming the missing
view/field from the server text, and pointing at explain(). Verbatim read.sql errors are
untouched (the SQL is the user's own).
9. Test coverage¶
- Golden:
sql_*.sql(OmniSQL text) andsemantic_sql_*.json(nostaticQueryReferences, norewriteSql, nosqlSortsEnabled;omnisqlflag is non-wire so envelopes stay clean), including mixed aggregation as a single statement and measure grand totals. - Unit (sqlgen): the translation table rows (
${}rendering, sentinel charset gate, measure select items incl. measure arithmetic and measure HAVING), §3.2 both regimes (bare-ref dedup at emission,of_expr_<n>alias generation and ordering), §3.3 refusals (grain-in-WHERE; BETWEEN shapes now EMIT and are golden-pinned), injection pins (a value containing'; DROP TABLEand a value containing${users.state}both render as literals — the sentinel pass must never touch a literal). - Unit (querymodel/semantic):
for_omnisqlwire shape (norewriteSqlkey), the XOR guard both ways,RemoteStepenvelope mirror. - Differential:
test_tiers.pycompares SQL pushdown with local execution (disable_sqlon/off must agree), including one-request mixed aggregation against its decomposed result. - Wire/normalize:
__rawpair fixtures (tier-1 semantic AND OmniSQL results), the §5 collapse incl. schema; a user-requestedX__raw-style name passing through untouched; suffix-match renaming ofof_expr_<n>columns under arbitrary scope prefixes. - e2e: envelope assertions (
userEditedSQLwith${…}, norewriteSql, no refs; mixed agg = one request); explain snapshots; fake resolver tests incl. the loud hazard rejections; error-mapping test off the fake's substitution error.
10. Implementation and validation¶
The L probe battery ran 2026-08-14 (compound ranges, LIKE ESCAPE, measure arithmetic, measure HAVING, unselected ORDER BY, expression scoping, duplicate select items); every outcome is folded into the DECISIONs above and recorded in CONTRACT_NOTES §3.6. The compiler and integration paths described here are implemented:
| Behavior | Implementation | Offline coverage |
|---|---|---|
| Parsed-OmniSQL envelope and guards | compile/querymodel.py, compile/semantic.py |
query-model and semantic compiler unit tests |
| SQL emission, output naming, filters, and explain output | compile/sqlgen.py, compile/explain.py |
SQL compiler unit tests and golden snapshots |
| Mixed aggregation as one statement, with decomposition fallback | compile/splitter.py |
splitter unit tests and tests/differential/test_tiers.py |
| Fake resolver, formatted grain pairs, and rejected hazards | tests/fakes/omnisql.py, tests/fakes/sqljobs.py, tests/fakes/engine.py |
fake and end-to-end tests |
| Grain normalization and expression-column renaming | transport/normalize.py, transport/arrow.py, dataframe.py |
wire fixtures and schema tests |
| Removed dialect knob and substitution-error mapping | session.py, compile/executor.py |
session, executor, and end-to-end tests |
Run the offline validation gate and docs build:
uv run ruff format --check && uv run ruff check && uv run mypy && uv run pytest -m "not live"
uv run mkdocs build --strict
Live verification is separate and requires a configured Omni organization. Use
scripts/live_smoke.py and the LIVE-VALIDATE register in CONTRACT_NOTES §6 for server behaviors
that offline tests cannot establish.
Historical note — v1 (refuted 2026-08-14). v1 delivered tier 2 as verbatim SQL
(rewriteSql: false) over a governed tier-1 query embedded in staticQueryReferences and
named FROM ref_1. The server has no refKey-as-table mechanism — the envelope fails live
with relation "ref_1" does not exist, and FakeOmniAPI's temp-view materialization modeled
something that does not exist (CONTRACT_NOTES §3.5, §6 closure #1). v1's plan-shape analysis,
HAVING substitution, typed-literal injection stance, and fallback discipline all survive in
v2; the delivery mechanism, naming model, and dialect machinery do not.