Skip to content

Commit 8c71de8

Browse files
nicksspiritcursoragentmday-io
authored
fix(snowflake): change the clustering key before dropping a clustered column (#6176)
Signed-off-by: Nick Muoh <nickspirit3+github@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com> Co-authored-by: Michael Day <mdaytn@gmail.com>
1 parent 1dcd885 commit 8c71de8

6 files changed

Lines changed: 1056 additions & 16 deletions

File tree

‎sqlmesh/core/engine_adapter/mixins.py‎

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
if t.TYPE_CHECKING:
2020
from sqlmesh.core._typing import TableName
21+
from sqlmesh.core.engine_adapter.shared import DataObject
2122
from sqlmesh.core.engine_adapter._typing import (
2223
DCL,
2324
DF,
@@ -383,23 +384,53 @@ def get_alter_operations(
383384
self.get_data_objects(target_table_schema, {target_table.name}), 0
384385
)
385386

387+
clustering_operation: t.Optional[TableAlterClusterByOperation] = None
386388
if current_table_info and target_table_info:
387-
if target_table_info.is_clustered:
388-
if target_table_info.clustering_key and (
389-
current_table_info.clustering_key != target_table_info.clustering_key
390-
):
391-
operations.append(
392-
TableAlterChangeClusterKeyOperation(
393-
target_table=current_table,
394-
clustering_key=target_table_info.clustering_key,
395-
dialect=self.dialect,
396-
)
397-
)
389+
if target_table_info.is_clustered and target_table_info.clustering_key:
390+
# Include an unchanged key. Snowflake may have to drop it and set it
391+
# again when a key column is dropped and re-added. Other engines ignore
392+
# that operation in _apply_clustering_operation.
393+
clustering_operation = TableAlterChangeClusterKeyOperation(
394+
target_table=current_table,
395+
clustering_key=target_table_info.clustering_key,
396+
dialect=self.dialect,
397+
)
398398
elif current_table_info.is_clustered:
399-
operations.append(TableAlterDropClusterKeyOperation(target_table=current_table))
399+
clustering_operation = TableAlterDropClusterKeyOperation(target_table=current_table)
400+
401+
if clustering_operation:
402+
self._apply_clustering_operation(operations, clustering_operation, current_table_info)
400403

401404
return operations
402405

406+
def _clustering_key_is_unchanged(
407+
self,
408+
clustering_operation: "TableAlterClusterByOperation",
409+
current_table_info: t.Optional["DataObject"],
410+
) -> bool:
411+
return (
412+
isinstance(clustering_operation, TableAlterChangeClusterKeyOperation)
413+
and current_table_info is not None
414+
and current_table_info.clustering_key == clustering_operation.clustering_key
415+
)
416+
417+
def _apply_clustering_operation(
418+
self,
419+
operations: t.List[TableAlterOperation],
420+
clustering_operation: "TableAlterClusterByOperation",
421+
current_table_info: t.Optional["DataObject"],
422+
) -> None:
423+
"""Insert the clustering operation into the alter operations list.
424+
425+
The default implementation appends a real clustering change after all column
426+
changes. An unchanged key is ignored here. Snowflake overrides this so it can
427+
drop that key before removing a column the key names, then set the same key
428+
after the column comes back.
429+
"""
430+
if self._clustering_key_is_unchanged(clustering_operation, current_table_info):
431+
return
432+
operations.append(clustering_operation)
433+
403434

404435
def logical_merge(
405436
engine_adapter: EngineAdapter,

‎sqlmesh/core/engine_adapter/snowflake.py‎

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from __future__ import annotations
22

33
import contextlib
4+
import itertools
45
import logging
56
import typing as t
67

@@ -16,15 +17,18 @@
1617
ClusteredByMixin,
1718
RowDiffMixin,
1819
GrantsFromInfoSchemaMixin,
20+
TableAlterChangeClusterKeyOperation,
21+
TableAlterClusterByOperation,
22+
TableAlterDropClusterKeyOperation,
1923
)
24+
from sqlmesh.core.schema_diff import TableAlterDropColumnOperation, TableAlterOperation
2025
from sqlmesh.core.engine_adapter.shared import (
2126
CatalogSupport,
2227
DataObject,
2328
DataObjectType,
2429
SourceQuery,
2530
set_catalog,
2631
)
27-
from sqlmesh.core.schema_diff import TableAlterOperation
2832
from sqlmesh.utils import optional_import, get_source_columns_to_types
2933
from sqlmesh.utils.errors import SQLMeshError
3034
from sqlmesh.utils.pandas import columns_to_types_from_dtypes
@@ -692,6 +696,62 @@ def clone_table(
692696
**kwargs,
693697
)
694698

699+
def _apply_clustering_operation(
700+
self,
701+
operations: t.List[TableAlterOperation],
702+
clustering_operation: TableAlterClusterByOperation,
703+
current_table_info: t.Optional[DataObject],
704+
) -> None:
705+
"""Apply clustering DDL in the order Snowflake requires.
706+
707+
DROP COLUMN fails while the column is still in the clustering key, including
708+
inside an expression. CLUSTER BY fails on a column that does not exist yet. A
709+
type change that becomes DROP plus ADD still needs the key dropped first even
710+
when the key text is unchanged. An unchanged key with no matching column drop
711+
emits nothing. DESCRIBE names are compared as stored.
712+
"""
713+
clustering_key = current_table_info.clustering_key if current_table_info else None
714+
operations_include_drop_column = any(
715+
isinstance(op, TableAlterDropColumnOperation) for op in operations
716+
)
717+
718+
if clustering_key and operations_include_drop_column:
719+
referenced = self._clustering_key_column_names(clustering_key)
720+
drop_index = next(
721+
(
722+
i
723+
for i, op in enumerate(operations)
724+
if isinstance(op, TableAlterDropColumnOperation)
725+
and op.column_parts
726+
and op.column_parts[0].name in referenced
727+
),
728+
None,
729+
)
730+
if drop_index is not None:
731+
drop_key_op = TableAlterDropClusterKeyOperation(
732+
target_table=clustering_operation.target_table
733+
)
734+
operations.insert(drop_index, drop_key_op)
735+
if isinstance(clustering_operation, TableAlterChangeClusterKeyOperation):
736+
operations.append(clustering_operation)
737+
return
738+
if self._clustering_key_is_unchanged(clustering_operation, current_table_info):
739+
return
740+
operations.append(clustering_operation)
741+
742+
def _clustering_key_column_names(self, clustering_key: str) -> t.Set[str]:
743+
expressions = TableAlterChangeClusterKeyOperation(
744+
target_table=exp.to_table("_"),
745+
clustering_key=clustering_key,
746+
dialect=self.dialect,
747+
).cluster_key_expressions
748+
cluster_columns = itertools.chain.from_iterable(
749+
expression.find_all(exp.Column) for expression in expressions
750+
)
751+
return {
752+
normalize_identifiers(column, dialect=self.dialect).name for column in cluster_columns
753+
}
754+
695755
def alter_table(
696756
self,
697757
alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]],

‎tests/core/engine_adapter/test_bigquery.py‎

Lines changed: 161 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,12 @@
1313
import sqlmesh.core.dialect as d
1414
from sqlmesh.core.engine_adapter import BigQueryEngineAdapter
1515
from sqlmesh.core.engine_adapter.bigquery import select_partitions_expr
16-
from sqlmesh.core.engine_adapter.shared import DataObjectType
16+
from sqlmesh.core.engine_adapter.mixins import (
17+
TableAlterClusterByOperation,
18+
TableAlterDropClusterKeyOperation,
19+
)
20+
from sqlmesh.core.engine_adapter.shared import DataObject, DataObjectType
21+
from sqlmesh.core.schema_diff import TableAlterDropColumnOperation
1722
from sqlmesh.core.node import IntervalUnit
1823
from sqlmesh.utils import AttributeDict
1924
from sqlmesh.utils.errors import SQLMeshError
@@ -1380,3 +1385,158 @@ def test_sync_grants_config_no_schema(
13801385

13811386
with pytest.raises(ValueError, match="Table test_table does not have a schema \\(dataset\\)"):
13821387
adapter.sync_grants_config(relation, new_grants_config)
1388+
1389+
1390+
def _mock_bq_data_objects(
1391+
mocker: MockerFixture,
1392+
adapter: BigQueryEngineAdapter,
1393+
current_obj: DataObject,
1394+
target_obj: DataObject,
1395+
) -> None:
1396+
"""Patch _get_data_objects on *adapter* to return the supplied DataObjects."""
1397+
1398+
def _side_effect(
1399+
schema_name: t.Any, object_names: t.Optional[t.Set[str]] = None
1400+
) -> t.List[DataObject]:
1401+
results = []
1402+
for obj in [current_obj, target_obj]:
1403+
if object_names is None or obj.name in object_names:
1404+
results.append(obj)
1405+
return results
1406+
1407+
mocker.patch.object(adapter, "_get_data_objects", side_effect=_side_effect)
1408+
1409+
1410+
def test_bigquery_clustering_column_drop_before_clustering_change(
1411+
make_mocked_engine_adapter: t.Callable, mocker: MockerFixture
1412+
) -> None:
1413+
"""BigQuery keeps the historical order: DROP COLUMN before clustering key change.
1414+
1415+
Dropping a column that belongs to the current clustering key must NOT trigger
1416+
a reorder. BigQuery must emit DROP COLUMN first, then DROP CLUSTERING KEY.
1417+
"""
1418+
adapter = make_mocked_engine_adapter(BigQueryEngineAdapter, patch_get_data_objects=False)
1419+
1420+
current_columns = {"tenant_id": "INT64", "partition_month": "INT64"}
1421+
target_columns = {"tenant_id": "INT64"}
1422+
adapter.columns = lambda table_name, **kwargs: {
1423+
k: exp.DataType.build(v, dialect="bigquery")
1424+
for k, v in (
1425+
current_columns if table_name == "project.dataset.current_table" else target_columns
1426+
).items()
1427+
}
1428+
1429+
_mock_bq_data_objects(
1430+
mocker,
1431+
adapter,
1432+
current_obj=DataObject(
1433+
catalog="project",
1434+
schema="dataset",
1435+
name="current_table",
1436+
type=DataObjectType.TABLE,
1437+
clustering_key="(partition_month)",
1438+
),
1439+
target_obj=DataObject(
1440+
catalog="project",
1441+
schema="dataset",
1442+
name="target_table",
1443+
type=DataObjectType.TABLE,
1444+
clustering_key=None,
1445+
),
1446+
)
1447+
1448+
operations = adapter.get_alter_operations(
1449+
"project.dataset.current_table", "project.dataset.target_table"
1450+
)
1451+
1452+
assert len(operations) == 2
1453+
assert isinstance(operations[0], TableAlterDropColumnOperation)
1454+
assert isinstance(operations[1], TableAlterDropClusterKeyOperation)
1455+
1456+
1457+
def test_bigquery_no_column_change_clustering_drop_only(
1458+
make_mocked_engine_adapter: t.Callable, mocker: MockerFixture
1459+
) -> None:
1460+
"""BigQuery: same columns, only clustering key removed — one DROP CLUSTERING KEY."""
1461+
adapter = make_mocked_engine_adapter(BigQueryEngineAdapter, patch_get_data_objects=False)
1462+
1463+
columns = {"tenant_id": "INT64", "partition_month": "INT64"}
1464+
adapter.columns = lambda table_name, **kwargs: {
1465+
k: exp.DataType.build(v, dialect="bigquery") for k, v in columns.items()
1466+
}
1467+
1468+
_mock_bq_data_objects(
1469+
mocker,
1470+
adapter,
1471+
current_obj=DataObject(
1472+
catalog="project",
1473+
schema="dataset",
1474+
name="current_table",
1475+
type=DataObjectType.TABLE,
1476+
clustering_key="(partition_month)",
1477+
),
1478+
target_obj=DataObject(
1479+
catalog="project",
1480+
schema="dataset",
1481+
name="target_table",
1482+
type=DataObjectType.TABLE,
1483+
clustering_key=None,
1484+
),
1485+
)
1486+
1487+
operations = adapter.get_alter_operations(
1488+
"project.dataset.current_table", "project.dataset.target_table"
1489+
)
1490+
1491+
assert len(operations) == 1
1492+
assert isinstance(operations[0], TableAlterDropClusterKeyOperation)
1493+
1494+
1495+
def test_unchanged_clustering_key_is_not_altered_when_column_added(
1496+
adapter: BigQueryEngineAdapter, mocker: MockerFixture
1497+
):
1498+
# Adding a column to a clustered table must not emit a clustering change when the key
1499+
# itself is unchanged. BigQuery applies a new key by rewriting the clustered columns.
1500+
current_columns = {"c1": exp.DataType.build("INT64", dialect="bigquery")}
1501+
target_columns = {
1502+
**current_columns,
1503+
"c2": exp.DataType.build("INT64", dialect="bigquery"),
1504+
}
1505+
1506+
def columns(table_name: str, include_pseudo_columns: bool = False) -> t.Dict[str, exp.DataType]:
1507+
if exp.to_table(table_name).name == "current_table":
1508+
return current_columns
1509+
return target_columns
1510+
1511+
mocker.patch.object(adapter, "columns", side_effect=columns)
1512+
1513+
def get_data_objects(
1514+
schema_name: exp.Table,
1515+
object_names: t.Optional[t.Set[str]] = None,
1516+
safe_to_cache: bool = False,
1517+
) -> t.List[DataObject]:
1518+
assert object_names is not None
1519+
return [
1520+
DataObject(
1521+
schema=schema_name.db,
1522+
name=next(iter(object_names)),
1523+
type=DataObjectType.TABLE,
1524+
clustering_key="(c1)",
1525+
)
1526+
]
1527+
1528+
mocker.patch.object(adapter, "get_data_objects", side_effect=get_data_objects)
1529+
# Let a wrongly emitted clustering change surface as the column-rewrite UPDATE.
1530+
mocker.patch.object(adapter, "_get_table", return_value=mocker.Mock())
1531+
mocker.patch.object(adapter, "_db_call")
1532+
1533+
operations = adapter.get_alter_operations(
1534+
"test_schema.current_table", "test_schema.target_table"
1535+
)
1536+
assert not any(isinstance(operation, TableAlterClusterByOperation) for operation in operations)
1537+
1538+
adapter.alter_table(operations)
1539+
1540+
assert _to_sql_calls(adapter) == [
1541+
"ALTER TABLE `test_schema`.`current_table` ADD COLUMN `c2` INT64"
1542+
]

‎tests/core/engine_adapter/test_redshift.py‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import sqlmesh.core.dialect as d
1212
from sqlmesh.core.engine_adapter import RedshiftEngineAdapter
1313
from sqlmesh.core.engine_adapter.shared import DataObject, DataObjectType
14+
from sqlmesh.core.schema_diff import TableAlterDropColumnOperation
1415
from sqlmesh.core.model import load_sql_based_model
1516
from sqlmesh.core.model.definition import SqlModel
1617
from sqlmesh.utils.errors import SQLMeshError
@@ -912,3 +913,46 @@ def test_merge_logical(
912913
'INSERT INTO "target" ("ID", "ts") SELECT "ID", "ts" FROM (SELECT "ID" AS "ID", "ts" AS "ts", ROW_NUMBER() OVER (PARTITION BY "ID" ORDER BY "ID") AS _row_number FROM "__temp_test_abcdefgh") AS _t WHERE _row_number = 1',
913914
'DROP TABLE IF EXISTS "__temp_test_abcdefgh"',
914915
]
916+
917+
918+
def test_redshift_no_clustering_operations_on_column_drop(
919+
make_mocked_engine_adapter: t.Callable,
920+
) -> None:
921+
"""Dropping a column on Redshift never emits clustering statements."""
922+
adapter = make_mocked_engine_adapter(RedshiftEngineAdapter)
923+
adapter.cursor.fetchall.return_value = []
924+
925+
current_columns = {"TENANT_ID": "INT", "PARTITION_MONTH": "INT"}
926+
target_columns = {"TENANT_ID": "INT"}
927+
adapter.columns = lambda table_name, **kwargs: {
928+
k: exp.DataType.build(v)
929+
for k, v in (current_columns if "current" in str(table_name) else target_columns).items()
930+
}
931+
932+
operations = adapter.get_alter_operations("current_table", "target_table")
933+
934+
assert len(operations) == 1
935+
assert all(isinstance(op, TableAlterDropColumnOperation) for op in operations)
936+
937+
938+
def test_redshift_multiple_column_drops_no_clustering_operations(
939+
make_mocked_engine_adapter: t.Callable,
940+
) -> None:
941+
"""Dropping multiple columns on Redshift preserves the schema-diff order.
942+
943+
No clustering operations must be injected between or after the column drops.
944+
"""
945+
adapter = make_mocked_engine_adapter(RedshiftEngineAdapter)
946+
adapter.cursor.fetchall.return_value = []
947+
948+
current_columns = {"EXTRA": "INT", "PARTITION_MONTH": "INT", "TENANT_ID": "INT"}
949+
target_columns = {"TENANT_ID": "INT"}
950+
adapter.columns = lambda table_name, **kwargs: {
951+
k: exp.DataType.build(v)
952+
for k, v in (current_columns if "current" in str(table_name) else target_columns).items()
953+
}
954+
955+
operations = adapter.get_alter_operations("current_table", "target_table")
956+
957+
assert len(operations) == 2
958+
assert all(isinstance(op, TableAlterDropColumnOperation) for op in operations)

0 commit comments

Comments
 (0)