Skip to content

Commit 730d640

Browse files
authored
feat(migrations): preserve column data on renames in incremental models (#860)
* feat(migrations): preserve column data on renames in incremental models Add column-level migrate_from for incremental, microbatch and snapshot models. Exact expression renames are detected automatically, near matches produce a plan hint, and declared renames run as in-place RENAME COLUMN before the delta with append-only, deterministic-ID events in _sqlbuild_column_migrations. Pure renames no longer trigger replay_on_change. Refs CHI-393 * test(migrations): cover column rename detection, planning and execution Refs CHI-393 * docs(migrations): document column migrations and regenerate skill references Refs CHI-393 * fix(planner): skip target cursor reads when the cursor column is absent A planned rename of the cursor column made the first cursor-bounds read query the new column name, which does not exist yet, and log a misleading 'Failed cursor bounds' line before the old column was read. Skip the target read when inspected columns show the cursor column is missing; the outcome is unchanged and no failure is reported. Refs CHI-393 * fix(cli): end text plan output with a completion line Maintenance notices such as M101 and M108 were the last visible output of a successful sqb plan. Text plan output now ends with a 'Plan complete' line after the Warnings section; JSON is unchanged. Refs CHI-393 * fix(planner): rename columns automatically only when the rename is the whole change Automatic detection matched projection text alone, so an expression rebound by a changed CTE, join, or filter was renamed in place and bypassed on_schema_change. Replay suppression compared projections by position and treated alias references as query changes, so a pure rename with ORDER BY on the new alias, or a reordered projection list, replayed and overwrote history. Prove renames against the canonical AST: everything except the output names must match, projections are matched by name through the rename map, ORDER BY and QUALIFY references to a renamed alias are restored to the old name, and reorders are refused under positional grouping or ordering. Unproven changes get no automatic rename and only a 'similar to' hint; declared renames keep the model's replay decision unchanged. Refs CHI-393 * fix(executor): re-read live columns before each in-place column rename Live columns were read once per model, so an overlapping build that renamed a later column between two renames made the second ALTER fail. Re-read them immediately before each rename and record a column that is already renamed. Refs CHI-393 * docs(migrations): document when column renames are detected automatically Refs CHI-393 * fix(planner): prove alias bindings before treating renamed references as equivalent ORDER BY and QUALIFY references to a renamed output were rewritten to the old name without proving they bind to the alias. When the new name was also an input column, DuckDB bound QUALIFY to the input, so the queries differed but the rename was applied automatically and replay was suppressed. Substitute only when the binding is proven: every relation the SELECT reads must have known columns (inspected physical columns of sources and upstream models, or named CTE and subquery outputs), the new name must not be an input column, and the old name must be absent from the inputs or passed through unchanged. Otherwise the change is not a rename, so nothing is renamed automatically and declared renames follow the normal replay policy. Refs CHI-393
1 parent 9065fc9 commit 730d640

104 files changed

Lines changed: 6573 additions & 119 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎fensu.toml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,12 @@ rule = "FFR601"
9393
path = "src/sqlbuild/adapters/sqlserver/classes/sqlserver_adapter.py"
9494
reason = "The SQL Server adapter intentionally keeps overrideable backend behavior on one class."
9595

96+
[[targets.python.rule_exceptions]]
97+
rule = "XSB051"
98+
path = "src/sqlbuild/executor/migrations/_helpers/columns.py"
99+
symbols = ["apply_model_column_renames"]
100+
reason = "Each in-place column rename must re-read live columns immediately before its ALTER so overlapping builds reconcile instead of failing."
101+
96102
[[targets.python.rule_exceptions]]
97103
rule = "FFS120"
98104
path = "src/sqlbuild/integrations/dbt/_helpers/profile/render.py"

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/CONTENTS.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ Bundled copies of every page on the SQLBuild documentation site, matching the in
3939
- [Type Enforcement](concepts/models/type-enforcement.md) (`concepts/models/type-enforcement`) - Understand declared model types, static checks, and runtime casting.
4040
- [Contracts](concepts/models/contracts.md) (`concepts/models/contracts`) - Validate required or exact model output schemas.
4141
- [Model migrations](concepts/models/migrations.md) (`concepts/models/migrations`) - Keep the history of an incremental or snapshot model when you rename it.
42+
- [Column migrations](concepts/models/column-migrations.md) (`concepts/models/column-migrations`) - Keep a column's history when you rename it in an incremental or snapshot model.
4243
- [Hooks](concepts/models/hooks.md) (`concepts/models/hooks`) - Run SQL or Python lifecycle hooks around model materialization.
4344
- [SQL Hooks](concepts/models/hooks/sql.md) (`concepts/models/hooks/sql`) - Define, parameterize, compile, and invoke reusable or inline SQL lifecycle hooks.
4445
- [Python Hooks](concepts/models/hooks/python.md) (`concepts/models/hooks/python`) - Define Python lifecycle hooks with runtime context, providers, SQL access, and skips.

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/cli/plan.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@
66
77
Online: https://sqlbuild.com/docs/cli/plan/
88

9+
## Contents
10+
11+
- Usage
12+
- Flags
13+
- Output
14+
- Missing upstream dependencies
15+
916
Shows the execution plan without making any changes. Useful for inspecting change detection, backfill policies, and selector scope before building.
1017

1118
## Usage
@@ -55,6 +62,8 @@ First run (12)
5562
5663
Seeds (1)
5764
waffle_types
65+
66+
✓ Plan complete 13 selected
5867
```
5968

6069
Steady state:
@@ -72,10 +81,24 @@ Normal (12)
7281
7382
Seeds (1)
7483
waffle_types
84+
85+
✓ Plan complete 13 selected
7586
```
7687

7788
When query or schema changes are detected, the plan shows the affected models with backfill actions and cascade information.
7889

90+
Warnings and maintenance notices, such as `migrate_from can be removed`, are listed in a `Warnings` section before the final `Plan complete` line. `sqb plan --json` output is unchanged.
91+
92+
Work that runs before models build is listed first. [Model migrations](../concepts/models/migrations.md) appear under `Migrations`, and in-place [column renames](../concepts/models/column-migrations.md) under `Column migrations`:
93+
94+
```
95+
Column migrations (1)
96+
└── fact_orders migrate columns
97+
└── amount -> revenue rename in place
98+
```
99+
100+
When a removed column and an added column look related but are not an exact rename, the schema diff shows a hint such as `similar to amount; if this is a rename, add revenue (migrate_from amount)` until the change is built.
101+
79102
## Missing upstream dependencies
80103

81104
Planning a scoped selection whose upstream inputs were never built in the target fails with `S301` rather than producing a plan that would break at build time:

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/incremental.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -393,3 +393,5 @@ Controls how schema differences are handled at execution time when the increment
393393
| `sync_all_columns` | Add, drop, and alter columns to match the delta |
394394
| `ignore` | Log and continue without schema changes |
395395
| `fail` | Reject the build with an error |
396+
397+
Columns are compared by name, so a renamed column looks like one column removed and one added. [Column migrations](models/column-migrations.md) rename the warehouse column in place before `on_schema_change` runs, so its history keeps its values; the policy then applies only to the differences that remain. Pure renames such as `amount` becoming `amount AS revenue` are detected automatically, and `sqb plan` suggests `migrate_from` for near matches.
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
<!-- generated-by: sqlbuild skills -->
2+
3+
# Column migrations
4+
5+
> Keep a column's history when you rename it in an incremental or snapshot model.
6+
7+
Online: https://sqlbuild.com/docs/concepts/models/column-migrations/
8+
9+
## Contents
10+
11+
- Declaring a rename
12+
- Automatic detection
13+
- Decisions
14+
- How a rename runs
15+
- State
16+
- Adapter support
17+
18+
`on_schema_change` compares columns by name, so renaming a column looks like one column removed and another added. With the default `append_new_columns`, the new column starts empty and older rows keep their values under the old name; with `sync_all_columns`, the old column and its data are dropped. A column migration renames the column in the warehouse instead, so every row keeps its value under the new name, with no leftover column and no replay.
19+
20+
Column migrations apply to incremental models, including microbatch models, and snapshot models. Tables and views are rebuilt on every change and don't need them.
21+
22+
## Declaring a rename
23+
24+
Add `migrate_from` to the renamed column in the model header, naming the old column:
25+
26+
```sql
27+
MODEL (
28+
materialized incremental,
29+
incremental_strategy delete_insert,
30+
unique_key order_id,
31+
cursor order_date,
32+
columns (
33+
revenue (migrate_from amount),
34+
),
35+
);
36+
37+
SELECT order_id, order_date, amount AS revenue
38+
FROM __source("raw_orders")
39+
```
40+
41+
On the next build, SQLBuild renames `amount` to `revenue` in the existing table, records the rename, and then builds the model incrementally, so the new delta lands in `revenue`.
42+
43+
A column entry may contain only `migrate_from`, or combine it with `type`, `nullable`, `description`, and `audits`. When the model binds a reusable schema with `model_schema`, an inherited column may add `migrate_from` without redeclaring its metadata.
44+
45+
After the rename is recorded, `migrate_from` has no further effect, and SQLBuild reports (`M108`) that it can be removed. Keep it until every target that needs the rename has built. On a target where the table doesn't exist yet, the declaration does nothing: the table is created with the new column name.
46+
47+
A declaration is rejected when compiling if:
48+
49+
- the model is not incremental or snapshot;
50+
- `migrate_from` names the column itself, or a qualified name instead of one column;
51+
- two columns declare the same `migrate_from`;
52+
- a declared column migrates from another column that itself declares `migrate_from`. Chains and swaps can't be renamed in place safely; rename through a name no other column uses, or rebuild with `--full-refresh`.
53+
54+
## Automatic detection
55+
56+
A pure rename is detected without a declaration: exactly one column that disappeared from the model has exactly the same expression as exactly one new column, for example `amount` becoming `amount AS revenue`, or `ROUND(amount, 2) AS revenue` becoming `ROUND(amount, 2) AS net_revenue`. Expressions are compared ignoring formatting, comments, and keyword and unquoted identifier case. Detection compares the query the table was last built from with the current query, so it applies to the build that makes the change.
57+
58+
A rename is only automatic when it is the whole change. Apart from the renamed output names, the query must be the same: the same CTEs, `FROM`, joins, filters, grouping, and other columns. References to the new name in `ORDER BY` or `QUALIFY` count as references to the old name only when SQLBuild can prove they bind to the output column: the columns of every relation the query reads must be known, from the inspected warehouse tables of its sources and upstream models or from CTEs and subqueries with named columns, the new name must not be an input column, and the old name must either not be an input column or be passed through unchanged. Otherwise the change is not treated as a rename, because engines differ on whether such a name refers to the output alias or the input column. The output columns may be reordered unless the query groups, orders, or uses `DISTINCT ON` by position. If anything else changed, an unchanged expression such as `amount` may now read different data, for example from a CTE that redefines `amount`, so nothing is renamed automatically and the change follows `on_schema_change`. Declare `migrate_from` to rename the column anyway.
59+
60+
Detection never guesses:
61+
62+
- Columns are matched by expression, not by position. Two columns renamed at once each pair with the column whose expression they kept; two columns that swap names keep their names and are not renamed.
63+
- If more than one removed column has the same expression, or more than one new column does, nothing is migrated automatically.
64+
- A near match, where the expressions reference the same columns but differ or the rest of the query changed, for example `amount` becoming `ROUND(amount, 2) AS revenue`, is not a rename. It follows the model's `on_schema_change` policy, and `sqb plan` shows a hint next to the added column until the change has been built:
65+
66+
```text
67+
├── schema diff:
68+
+ revenue (added)
69+
similar to amount; if this is a rename, add revenue (migrate_from amount)
70+
- amount (removed)
71+
```
72+
73+
Add the suggested `migrate_from` to keep the history. A declared rename with a changed expression or other query changes is still renamed in place, but it is a query change, so `replay_on_change` decides how much history is rebuilt, exactly as it would without the rename.
74+
75+
## Decisions
76+
77+
`sqb plan` and `sqb build` list column migrations before the models they change:
78+
79+
```text
80+
Column migrations (1)
81+
└── fact_orders migrate columns
82+
└── amount -> revenue rename in place
83+
```
84+
85+
Automatically detected renames are marked `(automatic)`. The decision is based on the table's current columns first, then on recorded renames:
86+
87+
| Decision | Meaning |
88+
|----------|---------|
89+
| `rename in place` | The old column exists and the new one doesn't. Rename it. |
90+
| `already renamed, record` | The new column exists and the old one doesn't, but no rename is recorded, which is what an interrupted rename leaves behind. Record it without renaming again. |
91+
| `done` | The rename is recorded and the new column exists. Nothing to do. |
92+
| `conflict` | Both columns exist. The build stops (`M110`); drop one of them, or remove `migrate_from` and let `on_schema_change` handle the columns. |
93+
| `source missing` | Neither column exists and no rename is recorded. The build stops (`M109`); remove `migrate_from` if the rename happened elsewhere or is no longer needed. |
94+
| `source still produced` | The query still selects the old column. The build stops (`M111`). |
95+
| `unsupported` | The warehouse can't rename this table's columns in place (see [Adapter support](#adapter-support)). The build stops (`M112`). |
96+
97+
Automatically detected renames never stop a build: when they can't be applied, the columns follow `on_schema_change` as before.
98+
99+
## How a rename runs
100+
101+
Renames run after [model migrations](migrations.md) and before any model builds, one model at a time:
102+
103+
1. The table's current columns are read again. A column that is already renamed is not renamed twice.
104+
2. The column is renamed with `ALTER TABLE ... RENAME COLUMN` or the adapter's equivalent.
105+
3. The rename is recorded.
106+
107+
On adapters with transactional DDL (PostgreSQL, SQL Server, DuckDB, MotherDuck), each model's renames and their records commit together. On other adapters, a build interrupted between the rename and the record is recovered by the `already renamed, record` decision on the next build. A build that fails after the rename, for example while applying the delta, resumes on the next build without renaming again.
108+
109+
After the rename, the remaining differences follow `on_schema_change`: `append_new_columns` adds any other new columns, `sync_all_columns` also drops removed ones and changes types, and `fail` only fails when a difference other than the rename remains.
110+
111+
A pure rename is not a query change. This applies to declared renames too, as long as the rename is the whole change by the rule above. It doesn't trigger `replay_on_change` for the model, and it doesn't cascade a replay to downstream models. Downstream models that you edit to use the new name have query changes of their own and follow their own `replay_on_change`. If the renamed column is the model's cursor, the next delta starts from the watermark stored under the old name.
112+
113+
## State
114+
115+
Each rename is recorded in an append-only `_sqlbuild_column_migrations` table in the model's schema, with the model, relation, old and new column, discovery (`manual` or `automatic`), and decision (`rename` or `record`). Janitor never archives it.
116+
117+
## Adapter support
118+
119+
| Adapter | Statement |
120+
|---------|-----------|
121+
| Snowflake, PostgreSQL, DuckDB, MotherDuck, BigQuery | `ALTER TABLE ... RENAME COLUMN ... TO ...` |
122+
| Databricks | `ALTER TABLE ... RENAME COLUMN ... TO ...`, which requires Delta column mapping |
123+
| SQL Server | `EXEC sp_rename '<schema>.<table>.<column>', '<new name>', 'COLUMN'` |
124+
125+
Databricks renames a Delta table column only when the table uses name-based column mapping (`delta.columnMapping.mode = 'name'`). SQLBuild checks this during planning and doesn't change the table protocol for you. A declared rename on a table without column mapping stops the build (`M112`) with the statement that enables it:
126+
127+
```sql
128+
ALTER TABLE <table> SET TBLPROPERTIES (
129+
'delta.columnMapping.mode' = 'name',
130+
'delta.minReaderVersion' = '2',
131+
'delta.minWriterVersion' = '5'
132+
);
133+
```
134+
135+
Enabling column mapping upgrades the table's Delta protocol, which older readers may not support. An automatically detected rename on such a table is skipped with a warning (`M112`) and follows `on_schema_change`.
136+
137+
On PostgreSQL, views that select the renamed column keep working and keep their own output column names. On SQL Server, `sp_rename` warns that renaming can break scripts and stored procedures that reference the old name. On PostgreSQL, the new name is created in lower case, as an unquoted identifier would be.

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/models/configuration.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Online: https://sqlbuild.com/docs/concepts/models/configuration/
2323
| `materialized` | `view`, `table`, `incremental`, `snapshot`, or a custom materialization name |
2424
| `tags` | Tags used by selectors |
2525
| `description` | Human-readable model description |
26-
| `columns` | Model-local column declarations or inherited-column audit augmentation |
26+
| `columns` | Model-local column declarations, inherited-column audit augmentation, or column renames with `migrate_from` (incremental and snapshot models; see [Column migrations](column-migrations.md)) |
2727
| `model_schema` | Reusable column schema name |
2828
| `audits` | Model-level audit instances |
2929
| `enums` | Model-local enum declarations; names must begin with `_` |

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/models/migrations.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ Online: https://sqlbuild.com/docs/concepts/models/migrations/
88

99
Renaming an incremental or snapshot model normally means rebuilding it from scratch under the new name, which loses any history the source data can no longer reproduce. A model migration moves the existing relation's data to the new name instead, then continues building incrementally.
1010

11-
Migrations apply to incremental and snapshot models.
11+
Migrations apply to incremental and snapshot models. To keep a column's history when you rename the column, see [Column migrations](column-migrations.md).
1212

1313
## Declaring a migration
1414

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/models/schemas.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ MODEL (
3939

4040
See [Audits](../audits.md) for built-in audits, custom audits, arguments, severity, and incremental run scope.
4141

42+
In incremental and snapshot models, an inline column can also declare `migrate_from <old column>` to rename the existing warehouse column in place and keep its history. See [Column migrations](column-migrations.md). Reusable `SCHEMA()` declarations don't accept `migrate_from`; a bound model adds it to an inherited column in its own header.
43+
4244
## Reusable schemas
4345

4446
When multiple models implement the same relation shape, declare it once in a schema role. SQLBuild discovers schema files recursively in each role.
@@ -147,6 +149,6 @@ Reusable schemas intentionally support a narrow ownership model:
147149

148150
- One optional parent, with transitive inheritance.
149151
- Additive child and model-local output columns.
150-
- Audit-only model augmentation of inherited columns.
152+
- Audit-only model augmentation of inherited columns, plus `migrate_from` column renames.
151153
- No general column overrides, multiple inheritance, composition, mixins, parameters, or generated projections.
152154
- No physical output ordinal enforcement.

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/concepts/snapshots.md‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ Reappearing keys create a new active version.
200200
| `valid_to_column` | No | Override generated column name. Default `valid_to`. |
201201
| `initial_valid_from` | No | First-version start time: `updated_at`, `observed_at`, or `execution_time`. See defaults below. |
202202
| `snapshot_full_refresh` | No | Model-level full-refresh safety: `deny`, `require_confirmation`, or `allow`. |
203+
| `columns (<new> (migrate_from <old>))` | No | Rename a snapshot column in place so every version keeps its value. See [Column migrations](models/column-migrations.md). |
204+
205+
Renaming a column in the snapshot query, such as `amount` becoming `amount AS revenue`, renames the existing snapshot column in place, so earlier versions keep their values under the new name. Renames are detected automatically when the expression is unchanged; declare `migrate_from` for anything else. See [Column migrations](models/column-migrations.md).
203206

204207
### Initial valid_from defaults
205208

‎src/sqlbuild/.agents/skills/sqlbuild/references/docs/quickstart.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ First run (12)
6868
6969
Seeds (1)
7070
waffle_types
71+
72+
✓ Plan complete 13 selected
7173
```
7274

7375
Source data is defined as inline expressions in `sources/raw.yml`, so SQLBuild resolves everything at compile time - no seeding step needed.
@@ -159,6 +161,8 @@ Normal (12)
159161
160162
Seeds (1)
161163
waffle_types
164+
165+
✓ Plan complete 13 selected
162166
```
163167

164168
All models now show `Normal` instead of `First run`.

0 commit comments

Comments
 (0)