Conversion rules¶
The governing principle:
Convert when provably safe; otherwise fall back to a verbatim
operationsaction and record a warning.
An operations action executes the original SQL unchanged, so the fallback
path can never alter behavior - it only forgoes idiomatic Dataform structure.
This page is the exhaustive reference for how each statement class maps, the
proof obligations for the opt-in strategies, how metadata is translated, and
what every warning code means.
For the shape of what is produced (config block, provenance, source fidelity), see core concepts. For how references and ordering are wired, see the dependency model.
Statement mapping¶
Input statement |
Result |
Notes |
|---|---|---|
|
|
|
|
|
|
|
|
Column DDL can carry types/constraints the |
|
|
No Dataform equivalent ( |
|
|
No typed equivalent ( |
|
|
|
|
|
Tracked as a writer of the target ( |
|
|
Guarded views stay operations by default; view column lists are pushed into the select list as aliases. |
|
|
MV options (e.g. |
|
|
See below; |
|
|
No query body ( |
|
|
|
|
|
Write target tracked for dependency chaining. |
|
|
Write target tracked. |
|
|
Write target tracked. |
|
|
Kept verbatim; the affected table |
|
|
Same handling as index DDL ( |
|
|
Table-scoped grants track |
Bare |
|
Review: table, view or assertion? |
File requiring shared script context |
one whole-file |
Transactions, temporary objects, variables, procedural blocks and dynamic side effects stay together ( |
Everything else ( |
|
Verbatim. |
Worked example: CREATE ... AS with metadata¶
CREATE OR REPLACE TABLE analytics.daily_sales
PARTITION BY DATE(order_ts)
CLUSTER BY region, product_id
OPTIONS(description = 'Daily sales rollup', require_partition_filter = true)
AS
SELECT region, product_id, SUM(amount) AS total
FROM raw.orders
GROUP BY region, product_id;
becomes:
config {
type: "table",
schema: "analytics",
name: "daily_sales",
description: "Daily sales rollup",
bigquery: {
partitionBy: "DATE(order_ts)",
clusterBy: ["region", "product_id"],
requirePartitionFilter: true
}
}
-- source: input.sql:1 (CREATE TABLE converted by sql2sqlx v0.1.0)
SELECT region, product_id, SUM(amount) AS total
FROM raw.orders
GROUP BY region, product_id
Note how PARTITION BY DATE(order_ts) is preserved as the raw expression
text, CLUSTER BY becomes a string array, and the description option is
promoted to a top-level config key.
INSERT -> incremental: explicit migration strategy¶
Dataform’s incremental type appends this query’s rows on normal runs, but
creates the table from the query on first run and may rebuild it during a
full refresh. Since that is not equivalent to an imperative INSERT in every
environment, the semantics-preserving default is operations. When the user
explicitly selects --insert-strategy incremental, sql2sqlx:
emits
protected: trueby default (disable with--no-protected) so an accidental full refresh cannot drop pre-existing rows;records
INSERT_INCREMENTALsuggesting${when(incremental(), ...)}around date filters;when the
INSERThas a column list, rewrites the select list so its output names match (INSERT INTO t (a) SELECT x->SELECT x AS a) - and only when every item’s output name is exactly determinable at the token level.*expansion,SELECT AS STRUCT, ambiguousINTERVAL 1 DAYitems or arity mismatches trigger the operations fallback (FALLBACK_SELECT_ALIAS) instead of a guess;rejects alias changes when a later
GROUP BY,HAVING,QUALIFY,ORDER BYor pipe stage references the old alias.requires every incremental query output name to be exactly derivable, unique (case-insensitively), and safe for Dataform’s runtime backtick quoting. An unexpanded
*, an anonymous expression, duplicate names, or an identifier containing a decoded backtick/backslash/control character falls back instead of relying on warehouse metadata guesses.
For example, with --insert-strategy incremental:
INSERT INTO analytics.events (event_id, ts)
SELECT id, created_at FROM raw.events;
converts to (note the select list is aliased to the target column names):
config {
type: "incremental",
schema: "analytics",
name: "events",
protected: true
}
-- source: input.sql:1 (INSERT converted by sql2sqlx v0.1.0)
SELECT id AS event_id, created_at AS ts FROM raw.events
On later runs Dataform projects every column in the existing target
metadata by name from the incremental query. The INSERT_INCREMENTAL
warning therefore requires the operator to verify that the query output
matches the complete target schema; an original positional/subset INSERT
does not establish that fact. This strategy is explicitly opt-in because its
first-run/full-refresh behavior and this schema contract are not equivalent
to an imperative INSERT in every environment.
MERGE -> incremental proof obligations and schema precondition¶
With --merge-strategy incremental-when-safe, a MERGE converts only
when all of the following hold (otherwise: operations +
MERGE_FALLBACK with the exact reason):
the source is an aliased subquery;
ONis a pure conjunction of same-namedtarget.k = source.kequalities;exactly
WHEN MATCHED THEN UPDATE SETwith only direct same-namedcol = source.colassignments (no conditions, no expressions), including every unique-key column because Dataform updates those columns too;exactly
WHEN NOT MATCHED [BY TARGET] THEN INSERTwith an explicit matching column/value list (INSERT ROWis order-dependent and rejected);the subquery’s derivable output columns are exactly the key columns plus the updated columns, in the explicit INSERT column order;
every output/key name is a simple, unreserved identifier because Dataform emits unique-key and source-side update references without backticks.
A MERGE that satisfies all six:
MERGE analytics.dim_users T
USING (SELECT id, name, email FROM raw.users) S
ON T.id = S.id
WHEN MATCHED THEN UPDATE SET id = S.id, name = S.name, email = S.email
WHEN NOT MATCHED THEN INSERT (id, name, email) VALUES (S.id, S.name, S.email);
converts to an incremental with a uniqueKey, and emits two warnings -
the conversion note and the schema precondition:
config {
type: "incremental",
schema: "analytics",
name: "dim_users",
protected: true,
uniqueKey: ["id"]
}
-- source: input.sql:1 (MERGE converted by sql2sqlx v0.1.0)
SELECT id, name, email FROM raw.users
Under those conditions the MERGE that Dataform generates for
type: "incremental" + uniqueKey has identical row-level effects when
the existing target’s columns exactly match the source output. Dataform
builds the generated assignments from live target metadata, which SQL text
alone cannot prove. Every conversion therefore also reports
TARGET_SCHEMA_REQUIRED; verify the complete target column set before its
first incremental run. The default operations strategy has no such
precondition.
Common reasons a MERGE stays operations (MERGE_FALLBACK): the UPDATE SET
omits a unique-key column, uses a condition or an expression; the ON clause
is not a pure same-named equality conjunction; INSERT ROW is used; or the
source is not an aliased subquery.
Metadata mapping¶
SQL |
Dataform config |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
any other option, or a non-literal value |
|
view column |
|
Options that do not have a dedicated Dataform key - or whose value is a
non-literal expression - are preserved verbatim under
bigquery.additionalOptions as raw SQL text, so nothing is silently dropped.
For instance a materialized view:
CREATE MATERIALIZED VIEW mart.mv
OPTIONS(enable_refresh = true, refresh_interval_minutes = 60)
AS SELECT k, COUNT(*) AS n FROM raw.t GROUP BY k;
produces:
config {
type: "view",
schema: "mart",
name: "mv",
materialized: true,
bigquery: {
additionalOptions: {
enable_refresh: "true",
refresh_interval_minutes: "60"
}
}
}
References and dependencies¶
These rules are summarized here and covered in depth, with examples, in the dependency model.
Every read of a table that some statement produces becomes
${ref(...)}, carrying the producer’s own qualification (ref("t"),ref("schema","t"), or the object form withdatabase).Reads additionally gain
dependencieson the latest writer that precedes the reader, so a future mutation is never pulled before an earlier read.Within one source file, a later writer also depends on readers since the prior write, preserving read-before-write order under parallel scheduling.
If a read occurs before the table’s eventual owner, it stays literal and reports
FUTURE_CREATOR; a generatedref()would reverse corpus order.Writers of the same table are chained in corpus order (sorted file path, then position); cross-file chains raise
ORDER_ASSUMED.Only operations that actually create a table/view can be elected with
hasOutput: trueand${self()}. Mutating DML is never misdeclared as a producer;--declare-externalcan declare an existing ownerless target.Duplicate producers of one target are demoted to ordered verbatim operations (
DUPLICATE_TARGET) - Dataform allows one owner per target. Demotion also drops the abandoned typed-conversion warnings (for exampleINSERT_INCREMENTAL) so the report describes the operations action that was actually emitted.Resource-attached DDL/DCL (
CREATE/DROP/ALTER SEARCH|VECTOR INDEX,CREATE/DROP ROW ACCESS POLICY, table-scopedGRANT/REVOKE) is kept verbatim but tracked as a writer of the affected table, so it is ordered after that table’s creator and after earlier mutations of the same table. Such statements are never elected as the table’s owner.CTE names, table aliases (including
alias.columnpaths),UNNEST, table-valued functions,EXTRACT(... FROM ...)and the statement’s own target are never rewritten.--declare-externalsynthesizestype: "declaration"files undersources/for referenced-but-never-produced tables (INFORMATION_SCHEMA,region-*, wildcard tables and table decorators excluded) and refs them too. Unqualified sources are declarable when--default-datasetresolves them.A reference or manual ordering edge that would create a cycle is omitted, the table path remains literal, and
DEPENDENCY_CYCLEis reported.
Warning codes¶
Every warning is a stable code plus a specific message and source location.
Gate and filter on the code. See the report reference for the
JSON shape and a triage workflow.
Code |
Meaning |
|---|---|
|
Statement kept verbatim; reason in message |
|
Column-list rewrite not provably safe |
|
CTAS with typed column list |
|
Special CREATE forms kept as operations |
|
Index or row-access-policy DDL kept verbatim and ordered after its |
|
Table-scoped |
|
Create-if-absent guard lost (or preserved per option) |
|
Typed Dataform action rebuilds a non- |
|
Plain CREATE TABLE kept as operations |
|
Plain CREATE without OR REPLACE / IF NOT EXISTS fails on re-run |
|
Plain CREATE emitted as a declaration |
|
INSERT without a query body |
|
Semantics note for the incremental conversion |
|
MERGE shape result / fallback reason |
|
Converted MERGE requires exact target/query schema compatibility |
|
Stored procedure body kept verbatim and not treated as immediately executed DML |
|
|
|
Standalone query needs review |
|
Whole-file script preserved; targets it writes |
|
Statement reads its own target; left literal |
|
Second producer demoted to operations |
|
Multi-file writer order inferred from sorted paths |
|
Read left literal because its eventual owner occurs later in corpus order |
|
Dependency omitted and reference left literal to keep the Dataform graph acyclic |