@sqlanvil/cli 1.20.1 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +59 -39
  2. package/bundle.js +786 -44
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **SQL workflow tool for BigQuery, Postgres, Supabase, and MySQL/MariaDB.**
4
4
 
5
- SQLAnvil is an open-source fork of [Dataform OSS](https://github.com/dataform-co/dataform) (Apache 2.0), extended with first-class PostgreSQL and Supabase support, plus adapters for other data sources. Define your data transformations in SQLX, have SQLAnvil compile them to idiomatic SQL, and run them against your warehouse.
5
+ SQLAnvil is an open-source fork of [Dataform OSS](https://github.com/dataform-co/dataform) (Apache 2.0), extended with first-class PostgreSQL, Supabase, and MySQL/MariaDB support. Define your data transformations in SQLX, have SQLAnvil compile them to idiomatic SQL for your warehouse, and run, validate, and document the whole DAG from one CLI.
6
6
 
7
7
  > **SQLAnvil is not affiliated with or endorsed by Google.** The Dataform name and related marks are trademarks of Google LLC. See [NOTICE](NOTICE) for attribution.
8
8
 
@@ -10,10 +10,21 @@ SQLAnvil is an open-source fork of [Dataform OSS](https://github.com/dataform-co
10
10
 
11
11
  ## Features
12
12
 
13
- - **BigQuery** — full support: partitioning, clustering, labels, materialized views, `MERGE`-based incremental upserts
14
- - **PostgreSQL** — idiomatic DDL: native partitioning, `INSERT ... ON CONFLICT` upserts, btree/gin/gist/brin indexes, tablespaces, fillfactor
15
- - **Supabase** — extends Postgres with RLS policies, Realtime publications, pgvector indexes, and Supabase Wrappers _(coming soon)_
16
- - **MySQL / MariaDB** — portable MySQL DDL: CTAS tables, `CREATE OR REPLACE VIEW`, `ON DUPLICATE KEY UPDATE` incremental upserts (one adapter, validated against both engines)
13
+ **Four first-class warehouses** — each adapter generates its warehouse's own idiomatic SQL, not translated BigQuery:
14
+
15
+ - **PostgreSQL** — native partitioning (RANGE/LIST/HASH), `INSERT … ON CONFLICT` upserts, btree/hash/gin/gist/brin indexes (unique, partial, `INCLUDE`, opclasses), tablespaces, fillfactor, materialized views with in-place refresh policies
16
+ - **Supabase** — everything Postgres, plus RLS policies, Realtime publications, pgvector indexes, and Supabase Wrappers (FDW) as declarative actions
17
+ - **MySQL / MariaDB** — one adapter validated against both engines: CTAS tables, `ON DUPLICATE KEY UPDATE` incremental upserts, the full `mysql: {}` config block (engine/charset/collation/rowFormat, secondary indexes incl. `FULLTEXT`/`SPATIAL` and prefix lengths, native partitioning), materialized-view emulation
18
+ - **BigQuery** — full upstream support: partitioning, clustering, labels, materialized views, `MERGE`-based incremental upserts
19
+
20
+ **Beyond transformation** — the pieces most SQL workflow tools leave outside the pipeline:
21
+
22
+ - **Cross-warehouse sources** — read BigQuery or MySQL/MariaDB from a Postgres/Supabase warehouse via named `connections:`: a live FDW bridge (Supabase Wrappers) or **runner-extract** (the CLI reads the source at run time and materializes a plain `ref()`-able table — keyless BigQuery supported). `sqlanvil introspect` scaffolds the declarations
23
+ - **File imports & exports** — `type: "import"` loads Parquet/CSV/JSON from `s3://`/`gs://`/local into a `ref()`-able table (DuckDB bridge on Postgres/Supabase, native `LOAD DATA` on BigQuery); `type: "export"` writes query results back out to files
24
+ - **Python script actions** — `python:` in `actions.yaml` runs file-staging and glue scripts as first-class DAG nodes: ordered by dependencies, covered by run history, no warehouse credentials injected (scripts stage files; `import` loads them). SQLAnvil validates the declared environment — interpreter version, `requirements.txt` vs installed packages, syntax — and never installs anything
25
+ - **`sqlanvil validate`** — EXPLAIN/dry-run the *whole DAG* against the live warehouse without executing: an isolated shadow schema of empty stubs lets every downstream `ref()` resolve; results classify as PASS / FAIL / BLOCKED so one real error doesn't cascade
26
+ - **Queryable artifacts** — every compile/run writes a Parquet catalog + run history under `target/`; `sqlanvil query` runs SQL over them, `sqlanvil inspect` summarizes, `sqlanvil docs` renders a self-contained HTML catalog
27
+ - **Release-artifact runs** — `sqlanvil run --graph <file>` executes a stored `compile --json` output exactly as compiled; **named environments** (`--environment`) give per-env schema suffixes and vars
17
28
  - **SQLX + YAML + JS** — three authoring modes: SQL with config blocks, `actions.yaml` bulk definitions, or the JavaScript API
18
29
 
19
30
  ---
@@ -24,23 +35,32 @@ SQLAnvil is an open-source fork of [Dataform OSS](https://github.com/dataform-co
24
35
  npm install -g @sqlanvil/cli
25
36
  sqlanvil init my-project
26
37
  cd my-project
27
- # edit workflow_settings.yaml to configure your warehouse
38
+ # edit workflow_settings.yaml + .df-credentials.json (see below)
28
39
  sqlanvil compile
29
40
  sqlanvil run
30
41
  ```
31
42
 
32
- **`workflow_settings.yaml` example (Postgres):**
43
+ **`workflow_settings.yaml` (Postgres/Supabase):** the warehouse is a flat string; connection
44
+ secrets never live here.
33
45
 
34
46
  ```yaml
35
- warehouse:
36
- kind: postgres
37
- host: localhost
38
- port: 5432
39
- database: analytics
40
- user: sqlanvil_writer
41
- password: ${PG_PASSWORD}
42
- ssl: disable
43
- defaultSchema: public
47
+ warehouse: postgres # bigquery | postgres | supabase | mysql
48
+ defaultDataset: public # the Postgres schema
49
+ defaultAssertionDataset: sqlanvil_assertions
50
+ sqlanvilCoreVersion: 1.20.1
51
+ ```
52
+
53
+ **`.df-credentials.json`** (gitignored) holds the connection:
54
+
55
+ ```json
56
+ {
57
+ "host": "localhost",
58
+ "port": 5432,
59
+ "database": "analytics",
60
+ "user": "sqlanvil_writer",
61
+ "password": "...",
62
+ "sslMode": "disable"
63
+ }
44
64
  ```
45
65
 
46
66
  **First action (`definitions/my_view.sqlx`):**
@@ -58,23 +78,29 @@ SELECT 1 AS id, 'hello' AS greeting
58
78
 
59
79
  ## Documentation
60
80
 
61
- Full documentation at **[sqlanvil.com](https://sqlanvil.com)**.
81
+ Full documentation at **[sqlanvil.com/docs](https://sqlanvil.com/docs/)**.
82
+
83
+ - [Getting Started](https://sqlanvil.com/docs/getting-started/)
84
+ - Warehouses: [PostgreSQL](https://sqlanvil.com/docs/guides/postgres/) · [Supabase](https://sqlanvil.com/docs/guides/supabase/) · [MySQL/MariaDB](https://sqlanvil.com/docs/guides/mysql/) · [BigQuery](https://sqlanvil.com/docs/guides/bigquery/)
85
+ - [Cross-warehouse sources](https://sqlanvil.com/docs/guides/foreign-wrappers/) · [File Imports](https://sqlanvil.com/docs/guides/imports/) · [File Exports](https://sqlanvil.com/docs/guides/exports/) · [Python Script Actions](https://sqlanvil.com/docs/guides/python-actions/)
86
+ - [Validate](https://sqlanvil.com/docs/guides/validate/) · [Named Environments](https://sqlanvil.com/docs/guides/environments/) · [Artifacts & Catalog](https://sqlanvil.com/docs/guides/artifacts/)
87
+ - [Configs Reference](https://sqlanvil.com/docs/reference/configs/) · [What's New](https://sqlanvil.com/docs/whats-new/)
62
88
 
63
- - [Getting Started](https://sqlanvil.com/getting-started/)
64
- - [BigQuery Guide](https://sqlanvil.com/guides/bigquery/)
65
- - [PostgreSQL Guide](https://sqlanvil.com/guides/postgres/)
66
- - [Supabase Guide](https://sqlanvil.com/guides/supabase/)
67
- - [Configs Reference](https://sqlanvil.com/reference/configs/)
89
+ Writing sqlanvil projects with an AI agent? Point it at [`AGENTS.md`](AGENTS.md) — it corrects
90
+ the Dataform/BigQuery priors that otherwise produce broken sqlanvil code. Contributor/design
91
+ docs live in the separate [`sqlanvil/docs`](https://github.com/sqlanvil/docs) repo.
68
92
 
69
93
  ---
70
94
 
71
95
  ## Project layout
72
96
 
73
97
  ```
74
- core/ Compiler + action types (table/view/incremental/assertion/operation/notebook/declaration)
75
- cli/ CLI entrypoint and per-adapter glue (cli/api/dbadapters/)
98
+ core/ Compiler + action types (table/view/incremental/assertion/operation/declaration/import/export/script/…)
99
+ cli/ CLI entrypoint and per-adapter glue (cli/api/dbadapters/ — bigquery, postgres, supabase, mysql)
76
100
  protos/ Protobuf definitions for core/configs/execution/db_adapter
77
- examples/ Sample SQLAnvil projects
101
+ tests/ Integration specs against real warehouses (Postgres, Supabase, MySQL/MariaDB, BigQuery)
102
+ tools/ Bazel rules + Docker fixtures for the integration databases
103
+ examples/ Sample SQLAnvil projects (see examples/supabase_bigquery_mailing_list)
78
104
  scripts/ ./scripts/run is the CLI entrypoint wrapper
79
105
  ```
80
106
 
@@ -82,28 +108,22 @@ scripts/ ./scripts/run is the CLI entrypoint wrapper
82
108
 
83
109
  ## Building from source
84
110
 
85
- SQLAnvil uses [Bazel](https://bazel.build) (via Bazelisk). Native Bazel works on macOS
86
- and Linux (Bazel 7 + bzlmod):
111
+ SQLAnvil uses [Bazel](https://bazel.build) 7 with bzlmod (via Bazelisk). Native Bazel works on
112
+ macOS and Linux:
87
113
 
88
114
  ```bash
89
- # Build the proto layer
90
- bazel build //protos:sqlanvil_proto
91
-
92
115
  # Run the CLI
93
116
  ./scripts/run help
94
117
 
95
118
  # Run tests
96
119
  bazel test //core/... //cli/...
97
- ```
98
120
 
99
- A Docker-based build container (`./scripts/docker-bazel`) remains available as an
100
- optional hermetic environment. See [contributing.md](contributing.md) for full instructions.
101
-
102
- ---
103
-
104
- ## Documentation
121
+ # Integration tests against a local Docker Postgres
122
+ ./tools/postgres/run-postgres-db.sh
123
+ ```
105
124
 
106
- Reference docs and design documents live in the separate [`sqlanvil/docs`](https://github.com/sqlanvil/docs) repo, published at [sqlanvil.com/docs](https://sqlanvil.com/docs/).
125
+ A Docker-based build container (`./scripts/docker-bazel`) remains available as an optional
126
+ hermetic environment. See [contributing.md](contributing.md) for full instructions.
107
127
 
108
128
  ---
109
129
 
@@ -111,4 +131,4 @@ Reference docs and design documents live in the separate [`sqlanvil/docs`](https
111
131
 
112
132
  Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
113
133
 
114
- SQLAnvil is a derivative of Dataform OSS (originally developed by Dataform Co and contributed to by Google LLC). It has been fully renamed and extended with first-class PostgreSQL/Supabase support. Licensed under Apache License 2.0.
134
+ SQLAnvil is a derivative of Dataform OSS (originally developed by Dataform Co and contributed to by Google LLC). It has been fully renamed and extended with first-class PostgreSQL/Supabase/MySQL support. Licensed under Apache License 2.0.
package/bundle.js CHANGED
@@ -49392,11 +49392,11 @@ function setOrValidateTableEnumType(table) {
49392
49392
  function prune(compiledGraph, runConfig) {
49393
49393
  compiledGraph.tables.forEach(setOrValidateTableEnumType);
49394
49394
  const includedActionNames = computeIncludedActionNames(compiledGraph, runConfig);
49395
- return Object.assign(Object.assign({}, compiledGraph), { tables: compiledGraph.tables.filter(action => includedActionNames.has(targetAsReadableString(action.target))), assertions: compiledGraph.assertions.filter(action => includedActionNames.has(targetAsReadableString(action.target))), operations: compiledGraph.operations.filter(action => includedActionNames.has(targetAsReadableString(action.target))), exports: compiledGraph.exports.filter(action => includedActionNames.has(targetAsReadableString(action.target))), imports: compiledGraph.imports.filter(action => includedActionNames.has(targetAsReadableString(action.target))), scripts: (compiledGraph.scripts || []).filter(action => includedActionNames.has(targetAsReadableString(action.target))) });
49395
+ return Object.assign(Object.assign({}, compiledGraph), { tables: compiledGraph.tables.filter(action => includedActionNames.has(targetAsReadableString(action.target))), assertions: compiledGraph.assertions.filter(action => includedActionNames.has(targetAsReadableString(action.target))), operations: compiledGraph.operations.filter(action => includedActionNames.has(targetAsReadableString(action.target))), exports: compiledGraph.exports.filter(action => includedActionNames.has(targetAsReadableString(action.target))), imports: compiledGraph.imports.filter(action => includedActionNames.has(targetAsReadableString(action.target))), extracts: (compiledGraph.extracts || []).filter(action => includedActionNames.has(targetAsReadableString(action.target))), scripts: (compiledGraph.scripts || []).filter(action => includedActionNames.has(targetAsReadableString(action.target))) });
49396
49396
  }
49397
49397
  function computeIncludedActionNames(compiledGraph, runConfig) {
49398
49398
  var _a, _b, _c;
49399
- const allActions = [].concat(compiledGraph.tables, compiledGraph.operations, compiledGraph.assertions, compiledGraph.exports, compiledGraph.imports, compiledGraph.scripts || []);
49399
+ const allActions = [].concat(compiledGraph.tables, compiledGraph.operations, compiledGraph.assertions, compiledGraph.exports, compiledGraph.imports, compiledGraph.extracts || [], compiledGraph.scripts || []);
49400
49400
  const allActionNames = new Set(allActions.map(action => targetAsReadableString(action.target)));
49401
49401
  const allActionsByName = new Map(allActions.map(action => [targetAsReadableString(action.target), action]));
49402
49402
  const hasActionSelector = ((_a = runConfig.actions) === null || _a === void 0 ? void 0 : _a.length) > 0;
@@ -50606,7 +50606,7 @@ function collectEvaluationQueries(queryOrAction, concatenate, queryModifier = (q
50606
50606
  .filter(validationQuery => !!validationQuery.query);
50607
50607
  }
50608
50608
 
50609
- const version = "1.20.1";
50609
+ const version = "1.22.0";
50610
50610
  const dataformVersion = "3.0.61";
50611
50611
 
50612
50612
  async function build(compiledGraph, runConfig, dbadapter) {
@@ -50995,10 +50995,10 @@ node_modules/
50995
50995
  function postgresCredentialsTemplate(warehouse) {
50996
50996
  const isSupabase = warehouse === "supabase";
50997
50997
  const template = {
50998
- host: isSupabase ? "db.<project-ref>.supabase.co" : "localhost",
50998
+ host: isSupabase ? "aws-1-<region>.pooler.supabase.com" : "localhost",
50999
50999
  port: 5432,
51000
51000
  database: "postgres",
51001
- user: "postgres",
51001
+ user: isSupabase ? "postgres.<your-project-ref>" : "postgres",
51002
51002
  password: "",
51003
51003
  sslMode: isSupabase ? "require" : "disable",
51004
51004
  defaultSchema: "public"
@@ -51041,7 +51041,9 @@ async function init(projectDir, projectConfig) {
51041
51041
  workflowSettings.defaultProject = projectConfig.defaultDatabase;
51042
51042
  workflowSettings.defaultLocation = projectConfig.defaultLocation;
51043
51043
  }
51044
- workflowSettings.defaultDataset = projectConfig.defaultSchema || "sqlanvil";
51044
+ const isPostgresLike = warehouse === "postgres" || warehouse === "supabase";
51045
+ workflowSettings.defaultDataset =
51046
+ projectConfig.defaultSchema || (isPostgresLike ? "public" : "sqlanvil");
51045
51047
  workflowSettings.defaultAssertionDataset =
51046
51048
  projectConfig.assertionSchema || "sqlanvil_assertions";
51047
51049
  if (isBigQuery && projectConfig.defaultIcebergConfig) {
@@ -51062,6 +51064,17 @@ async function init(projectDir, projectConfig) {
51062
51064
  if (projectConfig.builtinAssertionNamePrefix) {
51063
51065
  workflowSettings.builtinAssertionNamePrefix = projectConfig.builtinAssertionNamePrefix;
51064
51066
  }
51067
+ if (isPostgresLike) {
51068
+ workflowSettings.connections = {
51069
+ bigquery_public: sqlanvil.ConnectionConfig.create({
51070
+ platform: "bigquery",
51071
+ project: "bigquery-public-data",
51072
+ dataset: "geo_us_boundaries",
51073
+ billingProject: "REPLACE_WITH_YOUR_GCP_PROJECT",
51074
+ mode: "runner-extract"
51075
+ })
51076
+ };
51077
+ }
51065
51078
  fs__namespace$1.writeFileSync(workflowSettingsYamlPath, jsYaml.dump(workflowSettings));
51066
51079
  filesWritten.push(workflowSettingsYamlPath);
51067
51080
  fs__namespace$1.writeFileSync(gitignorePath, gitIgnoreContents);
@@ -51070,39 +51083,140 @@ async function init(projectDir, projectConfig) {
51070
51083
  fs__namespace$1.writeFileSync(path__namespace.join(projectDir, CREDENTIALS_FILENAME), warehouse === "mysql" ? mysqlCredentialsTemplate() : postgresCredentialsTemplate(warehouse));
51071
51084
  filesWritten.push(path__namespace.join(projectDir, CREDENTIALS_FILENAME));
51072
51085
  }
51073
- const definitionsDir = path__namespace.join(projectDir, "definitions");
51074
- fs__namespace$1.mkdirSync(definitionsDir);
51075
- dirsCreated.push(definitionsDir);
51076
- const sourcesDir = path__namespace.join(definitionsDir, "sources");
51077
- fs__namespace$1.mkdirSync(sourcesDir);
51078
- dirsCreated.push(sourcesDir);
51079
- const ecommerceDir = path__namespace.join(sourcesDir, "ecommerce");
51080
- fs__namespace$1.mkdirSync(ecommerceDir);
51081
- dirsCreated.push(ecommerceDir);
51082
- const intermediateDir = path__namespace.join(definitionsDir, "intermediate");
51083
- fs__namespace$1.mkdirSync(intermediateDir);
51084
- dirsCreated.push(intermediateDir);
51085
- const outputsDir = path__namespace.join(definitionsDir, "outputs");
51086
- fs__namespace$1.mkdirSync(outputsDir);
51087
- dirsCreated.push(outputsDir);
51088
- const salesDir = path__namespace.join(outputsDir, "sales");
51089
- fs__namespace$1.mkdirSync(salesDir);
51090
- dirsCreated.push(salesDir);
51091
- const ordersDir = path__namespace.join(outputsDir, "orders");
51092
- fs__namespace$1.mkdirSync(ordersDir);
51093
- dirsCreated.push(ordersDir);
51094
- const marketingDir = path__namespace.join(outputsDir, "marketing");
51095
- fs__namespace$1.mkdirSync(marketingDir);
51096
- dirsCreated.push(marketingDir);
51097
- const testDir = path__namespace.join(definitionsDir, "test");
51098
- fs__namespace$1.mkdirSync(testDir);
51099
- dirsCreated.push(testDir);
51100
- const extraDir = path__namespace.join(definitionsDir, "extra");
51101
- fs__namespace$1.mkdirSync(extraDir);
51102
- dirsCreated.push(extraDir);
51103
- const includesDir = path__namespace.join(projectDir, "includes");
51104
- fs__namespace$1.mkdirSync(includesDir);
51105
- dirsCreated.push(includesDir);
51086
+ const mkdir = (...segments) => {
51087
+ const dir = path__namespace.join(projectDir, ...segments);
51088
+ fs__namespace$1.mkdirSync(dir, { recursive: true });
51089
+ dirsCreated.push(dir);
51090
+ return dir;
51091
+ };
51092
+ const write = (filePath, contents) => {
51093
+ fs__namespace$1.writeFileSync(filePath, contents);
51094
+ filesWritten.push(filePath);
51095
+ };
51096
+ const keep = (dir) => write(path__namespace.join(dir, ".gitkeep"), "");
51097
+ mkdir("definitions");
51098
+ const sourcesDir = mkdir("definitions", "sources");
51099
+ const intermediateDir = mkdir("definitions", "intermediate");
51100
+ const salesDir = mkdir("definitions", "outputs", "sales");
51101
+ const reportingDir = mkdir("definitions", "outputs", "reporting");
51102
+ const testDir = mkdir("definitions", "test");
51103
+ keep(mkdir("includes"));
51104
+ const withBigQuerySource = warehouse !== "mysql";
51105
+ write(path__namespace.join(sourcesDir, "app_orders.sqlx"), `-- A declaration: a table that already exists in your warehouse (created by your
51106
+ -- application, an import, or another pipeline). Point name/dataset at a real table —
51107
+ -- downstream models read it with \${ref("app_orders")}.
51108
+ config {
51109
+ type: "declaration",
51110
+ name: "app_orders",
51111
+ description: "Raw orders table owned by the application — replace with one of your tables."
51112
+ }
51113
+ `);
51114
+ write(path__namespace.join(intermediateDir, "stg_app_orders.sqlx"), `config {
51115
+ type: "view",
51116
+ description: "Staging view over the raw orders: rename, cast, and filter here — downstream models only read staged data."
51117
+ }
51118
+
51119
+ SELECT
51120
+ order_id,
51121
+ product,
51122
+ quantity,
51123
+ amount,
51124
+ postal_code,
51125
+ ordered_at
51126
+ FROM \${ref("app_orders")}
51127
+ WHERE amount IS NOT NULL
51128
+ `);
51129
+ write(path__namespace.join(salesDir, "daily_sales.sqlx"), `config {
51130
+ type: "table",
51131
+ description: "Sales by day and product, built from the staged orders."
51132
+ }
51133
+
51134
+ SELECT
51135
+ CAST(ordered_at AS DATE) AS sale_date,
51136
+ product,
51137
+ SUM(quantity) AS units_sold,
51138
+ SUM(amount) AS revenue
51139
+ FROM \${ref("stg_app_orders")}
51140
+ GROUP BY CAST(ordered_at AS DATE), product
51141
+ `);
51142
+ write(path__namespace.join(reportingDir, "product_revenue.sqlx"), `config {
51143
+ type: "view",
51144
+ description: "Revenue by product, straight off the staged orders."
51145
+ }
51146
+
51147
+ SELECT
51148
+ product,
51149
+ SUM(quantity) AS units_sold,
51150
+ SUM(amount) AS revenue
51151
+ FROM \${ref("stg_app_orders")}
51152
+ GROUP BY product
51153
+ `);
51154
+ write(path__namespace.join(testDir, "assert_sales_amounts_positive.sqlx"), `config {
51155
+ type: "assertion",
51156
+ description: "Business rule: every sales row has positive units and revenue."
51157
+ }
51158
+
51159
+ -- An assertion FAILS when its query returns rows — select the violations.
51160
+ SELECT *
51161
+ FROM \${ref("daily_sales")}
51162
+ WHERE units_sold <= 0 OR revenue <= 0
51163
+ `);
51164
+ if (withBigQuerySource) {
51165
+ write(path__namespace.join(sourcesDir, "bigquery_zip_codes.sqlx"), isBigQuery
51166
+ ? `-- A declaration for a table in another project: Google's public ZIP geo data.
51167
+ -- BigQuery reads it natively — \${ref("zip_codes")} resolves to the full name.
51168
+ config {
51169
+ type: "declaration",
51170
+ database: "bigquery-public-data",
51171
+ schema: "geo_us_boundaries",
51172
+ name: "zip_codes",
51173
+ description: "US ZIP codes from BigQuery public data."
51174
+ }
51175
+ `
51176
+ : `-- A cross-warehouse source: Google's public ZIP geo data in BigQuery, read through
51177
+ -- the "bigquery_public" connection (see workflow_settings.yaml). With
51178
+ -- mode: runner-extract the CLI reads BigQuery at run time and materializes the rows
51179
+ -- as a plain table here — \${ref("zip_codes")} works like any other source.
51180
+ -- Scaffold real sources with: sqlanvil introspect bigquery_public <dataset.table>
51181
+ config {
51182
+ type: "declaration",
51183
+ connection: "bigquery_public",
51184
+ name: "zip_codes",
51185
+ description: "US ZIP codes from BigQuery public data, materialized by runner-extract.",
51186
+ columnTypes: {
51187
+ zip_code: "text",
51188
+ city: "text",
51189
+ state_code: "text"
51190
+ }
51191
+ }
51192
+ `);
51193
+ write(path__namespace.join(intermediateDir, "stg_zip_codes.sqlx"), `config {
51194
+ type: "view",
51195
+ description: "Staged ZIP geography from the BigQuery source."
51196
+ }
51197
+
51198
+ SELECT
51199
+ zip_code,
51200
+ city,
51201
+ state_code
51202
+ FROM \${ref("zip_codes")}
51203
+ `);
51204
+ write(path__namespace.join(reportingDir, "orders_by_region.sqlx"), `config {
51205
+ type: "table",
51206
+ description: "Orders and revenue by state/city — joins both staged sources."
51207
+ }
51208
+
51209
+ SELECT
51210
+ z.state_code,
51211
+ z.city,
51212
+ COUNT(*) AS orders,
51213
+ SUM(o.amount) AS revenue
51214
+ FROM \${ref("stg_app_orders")} o
51215
+ JOIN \${ref("stg_zip_codes")} z
51216
+ ON z.zip_code = o.postal_code
51217
+ GROUP BY z.state_code, z.city
51218
+ `);
51219
+ }
51106
51220
  return {
51107
51221
  filesWritten,
51108
51222
  dirsCreated
@@ -53427,7 +53541,7 @@ class Runner {
53427
53541
  return actionResult;
53428
53542
  }
53429
53543
  async executeTask(client, task, parentAction, options, action) {
53430
- var _a, _b, _c, _d, _e, _f, _g;
53544
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o, _p, _q;
53431
53545
  const timer = Timer.start();
53432
53546
  const taskResult = {
53433
53547
  status: sqlanvil.TaskResult.ExecutionStatus.RUNNING,
@@ -53499,7 +53613,14 @@ class Runner {
53499
53613
  }
53500
53614
  else if (task.type === "extract") {
53501
53615
  try {
53502
- const extractor = ((_d = action === null || action === void 0 ? void 0 : action.extract) === null || _d === void 0 ? void 0 : _d.platform) === "mysql"
53616
+ if (Object.keys((_e = (_d = action === null || action === void 0 ? void 0 : action.extract) === null || _d === void 0 ? void 0 : _d.columnTypes) !== null && _e !== void 0 ? _e : {}).length === 0) {
53617
+ const ds = ((_f = action === null || action === void 0 ? void 0 : action.extract) === null || _f === void 0 ? void 0 : _f.platform) === "mysql"
53618
+ ? (_g = action === null || action === void 0 ? void 0 : action.extract) === null || _g === void 0 ? void 0 : _g.database : (_h = action === null || action === void 0 ? void 0 : action.extract) === null || _h === void 0 ? void 0 : _h.dataset;
53619
+ throw new Error(`declaration "${(_j = action === null || action === void 0 ? void 0 : action.target) === null || _j === void 0 ? void 0 : _j.name}" has no columnTypes — scaffold them with: ` +
53620
+ `sqlanvil introspect ${(_k = action === null || action === void 0 ? void 0 : action.extract) === null || _k === void 0 ? void 0 : _k.connectionName} ${ds}.${(_l = action === null || action === void 0 ? void 0 : action.extract) === null || _l === void 0 ? void 0 : _l.sourceName} ` +
53621
+ `--output <its .sqlx file>`);
53622
+ }
53623
+ const extractor = ((_m = action === null || action === void 0 ? void 0 : action.extract) === null || _m === void 0 ? void 0 : _m.platform) === "mysql"
53503
53624
  ? this.executionOptions.mysqlExtract || runMysqlExtract
53504
53625
  : this.executionOptions.bigQueryExtract || runBigQueryExtract;
53505
53626
  await extractor({
@@ -53529,7 +53650,7 @@ class Runner {
53529
53650
  }), task.type === "operation" ? 1 : options.bigquery.actionRetryLimit + 1 || 1);
53530
53651
  taskResult.metadata = metadata;
53531
53652
  if (task.type === "assertion") {
53532
- const rowCount = (_e = rows[0]) === null || _e === void 0 ? void 0 : _e[Object.keys(rows[0])[0]];
53653
+ const rowCount = (_o = rows[0]) === null || _o === void 0 ? void 0 : _o[Object.keys(rows[0])[0]];
53533
53654
  if (rowCount > 0) {
53534
53655
  throw new Error(`Assertion failed: query returned ${rowCount} row(s).`);
53535
53656
  }
@@ -53541,7 +53662,7 @@ class Runner {
53541
53662
  ? sqlanvil.TaskResult.ExecutionStatus.CANCELLED
53542
53663
  : sqlanvil.TaskResult.ExecutionStatus.FAILED;
53543
53664
  taskResult.errorMessage = `${this.graph.projectConfig.warehouse} error: ${e.message}`;
53544
- if ((_g = (_f = e.metadata) === null || _f === void 0 ? void 0 : _f.bigquery) === null || _g === void 0 ? void 0 : _g.jobId) {
53665
+ if ((_q = (_p = e.metadata) === null || _p === void 0 ? void 0 : _p.bigquery) === null || _q === void 0 ? void 0 : _q.jobId) {
53545
53666
  taskResult.metadata = {
53546
53667
  bigquery: {
53547
53668
  jobId: e.metadata.bigquery.jobId
@@ -54061,6 +54182,596 @@ ${columnRows || '<tr><td colspan="3" class="meta">No documented columns.</td></t
54061
54182
  `;
54062
54183
  }
54063
54184
 
54185
+ function containsPath(parent, child) {
54186
+ const rel = path__namespace.relative(parent, child);
54187
+ return rel === "" || (!rel.startsWith("..") && !path__namespace.isAbsolute(rel));
54188
+ }
54189
+ class OutWriter {
54190
+ constructor(outDir) {
54191
+ this.outDir = outDir;
54192
+ }
54193
+ write(relPath, contents) {
54194
+ const full = path__namespace.resolve(this.outDir, relPath);
54195
+ if (!containsPath(path__namespace.resolve(this.outDir), full)) {
54196
+ throw new Error(`Refusing to write outside the output directory: ${relPath}`);
54197
+ }
54198
+ fs__namespace$1.mkdirSync(path__namespace.dirname(full), { recursive: true });
54199
+ fs__namespace$1.writeFileSync(full, contents);
54200
+ }
54201
+ }
54202
+ const SAFETY_SKIP = [
54203
+ /^\.git(\/|$)/,
54204
+ /(^|\/)node_modules(\/|$)/,
54205
+ /(^|\/)\.df-credentials[^/]*\.json$/,
54206
+ /service_account[^/]*\.json$/i,
54207
+ /(^|\/)\.env([^/]*)$/,
54208
+ /\.pem$/,
54209
+ /(^|\/)compile_output\.json$/,
54210
+ /(^|\/)\.DS_Store$/,
54211
+ /(^|\/)target(\/|$)/,
54212
+ ];
54213
+ function walkFiles(root) {
54214
+ const out = [];
54215
+ const walk = (rel) => {
54216
+ for (const entry of fs__namespace$1.readdirSync(path__namespace.join(root, rel), { withFileTypes: true })) {
54217
+ const relPath = rel ? `${rel}/${entry.name}` : entry.name;
54218
+ if (SAFETY_SKIP.some(re => re.test(relPath) || re.test(relPath + "/"))) {
54219
+ skippedForSafety.add(relPath);
54220
+ continue;
54221
+ }
54222
+ if (entry.isDirectory()) {
54223
+ walk(relPath);
54224
+ }
54225
+ else if (entry.isFile()) {
54226
+ out.push(relPath);
54227
+ }
54228
+ }
54229
+ };
54230
+ walk("");
54231
+ return out;
54232
+ }
54233
+ let skippedForSafety;
54234
+ function findConfigBlock(source) {
54235
+ const m = /(^|\n)\s*config\s*\{/.exec(source);
54236
+ if (!m)
54237
+ return null;
54238
+ const open = source.indexOf("{", m.index + m[0].indexOf("config"));
54239
+ let depth = 0;
54240
+ let inString = null;
54241
+ for (let i = open; i < source.length; i++) {
54242
+ const ch = source[i];
54243
+ const prev = source[i - 1];
54244
+ if (inString) {
54245
+ if (ch === inString && prev !== "\\")
54246
+ inString = null;
54247
+ continue;
54248
+ }
54249
+ if (ch === '"' || ch === "'" || ch === "`") {
54250
+ inString = ch;
54251
+ continue;
54252
+ }
54253
+ if (ch === "{")
54254
+ depth++;
54255
+ if (ch === "}") {
54256
+ depth--;
54257
+ if (depth === 0) {
54258
+ return {
54259
+ start: m.index + (m[1] ? m[1].length : 0),
54260
+ open,
54261
+ end: i + 1,
54262
+ body: source.slice(open + 1, i),
54263
+ };
54264
+ }
54265
+ }
54266
+ }
54267
+ return null;
54268
+ }
54269
+ function jsBlockLines(source) {
54270
+ const lines = new Set();
54271
+ const re = /(^|\n)\s*js\s*\{/g;
54272
+ let m;
54273
+ while ((m = re.exec(source))) {
54274
+ const open = source.indexOf("{", m.index + m[0].length - 1);
54275
+ let depth = 0;
54276
+ let end = -1;
54277
+ for (let i = open; i < source.length; i++) {
54278
+ if (source[i] === "{")
54279
+ depth++;
54280
+ if (source[i] === "}") {
54281
+ depth--;
54282
+ if (depth === 0) {
54283
+ end = i;
54284
+ break;
54285
+ }
54286
+ }
54287
+ }
54288
+ if (end < 0)
54289
+ end = source.length - 1;
54290
+ const startLine = source.slice(0, m.index + (m[1] ? m[1].length : 0)).split("\n").length - 1;
54291
+ const endLine = source.slice(0, end).split("\n").length - 1;
54292
+ for (let l = startLine; l <= endLine; l++)
54293
+ lines.add(l);
54294
+ }
54295
+ return lines;
54296
+ }
54297
+ const str = (field, body) => {
54298
+ const re = new RegExp(`${field}\\s*:\\s*["'\`]([^"'\`]+)["'\`]`);
54299
+ const m = re.exec(body);
54300
+ return m ? m[1] : null;
54301
+ };
54302
+ function parseSqlxConfig(body) {
54303
+ let database = str("database", body);
54304
+ if (!database && /database\s*:\s*(dataform|sqlanvil)\.projectConfig\.defaultDatabase/.test(body)) {
54305
+ database = "<defaultDatabase>";
54306
+ }
54307
+ return {
54308
+ type: str("type", body),
54309
+ schema: str("schema", body),
54310
+ database,
54311
+ name: str("name", body),
54312
+ description: str("description", body),
54313
+ };
54314
+ }
54315
+ const GLOBAL_RULES = [
54316
+ {
54317
+ pattern: /\bdataform\.projectConfig\b/g,
54318
+ replacement: "sqlanvil.projectConfig",
54319
+ kind: "rewrite",
54320
+ note: "dataform.projectConfig → sqlanvil.projectConfig (compile global renamed)",
54321
+ },
54322
+ ];
54323
+ const SQL_RULES = [
54324
+ {
54325
+ pattern: /\bSAFE_CAST\s*\(/g,
54326
+ replacement: "CAST(",
54327
+ kind: "rewrite",
54328
+ note: "SAFE_CAST → CAST — Postgres CAST raises on bad input; verify the data or guard it",
54329
+ },
54330
+ {
54331
+ pattern: /\bCURRENT_DATE\s*\(\s*\)/g,
54332
+ replacement: "CURRENT_DATE",
54333
+ kind: "rewrite",
54334
+ note: "CURRENT_DATE() → CURRENT_DATE",
54335
+ },
54336
+ {
54337
+ pattern: /\bCURRENT_DATE\s*\(\s*['"]/g,
54338
+ kind: "flag",
54339
+ note: "CURRENT_DATE('<tz>') — Postgres: (CURRENT_TIMESTAMP AT TIME ZONE '<tz>')::date",
54340
+ },
54341
+ {
54342
+ pattern: /\bSAFE_DIVIDE\s*\(/g,
54343
+ kind: "flag",
54344
+ note: "SAFE_DIVIDE(a, b) — Postgres: a / NULLIF(b, 0)",
54345
+ },
54346
+ {
54347
+ pattern: /\bSAFE_OFFSET\s*\(/g,
54348
+ kind: "flag",
54349
+ note: "array[SAFE_OFFSET(n)] — Postgres arrays are 1-based; use (split_part / arr[n+1])",
54350
+ },
54351
+ { pattern: /\bQUALIFY\b/gi, kind: "flag", note: "QUALIFY — rewrite as a subquery/CTE filter on the window function" },
54352
+ { pattern: /\bUNNEST\s*\(/g, kind: "flag", note: "UNNEST — Postgres unnest() works on arrays only (no STRUCT arrays); verify semantics" },
54353
+ { pattern: /\bSTRUCT\s*[(<]/g, kind: "flag", note: "STRUCT — no Postgres equivalent; consider jsonb or a composite type" },
54354
+ { pattern: /\bARRAY\s*</g, kind: "flag", note: "ARRAY<T> type syntax — Postgres uses T[]" },
54355
+ { pattern: /\bFORMAT_DATE\s*\(/g, kind: "flag", note: "FORMAT_DATE → TO_CHAR (different format tokens)" },
54356
+ { pattern: /\bPARSE_DATE\s*\(/g, kind: "flag", note: "PARSE_DATE → TO_DATE (different format tokens)" },
54357
+ { pattern: /\bFORMAT_TIMESTAMP\s*\(/g, kind: "flag", note: "FORMAT_TIMESTAMP → TO_CHAR (different format tokens)" },
54358
+ {
54359
+ pattern: /\b(DATE_ADD|DATE_SUB|TIMESTAMP_ADD|TIMESTAMP_SUB|DATETIME_ADD|DATETIME_SUB)\s*\(/g,
54360
+ kind: "flag",
54361
+ note: "BigQuery date arithmetic — Postgres uses interval arithmetic (x + INTERVAL '1 day')",
54362
+ },
54363
+ { pattern: /\bDATE_DIFF\s*\(/g, kind: "flag", note: "DATE_DIFF — Postgres: subtraction / EXTRACT(EPOCH FROM ...) depending on the part" },
54364
+ { pattern: /_PARTITIONTIME|_PARTITIONDATE|_TABLE_SUFFIX/g, kind: "flag", note: "BigQuery pseudo-column has no Postgres equivalent" },
54365
+ { pattern: /\bEXPORT\s+DATA\b/gi, kind: "flag", note: "EXPORT DATA — use a sqlanvil `type: \"export\"` action instead" },
54366
+ { pattern: /\bEXECUTE\s+IMMEDIATE\b/gi, kind: "flag", note: "BigQuery scripting (EXECUTE IMMEDIATE) — rewrite as a PL/pgSQL DO block or operations" },
54367
+ { pattern: /\bGENERATE_UUID\s*\(\s*\)/g, kind: "flag", note: "GENERATE_UUID() → gen_random_uuid()" },
54368
+ {
54369
+ pattern: /`[A-Za-z0-9_-]+\.[A-Za-z0-9_$]+\.[A-Za-z0-9_$]+`/g,
54370
+ kind: "flag",
54371
+ note: "backticked BigQuery FQN — use ${ref(...)} (declare it as a source) or a quoted Postgres name",
54372
+ },
54373
+ ];
54374
+ const CONFIG_BQ_KEYS = /^(\s*)(bigquery\s*:|partitionBy\s*:|clusterBy\s*:|requirePartitionFilter\s*:|partitionExpirationDays\s*:|database\s*:)/;
54375
+ const sanitize = (s) => s.toLowerCase().replace(/[^a-z0-9_]/g, "_");
54376
+ const connectionNameFor = (project) => `bq_${sanitize(project)}`;
54377
+ function convertDeclaration(source, parsed, connection, sourceDataset, filePath) {
54378
+ const span = findConfigBlock(source);
54379
+ const description = parsed.description
54380
+ ? ` description: ${JSON.stringify(parsed.description)},\n`
54381
+ : "";
54382
+ const newConfig = `config {
54383
+ type: "declaration",
54384
+ connection: ${JSON.stringify(connection)},
54385
+ schema: ${JSON.stringify(sourceDataset)},
54386
+ name: ${JSON.stringify(parsed.name)},
54387
+ ${description} columnTypes: {
54388
+ // SQLANVIL-MIGRATE(TODO): scaffold the columns from the live source:
54389
+ // sqlanvil introspect ${connection} ${sourceDataset}.${parsed.name} --output ${filePath}
54390
+ },
54391
+ }`;
54392
+ const content = source.slice(0, span.start) + newConfig + source.slice(span.end);
54393
+ return {
54394
+ content,
54395
+ findings: [
54396
+ {
54397
+ line: 1,
54398
+ kind: "rewrite",
54399
+ note: `declaration now reads BigQuery through connection "${connection}" (runner-extract); columnTypes need introspect`,
54400
+ },
54401
+ ],
54402
+ };
54403
+ }
54404
+ function convertTarget(source, jsMode) {
54405
+ const span = jsMode ? null : findConfigBlock(source);
54406
+ const findings = [];
54407
+ const lines = source.split("\n");
54408
+ const jsLines = jsMode ? new Set() : jsBlockLines(source);
54409
+ let configStartLine = -1;
54410
+ let configEndLine = -1;
54411
+ if (span) {
54412
+ configStartLine = source.slice(0, span.start).split("\n").length - 1;
54413
+ configEndLine = source.slice(0, span.end).split("\n").length - 1;
54414
+ }
54415
+ const out = [];
54416
+ let commentingConfigBlockDepth = 0;
54417
+ let swallowLeadingComma = false;
54418
+ for (let i = 0; i < lines.length; i++) {
54419
+ let line = lines[i];
54420
+ const inConfig = i >= configStartLine && i <= configEndLine && configStartLine >= 0;
54421
+ const markersSafe = !jsMode && !jsLines.has(i);
54422
+ const marker = (note) => inConfig ? `// SQLANVIL-MIGRATE: ${note}` : `-- SQLANVIL-MIGRATE: ${note}`;
54423
+ const lineNo = i + 1;
54424
+ const notesForLine = [];
54425
+ for (const rule of GLOBAL_RULES) {
54426
+ rule.pattern.lastIndex = 0;
54427
+ if (rule.pattern.test(line)) {
54428
+ line = line.replace(rule.pattern, rule.replacement);
54429
+ findings.push({ line: lineNo, kind: "rewrite", note: rule.note });
54430
+ }
54431
+ }
54432
+ if (swallowLeadingComma) {
54433
+ swallowLeadingComma = false;
54434
+ if (/^\s*,\s*$/.test(line)) {
54435
+ out.push(`// ${line}`);
54436
+ continue;
54437
+ }
54438
+ if (/^\s*,/.test(line)) {
54439
+ line = line.replace(/,/, " ");
54440
+ }
54441
+ }
54442
+ if (inConfig && commentingConfigBlockDepth === 0) {
54443
+ const m = CONFIG_BQ_KEYS.exec(line);
54444
+ if (m) {
54445
+ const note = `BigQuery-only setting — not applicable on Postgres (partitioning/clustering → postgres: {}; project qualifiers drop)`;
54446
+ findings.push({ line: lineNo, kind: "flag", note });
54447
+ out.push(marker(note));
54448
+ const opens = (line.match(/\{/g) || []).length;
54449
+ const closes = (line.match(/\}/g) || []).length;
54450
+ commentingConfigBlockDepth = Math.max(0, opens - closes);
54451
+ if (commentingConfigBlockDepth === 0)
54452
+ swallowLeadingComma = !/,\s*$/.test(line) ? true : false;
54453
+ out.push(`// ${line}`);
54454
+ continue;
54455
+ }
54456
+ }
54457
+ else if (commentingConfigBlockDepth > 0) {
54458
+ const opens = (line.match(/\{/g) || []).length;
54459
+ const closes = (line.match(/\}/g) || []).length;
54460
+ commentingConfigBlockDepth = Math.max(0, commentingConfigBlockDepth + opens - closes);
54461
+ if (commentingConfigBlockDepth === 0 && !/,\s*$/.test(line))
54462
+ swallowLeadingComma = true;
54463
+ out.push(`// ${line}`);
54464
+ continue;
54465
+ }
54466
+ if (!inConfig || jsMode) {
54467
+ for (const rule of SQL_RULES) {
54468
+ rule.pattern.lastIndex = 0;
54469
+ if (!rule.pattern.test(line))
54470
+ continue;
54471
+ if (rule.replacement !== undefined) {
54472
+ rule.pattern.lastIndex = 0;
54473
+ line = line.replace(rule.pattern, rule.replacement);
54474
+ findings.push({ line: lineNo, kind: "rewrite", note: rule.note });
54475
+ if (markersSafe)
54476
+ notesForLine.push(rule.note);
54477
+ }
54478
+ else {
54479
+ findings.push({ line: lineNo, kind: "flag", note: rule.note });
54480
+ if (markersSafe)
54481
+ notesForLine.push(rule.note);
54482
+ }
54483
+ }
54484
+ }
54485
+ for (const note of notesForLine) {
54486
+ out.push(marker(note));
54487
+ }
54488
+ out.push(line);
54489
+ }
54490
+ let content = out.join("\n");
54491
+ if (jsMode && findings.length > 0) {
54492
+ const noted = findings.slice(0, 12).map(f => ` * L${f.line}: ${f.note}`);
54493
+ const more = findings.length > 12 ? [` * …and ${findings.length - 12} more (see migration-report.json)`] : [];
54494
+ content =
54495
+ ["/* SQLANVIL-MIGRATE — findings in this file (inline markers are unsafe in JS templates):", ...noted, ...more, " */"].join("\n") +
54496
+ "\n" +
54497
+ content;
54498
+ }
54499
+ return { content, findings };
54500
+ }
54501
+ function readSourceConfig(srcDir) {
54502
+ var _a, _b, _c, _d, _e;
54503
+ const wsPath = path__namespace.join(srcDir, "workflow_settings.yaml");
54504
+ const djPath = path__namespace.join(srcDir, "dataform.json");
54505
+ let raw;
54506
+ if (fs__namespace$1.existsSync(wsPath)) {
54507
+ raw = jsYaml.load(fs__namespace$1.readFileSync(wsPath, "utf8")) || {};
54508
+ }
54509
+ else if (fs__namespace$1.existsSync(djPath)) {
54510
+ raw = JSON.parse(fs__namespace$1.readFileSync(djPath, "utf8"));
54511
+ }
54512
+ else {
54513
+ throw new Error(`${srcDir} does not look like a Dataform project (no workflow_settings.yaml or dataform.json).`);
54514
+ }
54515
+ const s = (k) => (typeof raw[k] === "string" ? raw[k] : null);
54516
+ return {
54517
+ defaultProject: (_a = s("defaultProject")) !== null && _a !== void 0 ? _a : s("defaultDatabase"),
54518
+ defaultDataset: (_c = (_b = s("defaultDataset")) !== null && _b !== void 0 ? _b : s("defaultSchema")) !== null && _c !== void 0 ? _c : "dataform",
54519
+ defaultAssertionDataset: (_d = s("defaultAssertionDataset")) !== null && _d !== void 0 ? _d : s("assertionSchema"),
54520
+ vars: (_e = raw.vars) !== null && _e !== void 0 ? _e : null,
54521
+ raw,
54522
+ };
54523
+ }
54524
+ async function migrateDataform(opts) {
54525
+ var _a, _b, _c, _d, _e, _f, _g, _h;
54526
+ const srcDir = path__namespace.resolve(opts.srcDir);
54527
+ const outDir = path__namespace.resolve(opts.outDir);
54528
+ if (containsPath(srcDir, outDir)) {
54529
+ throw new Error("The output directory must not be inside the source project.");
54530
+ }
54531
+ if (containsPath(outDir, srcDir)) {
54532
+ throw new Error("The source project must not be inside the output directory.");
54533
+ }
54534
+ if (fs__namespace$1.existsSync(outDir) && fs__namespace$1.readdirSync(outDir).length > 0) {
54535
+ throw new Error(`Output directory ${outDir} is not empty.`);
54536
+ }
54537
+ const sourceConfig = readSourceConfig(srcDir);
54538
+ const writer = new OutWriter(outDir);
54539
+ skippedForSafety = new Set();
54540
+ const files = walkFiles(srcDir);
54541
+ const report = {
54542
+ generator: `sqlanvil migrate-dataform ${version}`,
54543
+ sourceDir: srcDir,
54544
+ sourceConfig: sourceConfig.raw,
54545
+ inventory: { sqlxFiles: 0, jsFiles: 0, byType: {} },
54546
+ connections: [],
54547
+ overlappingSchemas: [],
54548
+ files: [],
54549
+ skippedForSafety: [],
54550
+ warnings: [],
54551
+ };
54552
+ const sqlxFiles = [];
54553
+ for (const rel of files) {
54554
+ if (!rel.endsWith(".sqlx"))
54555
+ continue;
54556
+ const source = fs__namespace$1.readFileSync(path__namespace.join(srcDir, rel), "utf8");
54557
+ const span = findConfigBlock(source);
54558
+ const parsed = span
54559
+ ? parseSqlxConfig(span.body)
54560
+ : { type: null, schema: null, database: null, name: null, description: null };
54561
+ sqlxFiles.push({ rel, source, parsed });
54562
+ report.inventory.sqlxFiles++;
54563
+ const t = (_a = parsed.type) !== null && _a !== void 0 ? _a : "(none)";
54564
+ report.inventory.byType[t] = ((_b = report.inventory.byType[t]) !== null && _b !== void 0 ? _b : 0) + 1;
54565
+ }
54566
+ const resolveDatabase = (db) => { var _a; return !db || db === "<defaultDatabase>" ? (_a = sourceConfig.defaultProject) !== null && _a !== void 0 ? _a : "UNKNOWN_PROJECT" : db; };
54567
+ const declarationSchemas = new Set();
54568
+ const targetSchemas = new Set();
54569
+ const connectionMap = new Map();
54570
+ for (const f of sqlxFiles) {
54571
+ if (f.parsed.type === "declaration") {
54572
+ const dataset = (_c = f.parsed.schema) !== null && _c !== void 0 ? _c : sourceConfig.defaultDataset;
54573
+ const project = resolveDatabase(f.parsed.database);
54574
+ declarationSchemas.add(dataset);
54575
+ const name = connectionNameFor(project);
54576
+ const existing = connectionMap.get(name);
54577
+ if (existing) {
54578
+ existing.declarationCount++;
54579
+ if (!existing.datasets.includes(dataset))
54580
+ existing.datasets.push(dataset);
54581
+ }
54582
+ else {
54583
+ connectionMap.set(name, {
54584
+ name,
54585
+ project,
54586
+ datasets: [dataset],
54587
+ declarationCount: 1,
54588
+ introspectExample: `sqlanvil introspect ${name} <dataset>.<table> --output <declaration.sqlx>`,
54589
+ });
54590
+ }
54591
+ }
54592
+ else if (f.parsed.type) {
54593
+ targetSchemas.add((_d = f.parsed.schema) !== null && _d !== void 0 ? _d : sourceConfig.defaultDataset);
54594
+ }
54595
+ }
54596
+ report.connections = [...connectionMap.values()].sort((a, b) => a.name.localeCompare(b.name));
54597
+ report.overlappingSchemas = [...declarationSchemas].filter(s => targetSchemas.has(s)).sort();
54598
+ for (const rel of files) {
54599
+ const abs = path__namespace.join(srcDir, rel);
54600
+ if (rel === "workflow_settings.yaml" || rel === "dataform.json") {
54601
+ continue;
54602
+ }
54603
+ if (rel.endsWith(".sqlx")) {
54604
+ const info = sqlxFiles.find(f => f.rel === rel);
54605
+ if (info.parsed.type === "declaration") {
54606
+ const dataset = (_e = info.parsed.schema) !== null && _e !== void 0 ? _e : sourceConfig.defaultDataset;
54607
+ const project = resolveDatabase(info.parsed.database);
54608
+ const connection = connectionNameFor(project);
54609
+ const { content, findings } = convertDeclaration(info.source, info.parsed, connection, dataset, rel);
54610
+ writer.write(rel, content);
54611
+ report.files.push({
54612
+ file: rel,
54613
+ action: "declaration",
54614
+ type: "declaration",
54615
+ status: "rewritten",
54616
+ findings,
54617
+ connection,
54618
+ });
54619
+ }
54620
+ else {
54621
+ const { content, findings } = convertTarget(info.source, false);
54622
+ writer.write(rel, content);
54623
+ report.files.push({
54624
+ file: rel,
54625
+ action: "target",
54626
+ type: (_f = info.parsed.type) !== null && _f !== void 0 ? _f : "(none)",
54627
+ status: findings.length === 0
54628
+ ? "clean"
54629
+ : findings.some(f => f.kind === "flag")
54630
+ ? "flagged"
54631
+ : "rewritten",
54632
+ findings,
54633
+ });
54634
+ }
54635
+ continue;
54636
+ }
54637
+ if (rel.endsWith(".js") && (rel.startsWith("definitions/") || rel.startsWith("includes/"))) {
54638
+ report.inventory.jsFiles++;
54639
+ const source = fs__namespace$1.readFileSync(abs, "utf8");
54640
+ const { content, findings } = convertTarget(source, true);
54641
+ writer.write(rel, content);
54642
+ const isIncludes = rel.startsWith("includes/");
54643
+ if (isIncludes && findings.length > 0) {
54644
+ report.warnings.push(`includes helper ${rel} emits BigQuery-flavored SQL — review its ${findings.length} finding(s)`);
54645
+ }
54646
+ report.files.push({
54647
+ file: rel,
54648
+ action: isIncludes ? "includes" : "target",
54649
+ type: "js",
54650
+ status: findings.length === 0
54651
+ ? "clean"
54652
+ : findings.some(f => f.kind === "flag")
54653
+ ? "flagged"
54654
+ : "rewritten",
54655
+ findings,
54656
+ });
54657
+ continue;
54658
+ }
54659
+ writer.write(rel, fs__namespace$1.readFileSync(abs));
54660
+ report.files.push({ file: rel, action: "copied", status: "clean", findings: [] });
54661
+ }
54662
+ const settings = {
54663
+ warehouse: "supabase",
54664
+ defaultDataset: sourceConfig.defaultDataset.toLowerCase(),
54665
+ defaultAssertionDataset: ((_g = sourceConfig.defaultAssertionDataset) !== null && _g !== void 0 ? _g : "sqlanvil_assertions").toLowerCase(),
54666
+ sqlanvilCoreVersion: (_h = opts.coreVersion) !== null && _h !== void 0 ? _h : version,
54667
+ };
54668
+ if (sourceConfig.vars)
54669
+ settings.vars = sourceConfig.vars;
54670
+ if (report.connections.length > 0) {
54671
+ settings.connections = Object.fromEntries(report.connections.map(c => {
54672
+ var _a;
54673
+ return [
54674
+ c.name,
54675
+ {
54676
+ platform: "bigquery",
54677
+ project: c.project,
54678
+ billingProject: (_a = sourceConfig.defaultProject) !== null && _a !== void 0 ? _a : "REPLACE_WITH_YOUR_GCP_PROJECT",
54679
+ mode: "runner-extract",
54680
+ },
54681
+ ];
54682
+ }));
54683
+ }
54684
+ writer.write("workflow_settings.yaml", jsYaml.dump(settings));
54685
+ report.skippedForSafety = [...skippedForSafety].sort();
54686
+ if (skippedForSafety.size > 0) {
54687
+ report.warnings.push(`Skipped ${skippedForSafety.size} file(s)/dir(s) for safety (credentials, build artifacts, VCS) — see skippedForSafety`);
54688
+ }
54689
+ writer.write("migration-report.json", JSON.stringify(report, null, 2));
54690
+ writer.write("migration-report.md", renderReportMd(report));
54691
+ return report;
54692
+ }
54693
+ function renderReportMd(r) {
54694
+ const targets = r.files.filter(f => f.action === "target");
54695
+ const flagged = targets.filter(f => f.status === "flagged");
54696
+ const rewritten = targets.filter(f => f.status === "rewritten");
54697
+ const clean = targets.filter(f => f.status === "clean");
54698
+ const declarations = r.files.filter(f => f.action === "declaration");
54699
+ const lines = [];
54700
+ lines.push(`# Dataform → SQLAnvil migration report`);
54701
+ lines.push("");
54702
+ lines.push(`Generated by ${r.generator} from \`${r.sourceDir}\` (source untouched).`);
54703
+ lines.push("");
54704
+ lines.push(`## Inventory`);
54705
+ lines.push("");
54706
+ lines.push(`- ${r.inventory.sqlxFiles} .sqlx, ${r.inventory.jsFiles} .js`);
54707
+ for (const [t, n] of Object.entries(r.inventory.byType).sort((a, b) => b[1] - a[1])) {
54708
+ lines.push(` - ${t}: ${n}`);
54709
+ }
54710
+ lines.push("");
54711
+ lines.push(`## Source connections (${r.connections.length})`);
54712
+ lines.push("");
54713
+ lines.push(`Declarations keep reading BigQuery through named \`connections:\` in workflow_settings.yaml ` +
54714
+ `(mode: runner-extract — no Vault, no wrappers). Each declaration needs \`columnTypes\` ` +
54715
+ `before it can extract; scaffold them with \`sqlanvil introspect\`:`);
54716
+ lines.push("");
54717
+ for (const c of r.connections) {
54718
+ lines.push(`- **${c.name}** — project \`${c.project}\`, ${c.declarationCount} declaration(s) across ` +
54719
+ `${c.datasets.length} dataset(s): ${c.datasets.slice(0, 8).map(d => `\`${d}\``).join(", ")}` +
54720
+ (c.datasets.length > 8 ? ", …" : ""));
54721
+ lines.push(` - \`${c.introspectExample}\``);
54722
+ }
54723
+ if (r.overlappingSchemas.length > 0) {
54724
+ lines.push("");
54725
+ lines.push(`## ⚠ Schemas that are BOTH source and target`);
54726
+ lines.push("");
54727
+ lines.push(`These schemas have declarations AND materializing actions: ${r.overlappingSchemas
54728
+ .map(s => `\`${s}\``)
54729
+ .join(", ")}. ` +
54730
+ `Their declared tables are probably produced by this pipeline (or a sibling) — once those ` +
54731
+ `producers move to Postgres, convert the declarations to plain local declarations instead ` +
54732
+ `of BigQuery connections.`);
54733
+ }
54734
+ lines.push("");
54735
+ lines.push(`## Target files (${targets.length})`);
54736
+ lines.push("");
54737
+ lines.push(`- clean: ${clean.length}`);
54738
+ lines.push(`- rewritten (safe lexical changes only): ${rewritten.length}`);
54739
+ lines.push(`- **flagged (need dialect review): ${flagged.length}** — every location is marked ` +
54740
+ `inline with \`SQLANVIL-MIGRATE:\``);
54741
+ lines.push("");
54742
+ for (const f of flagged) {
54743
+ lines.push(`- \`${f.file}\` (${f.type})`);
54744
+ for (const finding of f.findings.filter(x => x.kind === "flag").slice(0, 6)) {
54745
+ lines.push(` - L${finding.line}: ${finding.note}`);
54746
+ }
54747
+ const more = f.findings.filter(x => x.kind === "flag").length - 6;
54748
+ if (more > 0)
54749
+ lines.push(` - …and ${more} more (see migration-report.json)`);
54750
+ }
54751
+ lines.push("");
54752
+ lines.push(`## Declarations (${declarations.length})`);
54753
+ lines.push("");
54754
+ lines.push(`Every declaration was rewritten to ride its connection. The BigQuery data does not move.`);
54755
+ if (r.warnings.length > 0) {
54756
+ lines.push("");
54757
+ lines.push(`## Warnings`);
54758
+ lines.push("");
54759
+ for (const w of r.warnings)
54760
+ lines.push(`- ${w}`);
54761
+ }
54762
+ lines.push("");
54763
+ lines.push(`## Finish the migration`);
54764
+ lines.push("");
54765
+ lines.push(`1. \`sqlanvil compile\` — should already succeed.`);
54766
+ lines.push(`2. Scaffold \`columnTypes\` for the declarations you actually read (see connections above).`);
54767
+ lines.push(`3. \`sqlanvil validate\` against your Postgres/Supabase warehouse — PASS/FAILURE/BLOCKED per ` +
54768
+ `model is the migration to-do list. Work the flagged files until validate is green.`);
54769
+ lines.push(`4. This report is machine-readable (migration-report.json) — pointing an AI agent at it plus ` +
54770
+ `the \`SQLANVIL-MIGRATE:\` markers is the fastest path through the dialect work.`);
54771
+ lines.push("");
54772
+ return lines.join("\n");
54773
+ }
54774
+
54064
54775
  const CHECKER_SOURCE = `
54065
54776
  import json, os, sys
54066
54777
 
@@ -56125,7 +56836,7 @@ function runCli() {
56125
56836
  {
56126
56837
  format: `init [${projectDirOption.name}] [${ProjectConfigOptions.defaultDatabase.name}]` +
56127
56838
  ` [${ProjectConfigOptions.defaultLocation.name}]`,
56128
- description: "Create a new sqlanvil project (BigQuery, Postgres, or Supabase).",
56839
+ description: "Create a new sqlanvil project (BigQuery, Postgres, Supabase, or MySQL/MariaDB).",
56129
56840
  positionalOptions: [
56130
56841
  projectDirOption,
56131
56842
  positionalOption(ProjectConfigOptions.defaultDatabase.name, {
@@ -56729,6 +57440,37 @@ function runCli() {
56729
57440
  }
56730
57441
  return 0;
56731
57442
  }
57443
+ },
57444
+ {
57445
+ format: "migrate-dataform <source-dir> <out-dir>",
57446
+ description: "Convert a Dataform/BigQuery project into a sqlanvil-on-Postgres project. The source " +
57447
+ "directory is READ-ONLY; the converted project + migration-report.{md,json} land in " +
57448
+ "<out-dir> (must be empty). Sources stay in BigQuery as named connections; target SQL " +
57449
+ "gets safe rewrites + inline SQLANVIL-MIGRATE markers for dialect review.",
57450
+ positionalOptions: [
57451
+ positionalOption("source-dir", {
57452
+ describe: "The Dataform project to convert (never modified)."
57453
+ }),
57454
+ positionalOption("out-dir", {
57455
+ describe: "Where the converted sqlanvil project is written (created; must be empty)."
57456
+ })
57457
+ ],
57458
+ options: [],
57459
+ processFn: async (argv) => {
57460
+ const report = await migrateDataform({
57461
+ srcDir: argv["source-dir"],
57462
+ outDir: argv["out-dir"]
57463
+ });
57464
+ const targets = report.files.filter(f => f.action === "target");
57465
+ const flagged = targets.filter(f => f.status === "flagged").length;
57466
+ printSuccess(`Converted ${report.inventory.sqlxFiles} .sqlx file(s): ` +
57467
+ `${report.connections.length} source connection(s) over ` +
57468
+ `${report.files.filter(f => f.action === "declaration").length} declaration(s); ` +
57469
+ `${targets.length} target file(s), ${flagged} flagged for dialect review.`);
57470
+ print(`Report: ${path__namespace.join(argv["out-dir"], "migration-report.md")}`);
57471
+ print(`Next: sqlanvil compile ${argv["out-dir"]}`);
57472
+ return 0;
57473
+ }
56732
57474
  }
56733
57475
  ]
56734
57476
  })
package/package.json CHANGED
@@ -64,7 +64,7 @@
64
64
  "bin": {
65
65
  "sqlanvil": "bundle.js"
66
66
  },
67
- "version": "1.20.1",
67
+ "version": "1.22.0",
68
68
  "name": "@sqlanvil/cli",
69
69
  "description": "sqlanvil command line interface.",
70
70
  "main": "bundle.js"