Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5b1119d
feat(migrations): migrate incremental and snapshot history with migra…
kvlonge Sep 24, 2026
d1a82c5
feat(plan): preview another target with plan --as
kvlonge Sep 24, 2026
a67b8f5
feat(migrations): discover renamed models by migration fingerprint
kvlonge Sep 24, 2026
19f1a53
fix(migrations): redo interrupted clones and fail on missing origins
kvlonge Sep 24, 2026
d8dab52
fix(migrations): normalize only CTE and alias positions in fingerprints
kvlonge Sep 24, 2026
452e7c4
fix(migrations): match automatic migrations across the whole project
kvlonge Sep 24, 2026
d326bf6
fix(bigquery): reject model migrations
kvlonge Sep 24, 2026
36d9e87
fix(virtual): reject migrate_from in the virtual planning phase
kvlonge Sep 24, 2026
339b141
fix(plan): list error-severity diagnostics under an Errors heading
kvlonge Sep 24, 2026
6969711
fix(migrations): reject ambiguous CTE scopes and keep recorded renames
kvlonge Sep 25, 2026
45d211f
Merge remote-tracking branch 'origin/main' into feat/model-migrations
kvlonge Sep 25, 2026
bc50fd7
refactor(executor): extract staged-relation promotion from full refresh
kvlonge Sep 25, 2026
a4b56c1
feat(adapters): stage migrations by clone or copy per adapter
kvlonge Sep 25, 2026
5a06c71
feat(migrations): execute migrations through staged promotion with ar…
kvlonge Sep 25, 2026
b97a8ad
feat(migrations): hand over identity for renamed tables and views
kvlonge Sep 25, 2026
009d96d
test(migrations): cover staged migrations on PostgreSQL and through t…
kvlonge Sep 25, 2026
fbac925
fix(janitor): never archive the model migration state table
kvlonge Sep 25, 2026
59e7784
test(migrations): run the model migration lifecycle e2e in the CLI shard
kvlonge Sep 25, 2026
619fd37
fix(postgres): keep view options when re-pointing dependent views aft…
kvlonge Sep 25, 2026
6acd8e3
fix(migrations): create and write migration state through adapter-ren…
kvlonge Sep 25, 2026
8911e08
fix(migrations): fall back from clone to copy only on recognized clon…
kvlonge Sep 25, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ E2E_DUCKDB_CLI_PATHS := \
tests/e2e/src/sqlbuild/cli/commands/main/init \
tests/e2e/src/sqlbuild/cli/commands/main/rules \
tests/e2e/src/sqlbuild/cli/commands/main/lineage \
tests/e2e/src/sqlbuild/cli/commands/main/model_migrations \
tests/e2e/src/sqlbuild/cli/commands/main/motherduck \
tests/e2e/src/sqlbuild/cli/commands/main/playground \
tests/e2e/src/sqlbuild/cli/commands/main/postgres \
Expand Down
40 changes: 40 additions & 0 deletions src/sqlbuild/adapter/contract/classes/base_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
ExpressionInferenceProfile,
FunctionDefinition,
FunctionInfo,
MigrationStagePlan,
QueryResult,
RelationInfo,
RowDiffCoverage,
Expand All @@ -57,6 +58,7 @@
HistoricalSnapshotCloseStyle,
HistoricalSnapshotInsertStyle,
LoaderLogicalType,
MigrationTransfer,
PromotionStrategy,
SnapshotLatestVersionStyle,
SnapshotUpdateStyle,
Expand Down Expand Up @@ -702,6 +704,29 @@ def render_durable_clone(
del origin_is_transient
return self.render_create_table_as(destination=destination, sql=f"SELECT * FROM {origin}")

def render_migration_stage(
self,
*,
origin: str,
stage: str,
origin_is_transient: bool = False,
stage_is_transient: bool | None = None,
) -> MigrationStagePlan:
del origin_is_transient, stage_is_transient
return MigrationStagePlan(
transfer=MigrationTransfer.COPY,
statements=(f"CREATE TABLE {stage} AS SELECT * FROM {origin}",),
)

def capture_dependent_view_rebinds(
self, *, connection: Any, database: str | None, schema: str, name: str
) -> tuple[str, ...]:
del connection, database, schema, name
return ()

def supports_transactional_ddl(self) -> bool:
return False

def render_query_with_cursor_bounds(
self,
*,
Expand Down Expand Up @@ -2195,6 +2220,21 @@ def render_create_janitor_event_table_sql(self, *, database: str | None, schema:
transient=self.state_tables_transient,
)

def render_create_migration_state_table_sql(self, *, database: str | None, schema: str) -> str:
"""Render DDL that creates the model migration event table when it is missing."""

from sqlbuild.compiler.migrations.main.create_table_sql import (
build_migration_state_create_table_sql,
)

return build_migration_state_create_table_sql(
database=database,
schema=schema,
render_qualified_name=self.render_qualified_name,
render_framework_type=self.render_framework_type,
transient=self.state_tables_transient,
)

def render_prune_fingerprint_history_sql(
self,
*,
Expand Down
37 changes: 37 additions & 0 deletions src/sqlbuild/adapter/contract/classes/duckdb_backed_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
ExpressionInferenceProfile,
FunctionDefinition,
FunctionInfo,
MigrationStagePlan,
QueryResult,
RelationInfo,
RowDiffColumnResult,
Expand All @@ -61,6 +62,7 @@
CursorKind,
FrameworkType,
LoaderLogicalType,
MigrationTransfer,
PromotionStrategy,
TablePromotionMode,
)
Expand Down Expand Up @@ -851,6 +853,18 @@ def render_create_janitor_event_table_sql(self, *, database: str | None, schema:
render_framework_type=self.render_framework_type,
)

def render_create_migration_state_table_sql(self, *, database: str | None, schema: str) -> str:
from sqlbuild.compiler.migrations.main.create_table_sql import (
build_migration_state_create_table_sql,
)

return build_migration_state_create_table_sql(
database=database,
schema=schema,
render_qualified_name=self.render_qualified_name,
render_framework_type=self.render_framework_type,
)

def render_prune_fingerprint_history_sql(
self,
*,
Expand Down Expand Up @@ -1524,6 +1538,29 @@ def render_durable_clone(
del origin_is_transient
return self.render_create_table_as(destination=destination, sql=f"SELECT * FROM {origin}")

def render_migration_stage(
self,
*,
origin: str,
stage: str,
origin_is_transient: bool = False,
stage_is_transient: bool | None = None,
) -> MigrationStagePlan:
del origin_is_transient, stage_is_transient
return MigrationStagePlan(
transfer=MigrationTransfer.COPY,
statements=(f"CREATE TABLE {stage} AS SELECT * FROM {origin}",),
)

def capture_dependent_view_rebinds(
self, *, connection: Any, database: str | None, schema: str, name: str
) -> tuple[str, ...]:
del connection, database, schema, name
return ()

def supports_transactional_ddl(self) -> bool:
return True

def render_query_with_cursor_bounds(
self,
*,
Expand Down
30 changes: 30 additions & 0 deletions src/sqlbuild/adapter/contract/classes/strict_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
ColumnInfo,
CursorValue,
ExpressionInferenceProfile,
MigrationStagePlan,
RowDiffTolerance,
RowDiffTolerances,
SnapshotChangeTarget,
Expand Down Expand Up @@ -341,6 +342,30 @@ def render_durable_clone(
"""Render SQL statements that clone/copy into a durable independent destination."""
...

@abstractmethod
def render_migration_stage(
self,
*,
origin: str,
stage: str,
origin_is_transient: bool = False,
stage_is_transient: bool | None = None,
) -> MigrationStagePlan:
"""Render statements that create a fresh, independent stage holding the origin's data."""
...

@abstractmethod
def capture_dependent_view_rebinds(
self, *, connection: Any, database: str | None, schema: str, name: str
) -> tuple[str, ...]:
"""Capture statements that re-point identity-bound dependent views at this table name."""
...

@abstractmethod
def supports_transactional_ddl(self) -> bool:
"""Return whether renames and state inserts can commit or roll back together."""
...

@abstractmethod
def render_query_with_cursor_bounds(
self,
Expand Down Expand Up @@ -885,6 +910,11 @@ def render_create_janitor_event_table_sql(self, *, database: str | None, schema:
"""Render DDL that creates the janitor audit event table when it is missing."""
...

@abstractmethod
def render_create_migration_state_table_sql(self, *, database: str | None, schema: str) -> str:
"""Render DDL that creates the model migration event table when it is missing."""
...

@abstractmethod
def render_prune_fingerprint_history_sql(
self,
Expand Down
11 changes: 11 additions & 0 deletions src/sqlbuild/adapter/contract/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
HistoricalSnapshotCloseStyle,
HistoricalSnapshotInsertStyle,
LifeCycleEventKind,
MigrationTransfer,
RetentionChangePhase,
RetentionScope,
SnapshotLatestVersionStyle,
Expand Down Expand Up @@ -184,6 +185,16 @@ class RenderedRetentionChange:
statements: tuple[str, ...]


@dataclass(frozen=True)
class MigrationStagePlan:
"""Fresh migration stage statements, plus a copy used only for recognized clone refusals."""

transfer: MigrationTransfer
statements: tuple[str, ...]
fallback_statements: tuple[str, ...] = ()
is_clone_refusal: Callable[[BaseException], bool] | None = None


@dataclass(frozen=True)
class FunctionInfo:
"""Metadata for one discovered warehouse function or routine."""
Expand Down
13 changes: 13 additions & 0 deletions src/sqlbuild/adapter/contract/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ class PromotionStrategy(StrEnum):
CREATE_NEW = "create_new"


class MigrationTransfer(StrEnum):
"""How a model migration stage receives the origin's data."""

CLONE = "clone"
COPY = "copy"

@property
def label(self) -> str:
"""Return the human-readable plan label for this transfer."""

return "zero-copy clone" if self == MigrationTransfer.CLONE else "physical copy"


class TablePromotionMode(StrEnum):
IMMEDIATE = "immediate"
STAGED = "staged"
Expand Down
40 changes: 40 additions & 0 deletions src/sqlbuild/adapters/bigquery/_helpers/clone_refusal.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
"""Recognize BigQuery refusals to clone a table where a physical copy can still succeed."""

from __future__ import annotations

from sqlbuild.adapters.bigquery.constants import (
CLONE_REFUSAL_ERROR_CODE,
CLONE_REFUSAL_PHRASES,
CLONE_REFUSAL_REASONS,
MAX_CLONE_REFUSAL_ERROR_CHAIN,
)


def is_bigquery_clone_refusal(error: BaseException) -> bool:
"""Return whether a driver error in the cause chain is a clone-capability refusal."""

current: BaseException | None = error
depth: int = 0
while current is not None and depth < MAX_CLONE_REFUSAL_ERROR_CHAIN:
if _is_refusal(current):
return True
current = current.__cause__ or current.__context__
depth += 1
return False


def _is_refusal(error: BaseException) -> bool:
details: object | None = getattr(error, "errors", None)
if getattr(error, "code", None) != CLONE_REFUSAL_ERROR_CODE or not isinstance(details, list):
return False
reasons: set[str] = {
str(detail.get("reason", "")) for detail in details if isinstance(detail, dict)
}
messages: str = " ".join(
str(detail.get("message", "")) for detail in details if isinstance(detail, dict)
).lower()
return (
bool(reasons)
and reasons <= CLONE_REFUSAL_REASONS
and any(phrase in messages for phrase in CLONE_REFUSAL_PHRASES)
)
42 changes: 42 additions & 0 deletions src/sqlbuild/adapters/bigquery/classes/bigquery_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
ExpressionInferenceProfile,
FunctionDefinition,
FunctionInfo,
MigrationStagePlan,
QueryResult,
RelationInfo,
RenderedRetentionChange,
Expand Down Expand Up @@ -73,6 +74,7 @@
HistoricalSnapshotCloseStyle,
HistoricalSnapshotInsertStyle,
LoaderLogicalType,
MigrationTransfer,
PromotionStrategy,
RetentionChangePhase,
RetentionScope,
Expand All @@ -93,6 +95,7 @@
from sqlbuild.adapter.type_system.main.first_arg_nullability import first_arg_nullability
from sqlbuild.adapter.type_system.main.normalize_numeric_family import normalize_numeric_family
from sqlbuild.adapter.type_system.main.types_equal import types_equal
from sqlbuild.adapters.bigquery._helpers.clone_refusal import is_bigquery_clone_refusal
from sqlbuild.adapters.bigquery._helpers.statement_telemetry import affected_rows
from sqlbuild.adapters.bigquery.classes.bigquery_connection import _BigQueryConnection
from sqlbuild.adapters.bigquery.classes.bigquery_cursor import _BigQueryCursor
Expand Down Expand Up @@ -321,6 +324,18 @@ def render_create_janitor_event_table_sql(self, *, database: str | None, schema:
render_framework_type=self.render_framework_type,
)

def render_create_migration_state_table_sql(self, *, database: str | None, schema: str) -> str:
from sqlbuild.compiler.migrations.main.create_table_sql import (
build_migration_state_create_table_sql,
)

return build_migration_state_create_table_sql(
database=database,
schema=schema,
render_qualified_name=self.render_qualified_name,
render_framework_type=self.render_framework_type,
)

def render_prune_fingerprint_history_sql(
self,
*,
Expand Down Expand Up @@ -1712,6 +1727,33 @@ def render_durable_clone(
f"CLONE {self._quote_identifier_path(origin)}",
)

def render_migration_stage(
self,
*,
origin: str,
stage: str,
origin_is_transient: bool = False,
stage_is_transient: bool | None = None,
) -> MigrationStagePlan:
del origin_is_transient, stage_is_transient
quoted_stage: str = self._quote_identifier_path(stage)
quoted_origin: str = self._quote_identifier_path(origin)
return MigrationStagePlan(
transfer=MigrationTransfer.CLONE,
statements=(f"CREATE TABLE {quoted_stage} CLONE {quoted_origin}",),
fallback_statements=(f"CREATE TABLE {quoted_stage} COPY {quoted_origin}",),
is_clone_refusal=is_bigquery_clone_refusal,
)

def capture_dependent_view_rebinds(
self, *, connection: Any, database: str | None, schema: str, name: str
) -> tuple[str, ...]:
del connection, database, schema, name
return ()

def supports_transactional_ddl(self) -> bool:
return False

def render_query_with_cursor_bounds(
self,
*,
Expand Down
14 changes: 14 additions & 0 deletions src/sqlbuild/adapters/bigquery/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,3 +17,17 @@
SELECT_STATEMENT_TYPE: str = "SELECT"
TABLE_NAME_WILDCARD: str = "*"
TIMESTAMP_TYPE_TOKEN: str = "TIMESTAMP"

CLONE_REFUSAL_ERROR_CODE: int = 400
CLONE_REFUSAL_REASONS: frozenset[str] = frozenset({"invalid", "invalidQuery"})
CLONE_REFUSAL_PHRASES: tuple[str, ...] = (
"cannot clone",
"can't clone",
"clone is not supported",
"clones are not supported",
"cloning is not supported",
"not supported for clone",
"clones and snapshots",
"clone or snapshot",
)
MAX_CLONE_REFUSAL_ERROR_CHAIN: int = 8
Loading
Loading