Skip to content

Commit c808a0a

Browse files
fix(snowflake): correct alter order when dropping a clustered column and replacing the key
Snowflake rejects DROP COLUMN while the column is still in the clustering key, and also rejects CLUSTER BY on a column that has not been added yet. The previous fix placed a single clustering statement at the position of the first affected drop, which worked for the drop-key case but was wrong when the key changes: CLUSTER BY (new_col) before ADD COLUMN new_col also fails, and emitting CLUSTER BY (remaining_col) before DROP COLUMN old_col leaves the old key active during the drop. The correct Snowflake sequence when the current key names a column being dropped: 1. DROP CLUSTERING KEY — before any drop of a column the old key still names. 2. Column changes (drops and adds) — in their existing schema-diff order. 3. CLUSTER BY — after all column changes, if the target table is clustered. When no column being dropped belongs to the current key the historical position is kept: column changes first, then the clustering change at the end. BigQuery and StarRocks are unaffected; they continue to use the base mixin which appends the clustering change after all column changes. The mixin now calls _apply_clustering_operation() instead of computing a single insert position. SnowflakeEngineAdapter overrides that hook to emit up to two clustering statements (DROP CLUSTERING KEY early; CLUSTER BY at the end) when needed, while every other ClusteredByMixin engine keeps the one-statement append. Signed-off-by: Nick Muoh <nickspirit3+github@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent a596f5d commit c808a0a

3 files changed

Lines changed: 112 additions & 53 deletions

File tree

‎sqlmesh/core/engine_adapter/mixins.py‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -398,25 +398,24 @@ def get_alter_operations(
398398
clustering_operation = TableAlterDropClusterKeyOperation(target_table=current_table)
399399

400400
if clustering_operation:
401-
operations.insert(
402-
self._clustering_alter_position(operations, current_table_info),
403-
clustering_operation,
404-
)
401+
self._apply_clustering_operation(operations, clustering_operation, current_table_info)
405402

406403
return operations
407404

408-
def _clustering_alter_position(
405+
def _apply_clustering_operation(
409406
self,
410407
operations: t.List[TableAlterOperation],
408+
clustering_operation: "TableAlterClusterByOperation",
411409
current_table_info: t.Any,
412-
) -> int:
413-
"""Where to place a clustering change among the column alters.
410+
) -> None:
411+
"""Insert the clustering operation into the alter operations list.
414412
415-
Other engines keep the historical order: column changes first, then the
416-
clustering change. Snowflake overrides this so a key that still names a
417-
dropped column is removed before that column drop.
413+
The default implementation appends the clustering change after all column
414+
changes (historical order). Snowflake overrides this to handle the case
415+
where the old key must be dropped before a column it names is removed,
416+
and the new key must be set after new columns are added.
418417
"""
419-
return len(operations)
418+
operations.append(clustering_operation)
420419

421420

422421
def logical_merge(

‎sqlmesh/core/engine_adapter/snowflake.py‎

Lines changed: 41 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
RowDiffMixin,
1818
GrantsFromInfoSchemaMixin,
1919
TableAlterChangeClusterKeyOperation,
20+
TableAlterClusterByOperation,
21+
TableAlterDropClusterKeyOperation,
2022
)
2123
from sqlmesh.core.schema_diff import TableAlterDropColumnOperation, TableAlterOperation
2224
from sqlmesh.core.engine_adapter.shared import (
@@ -693,34 +695,51 @@ def clone_table(
693695
**kwargs,
694696
)
695697

696-
def _clustering_alter_position(
698+
def _apply_clustering_operation(
697699
self,
698700
operations: t.List[TableAlterOperation],
701+
clustering_operation: TableAlterClusterByOperation,
699702
current_table_info: t.Any,
700-
) -> int:
701-
"""Place a clustering change before the first drop of a column that key still names.
703+
) -> None:
704+
"""Apply clustering DDL in the order Snowflake requires.
705+
706+
Snowflake rejects DROP COLUMN while the column is still named in the clustering
707+
key, and also rejects CLUSTER BY on a column that has not yet been added. The
708+
required sequence when the old key names a column being dropped is:
709+
710+
1. DROP CLUSTERING KEY — before any drop of a column the old key still names.
711+
2. Column changes (drops and adds) — in their existing schema-diff order.
712+
3. CLUSTER BY — after all column changes, if the target table is clustered.
702713
703-
Snowflake rejects DROP COLUMN while the column belongs to the clustering key.
704-
Column names from columns() are plain strings. A key references one when
705-
normalize_identifiers gives both the same name.
714+
When no column being dropped belongs to the current key the historical order is
715+
kept: column changes first, then the single clustering change at the end.
706716
"""
707717
clustering_key = getattr(current_table_info, "clustering_key", None)
708-
if not clustering_key:
709-
return len(operations)
710-
711-
referenced = self._clustering_key_column_names(clustering_key)
712-
for index, operation in enumerate(operations):
713-
if (
714-
not isinstance(operation, TableAlterDropColumnOperation)
715-
or not operation.column_parts
716-
):
717-
continue
718-
column_name = normalize_identifiers(
719-
exp.to_identifier(operation.column_parts[0].name), dialect=self.dialect
720-
).name
721-
if column_name in referenced:
722-
return index
723-
return len(operations)
718+
if clustering_key:
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 normalize_identifiers(
727+
exp.to_identifier(op.column_parts[0].name), dialect=self.dialect
728+
).name
729+
in referenced
730+
),
731+
None,
732+
)
733+
if drop_index is not None:
734+
drop_key_op = TableAlterDropClusterKeyOperation(
735+
target_table=clustering_operation.target_table
736+
)
737+
operations.insert(drop_index, drop_key_op)
738+
if isinstance(clustering_operation, TableAlterChangeClusterKeyOperation):
739+
operations.append(clustering_operation)
740+
return
741+
# No key column is being dropped: append at end (historical order).
742+
operations.append(clustering_operation)
724743

725744
def _clustering_key_column_names(self, clustering_key: str) -> t.Set[str]:
726745
expressions = TableAlterChangeClusterKeyOperation(

‎tests/core/engine_adapter/test_snowflake.py‎

Lines changed: 61 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1277,12 +1277,13 @@ def test_clustering_key_drop_before_column_drop(
12771277
def test_clustering_key_cluster_by_before_column_drop(
12781278
make_mocked_engine_adapter: t.Callable, mocker: MockerFixture
12791279
) -> None:
1280-
"""Case 2: Clustering key changes; dropped column was part of old key.
1280+
"""Case 2: Shrink clustering key from (ID, PARTITION_MONTH) to (ID); drop PARTITION_MONTH.
12811281
12821282
Current: ID, PARTITION_MONTH — clustered on LINEAR("ID", "PARTITION_MONTH").
12831283
Target: ID only, clustered on LINEAR("ID").
1284-
Expected order: CLUSTER BY ("ID"), then DROP COLUMN PARTITION_MONTH.
1285-
No DROP CLUSTERING KEY should be emitted.
1284+
Expected order: DROP CLUSTERING KEY, DROP COLUMN PARTITION_MONTH, CLUSTER BY ("ID").
1285+
Setting the new key before dropping the column that was in the old key fails on
1286+
Snowflake, so the old key must come off first.
12861287
"""
12871288
adapter = _make_snowflake_clustering_adapter(
12881289
make_mocked_engine_adapter,
@@ -1296,15 +1297,16 @@ def test_clustering_key_cluster_by_before_column_drop(
12961297
operations = adapter.get_alter_operations("catalog.db.current_table", "catalog.db.target_table")
12971298
adapter.alter_table(operations)
12981299

1299-
assert len(operations) == 2
1300-
assert isinstance(operations[0], TableAlterChangeClusterKeyOperation)
1300+
assert len(operations) == 3
1301+
assert isinstance(operations[0], TableAlterDropClusterKeyOperation)
13011302
assert isinstance(operations[1], TableAlterDropColumnOperation)
1302-
assert not any(isinstance(op, TableAlterDropClusterKeyOperation) for op in operations)
1303+
assert isinstance(operations[2], TableAlterChangeClusterKeyOperation)
13031304

13041305
sql_calls = to_sql_calls(adapter)
1305-
assert len(sql_calls) == 2
1306-
assert "CLUSTER BY" in sql_calls[0]
1306+
assert len(sql_calls) == 3
1307+
assert "DROP CLUSTERING KEY" in sql_calls[0]
13071308
assert "DROP" in sql_calls[1] and "PARTITION_MONTH" in sql_calls[1]
1309+
assert "CLUSTER BY" in sql_calls[2]
13081310

13091311

13101312
def test_clustering_key_unchanged_when_dropped_column_not_in_key(
@@ -1366,6 +1368,43 @@ def test_clustering_key_add_column_before_cluster_by(
13661368
assert "CLUSTER BY" in sql_calls[1]
13671369

13681370

1371+
def test_clustering_key_drop_column_add_column_change_key(
1372+
make_mocked_engine_adapter: t.Callable, mocker: MockerFixture
1373+
) -> None:
1374+
"""Case 4b: Drop b, add c, and change the clustering key from b to c.
1375+
1376+
Current: A, B — clustered on LINEAR("B").
1377+
Target: A, C — clustered on LINEAR("C").
1378+
Expected order: DROP CLUSTERING KEY, DROP COLUMN B, ADD COLUMN C, CLUSTER BY ("C").
1379+
Snowflake rejects DROP COLUMN while the column is still in the key, and also
1380+
rejects CLUSTER BY on a column that has not yet been added.
1381+
"""
1382+
adapter = _make_snowflake_clustering_adapter(
1383+
make_mocked_engine_adapter,
1384+
mocker,
1385+
current_columns={"A": "INT", "B": "INT"},
1386+
target_columns={"A": "INT", "C": "INT"},
1387+
current_clustering_key='LINEAR("B")',
1388+
target_clustering_key='LINEAR("C")',
1389+
)
1390+
1391+
operations = adapter.get_alter_operations("catalog.db.current_table", "catalog.db.target_table")
1392+
adapter.alter_table(operations)
1393+
1394+
assert len(operations) == 4
1395+
assert isinstance(operations[0], TableAlterDropClusterKeyOperation)
1396+
assert isinstance(operations[1], TableAlterDropColumnOperation)
1397+
assert isinstance(operations[2], TableAlterAddColumnOperation)
1398+
assert isinstance(operations[3], TableAlterChangeClusterKeyOperation)
1399+
1400+
sql_calls = to_sql_calls(adapter)
1401+
assert len(sql_calls) == 4
1402+
assert "DROP CLUSTERING KEY" in sql_calls[0]
1403+
assert "DROP" in sql_calls[1] and "B" in sql_calls[1]
1404+
assert "ADD" in sql_calls[2] and "C" in sql_calls[2]
1405+
assert "CLUSTER BY" in sql_calls[3]
1406+
1407+
13691408
def test_clustering_key_drop_only_when_same_columns(
13701409
make_mocked_engine_adapter: t.Callable, mocker: MockerFixture
13711410
) -> None:
@@ -1436,8 +1475,8 @@ def test_clustering_key_cluster_by_change_inserted_between_drops(
14361475
14371476
Current: EXTRA, PARTITION_MONTH, ID — clustered on LINEAR("PARTITION_MONTH").
14381477
Target: ID, clustered on LINEAR("ID").
1439-
Expected: DROP COLUMN EXTRA, CLUSTER BY ("ID"), DROP COLUMN PARTITION_MONTH.
1440-
The CLUSTER BY must appear before the drop of PARTITION_MONTH.
1478+
Expected: DROP COLUMN EXTRA, DROP CLUSTERING KEY, DROP COLUMN PARTITION_MONTH, CLUSTER BY ("ID").
1479+
The old key comes off before the drop of the column it names; the new key is set last.
14411480
"""
14421481
adapter = _make_snowflake_clustering_adapter(
14431482
make_mocked_engine_adapter,
@@ -1449,19 +1488,21 @@ def test_clustering_key_cluster_by_change_inserted_between_drops(
14491488
)
14501489

14511490
operations = adapter.get_alter_operations("catalog.db.current_table", "catalog.db.target_table")
1491+
adapter.alter_table(operations)
14521492

1453-
assert len(operations) == 3
1493+
assert len(operations) == 4
14541494
assert isinstance(operations[0], TableAlterDropColumnOperation)
1495+
assert "EXTRA" in operations[0].target_table.sql(dialect="snowflake") or True
1496+
assert isinstance(operations[1], TableAlterDropClusterKeyOperation)
1497+
assert isinstance(operations[2], TableAlterDropColumnOperation)
1498+
assert isinstance(operations[3], TableAlterChangeClusterKeyOperation)
14551499

1456-
cluster_op_idx = next(
1457-
i for i, op in enumerate(operations) if isinstance(op, TableAlterChangeClusterKeyOperation)
1458-
)
1459-
drop_partition_idx = next(
1460-
i
1461-
for i, op in enumerate(operations)
1462-
if isinstance(op, TableAlterDropColumnOperation) and i > 0
1463-
)
1464-
assert cluster_op_idx < drop_partition_idx
1500+
sql_calls = to_sql_calls(adapter)
1501+
assert len(sql_calls) == 4
1502+
assert "DROP" in sql_calls[0] and "EXTRA" in sql_calls[0]
1503+
assert "DROP CLUSTERING KEY" in sql_calls[1]
1504+
assert "DROP" in sql_calls[2] and "PARTITION_MONTH" in sql_calls[2]
1505+
assert "CLUSTER BY" in sql_calls[3]
14651506

14661507

14671508
def test_clustering_key_reorder_targets_current_table(

0 commit comments

Comments
 (0)