sqlmesh 0.209.1.dev65__py3-none-any.whl → 0.209.1.dev66__py3-none-any.whl

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.
sqlmesh/_version.py CHANGED
@@ -28,7 +28,7 @@ version_tuple: VERSION_TUPLE
28
28
  commit_id: COMMIT_ID
29
29
  __commit_id__: COMMIT_ID
30
30
 
31
- __version__ = version = '0.209.1.dev65'
32
- __version_tuple__ = version_tuple = (0, 209, 1, 'dev65')
31
+ __version__ = version = '0.209.1.dev66'
32
+ __version_tuple__ = version_tuple = (0, 209, 1, 'dev66')
33
33
 
34
34
  __commit_id__ = commit_id = None
sqlmesh/cli/main.py CHANGED
@@ -450,6 +450,12 @@ def diff(ctx: click.Context, environment: t.Optional[str] = None) -> None:
450
450
  multiple=True,
451
451
  help="Allow destructive forward-only changes to models whose names match the expression.",
452
452
  )
453
+ @click.option(
454
+ "--allow-additive-model",
455
+ type=str,
456
+ multiple=True,
457
+ help="Allow additive forward-only changes to models whose names match the expression.",
458
+ )
453
459
  @click.option(
454
460
  "--effective-from",
455
461
  type=str,
@@ -548,6 +554,7 @@ def plan(
548
554
  restate_models = kwargs.pop("restate_model") or None
549
555
  select_models = kwargs.pop("select_model") or None
550
556
  allow_destructive_models = kwargs.pop("allow_destructive_model") or None
557
+ allow_additive_models = kwargs.pop("allow_additive_model") or None
551
558
  backfill_models = kwargs.pop("backfill_model") or None
552
559
  ignore_cron = kwargs.pop("ignore_cron") or None
553
560
  setattr(get_console(), "verbosity", Verbosity(verbose))
@@ -557,6 +564,7 @@ def plan(
557
564
  restate_models=restate_models,
558
565
  select_models=select_models,
559
566
  allow_destructive_models=allow_destructive_models,
567
+ allow_additive_models=allow_additive_models,
560
568
  backfill_models=backfill_models,
561
569
  ignore_cron=ignore_cron,
562
570
  **kwargs,
@@ -10,6 +10,8 @@ from sqlmesh.core.model.kind import (
10
10
  OnDestructiveChange,
11
11
  model_kind_validator,
12
12
  on_destructive_change_validator,
13
+ on_additive_change_validator,
14
+ OnAdditiveChange,
13
15
  )
14
16
  from sqlmesh.core.model.meta import FunctionCall
15
17
  from sqlmesh.core.node import IntervalUnit
@@ -34,6 +36,7 @@ class ModelDefaultsConfig(BaseConfig):
34
36
  storage_format: The storage format used to store the physical table, only applicable in certain engines.
35
37
  (eg. 'parquet', 'orc')
36
38
  on_destructive_change: What should happen when a forward-only model requires a destructive schema change.
39
+ on_additive_change: What should happen when a forward-only model requires an additive schema change.
37
40
  physical_properties: A key-value mapping of arbitrary properties that are applied to the model table / view in the physical layer.
38
41
  virtual_properties: A key-value mapping of arbitrary properties that are applied to the model view in the virtual layer.
39
42
  session_properties: A key-value mapping of properties specific to the target engine that are applied to the engine session.
@@ -56,6 +59,7 @@ class ModelDefaultsConfig(BaseConfig):
56
59
  table_format: t.Optional[str] = None
57
60
  storage_format: t.Optional[str] = None
58
61
  on_destructive_change: t.Optional[OnDestructiveChange] = None
62
+ on_additive_change: t.Optional[OnAdditiveChange] = None
59
63
  physical_properties: t.Optional[t.Dict[str, t.Any]] = None
60
64
  virtual_properties: t.Optional[t.Dict[str, t.Any]] = None
61
65
  session_properties: t.Optional[t.Dict[str, t.Any]] = None
@@ -71,6 +75,7 @@ class ModelDefaultsConfig(BaseConfig):
71
75
 
72
76
  _model_kind_validator = model_kind_validator
73
77
  _on_destructive_change_validator = on_destructive_change_validator
78
+ _on_additive_change_validator = on_additive_change_validator
74
79
 
75
80
  @field_validator("audits", mode="before")
76
81
  def _audits_validator(cls, v: t.Any) -> t.Any:
sqlmesh/core/console.py CHANGED
@@ -27,6 +27,7 @@ from rich.table import Table
27
27
  from rich.tree import Tree
28
28
  from sqlglot import exp
29
29
 
30
+ from sqlmesh.core.schema_diff import TableAlterOperation
30
31
  from sqlmesh.core.test.result import ModelTextTestResult
31
32
  from sqlmesh.core.environment import EnvironmentNamingInfo, EnvironmentSummary
32
33
  from sqlmesh.core.linter.rule import RuleViolation
@@ -47,6 +48,7 @@ from sqlmesh.utils.errors import (
47
48
  PythonModelEvalError,
48
49
  NodeAuditsErrors,
49
50
  format_destructive_change_msg,
51
+ format_additive_change_msg,
50
52
  )
51
53
  from sqlmesh.utils.rich import strip_ansi_codes
52
54
 
@@ -327,13 +329,22 @@ class PlanBuilderConsole(BaseConsole, abc.ABC):
327
329
  def log_destructive_change(
328
330
  self,
329
331
  snapshot_name: str,
330
- dropped_column_names: t.List[str],
331
- alter_expressions: t.List[exp.Alter],
332
+ alter_operations: t.List[TableAlterOperation],
332
333
  dialect: str,
333
334
  error: bool = True,
334
335
  ) -> None:
335
336
  """Display a destructive change error or warning to the user."""
336
337
 
338
+ @abc.abstractmethod
339
+ def log_additive_change(
340
+ self,
341
+ snapshot_name: str,
342
+ alter_operations: t.List[TableAlterOperation],
343
+ dialect: str,
344
+ error: bool = True,
345
+ ) -> None:
346
+ """Display an additive change error or warning to the user."""
347
+
337
348
 
338
349
  class UnitTestConsole(abc.ABC):
339
350
  @abc.abstractmethod
@@ -759,8 +770,16 @@ class NoopConsole(Console):
759
770
  def log_destructive_change(
760
771
  self,
761
772
  snapshot_name: str,
762
- dropped_column_names: t.List[str],
763
- alter_expressions: t.List[exp.Alter],
773
+ alter_operations: t.List[TableAlterOperation],
774
+ dialect: str,
775
+ error: bool = True,
776
+ ) -> None:
777
+ pass
778
+
779
+ def log_additive_change(
780
+ self,
781
+ snapshot_name: str,
782
+ alter_operations: t.List[TableAlterOperation],
764
783
  dialect: str,
765
784
  error: bool = True,
766
785
  ) -> None:
@@ -2202,22 +2221,29 @@ class TerminalConsole(Console):
2202
2221
  def log_destructive_change(
2203
2222
  self,
2204
2223
  snapshot_name: str,
2205
- dropped_column_names: t.List[str],
2206
- alter_expressions: t.List[exp.Alter],
2224
+ alter_operations: t.List[TableAlterOperation],
2207
2225
  dialect: str,
2208
2226
  error: bool = True,
2209
2227
  ) -> None:
2210
2228
  if error:
2211
- self._print(
2212
- format_destructive_change_msg(
2213
- snapshot_name, dropped_column_names, alter_expressions, dialect
2214
- )
2229
+ self._print(format_destructive_change_msg(snapshot_name, alter_operations, dialect))
2230
+ else:
2231
+ self.log_warning(
2232
+ format_destructive_change_msg(snapshot_name, alter_operations, dialect, error)
2215
2233
  )
2234
+
2235
+ def log_additive_change(
2236
+ self,
2237
+ snapshot_name: str,
2238
+ alter_operations: t.List[TableAlterOperation],
2239
+ dialect: str,
2240
+ error: bool = True,
2241
+ ) -> None:
2242
+ if error:
2243
+ self._print(format_additive_change_msg(snapshot_name, alter_operations, dialect))
2216
2244
  else:
2217
2245
  self.log_warning(
2218
- format_destructive_change_msg(
2219
- snapshot_name, dropped_column_names, alter_expressions, dialect, error
2220
- )
2246
+ format_additive_change_msg(snapshot_name, alter_operations, dialect, error)
2221
2247
  )
2222
2248
 
2223
2249
  def log_error(self, message: str) -> None:
sqlmesh/core/context.py CHANGED
@@ -1279,6 +1279,7 @@ class GenericContext(BaseContext, t.Generic[C]):
1279
1279
  empty_backfill: t.Optional[bool] = None,
1280
1280
  forward_only: t.Optional[bool] = None,
1281
1281
  allow_destructive_models: t.Optional[t.Collection[str]] = None,
1282
+ allow_additive_models: t.Optional[t.Collection[str]] = None,
1282
1283
  no_prompts: t.Optional[bool] = None,
1283
1284
  auto_apply: t.Optional[bool] = None,
1284
1285
  no_auto_categorization: t.Optional[bool] = None,
@@ -1322,6 +1323,7 @@ class GenericContext(BaseContext, t.Generic[C]):
1322
1323
  empty_backfill: Like skip_backfill, but also records processed intervals.
1323
1324
  forward_only: Whether the purpose of the plan is to make forward only changes.
1324
1325
  allow_destructive_models: Models whose forward-only changes are allowed to be destructive.
1326
+ allow_additive_models: Models whose forward-only changes are allowed to be additive.
1325
1327
  no_prompts: Whether to disable interactive prompts for the backfill time range. Please note that
1326
1328
  if this flag is set to true and there are uncategorized changes the plan creation will
1327
1329
  fail. Default: False.
@@ -1360,6 +1362,7 @@ class GenericContext(BaseContext, t.Generic[C]):
1360
1362
  empty_backfill=empty_backfill,
1361
1363
  forward_only=forward_only,
1362
1364
  allow_destructive_models=allow_destructive_models,
1365
+ allow_additive_models=allow_additive_models,
1363
1366
  no_auto_categorization=no_auto_categorization,
1364
1367
  effective_from=effective_from,
1365
1368
  include_unmodified=include_unmodified,
@@ -1411,6 +1414,7 @@ class GenericContext(BaseContext, t.Generic[C]):
1411
1414
  empty_backfill: t.Optional[bool] = None,
1412
1415
  forward_only: t.Optional[bool] = None,
1413
1416
  allow_destructive_models: t.Optional[t.Collection[str]] = None,
1417
+ allow_additive_models: t.Optional[t.Collection[str]] = None,
1414
1418
  no_auto_categorization: t.Optional[bool] = None,
1415
1419
  effective_from: t.Optional[TimeLike] = None,
1416
1420
  include_unmodified: t.Optional[bool] = None,
@@ -1480,6 +1484,9 @@ class GenericContext(BaseContext, t.Generic[C]):
1480
1484
  "allow_destructive_models": list(allow_destructive_models)
1481
1485
  if allow_destructive_models is not None
1482
1486
  else None,
1487
+ "allow_additive_models": list(allow_additive_models)
1488
+ if allow_additive_models is not None
1489
+ else None,
1483
1490
  "no_auto_categorization": no_auto_categorization,
1484
1491
  "effective_from": effective_from,
1485
1492
  "include_unmodified": include_unmodified,
@@ -1533,6 +1540,11 @@ class GenericContext(BaseContext, t.Generic[C]):
1533
1540
  else:
1534
1541
  expanded_destructive_models = None
1535
1542
 
1543
+ if allow_additive_models:
1544
+ expanded_additive_models = model_selector.expand_model_selections(allow_additive_models)
1545
+ else:
1546
+ expanded_additive_models = None
1547
+
1536
1548
  if backfill_models:
1537
1549
  backfill_models = model_selector.expand_model_selections(backfill_models)
1538
1550
  else:
@@ -1642,6 +1654,7 @@ class GenericContext(BaseContext, t.Generic[C]):
1642
1654
  is_dev=is_dev,
1643
1655
  forward_only=forward_only,
1644
1656
  allow_destructive_models=expanded_destructive_models,
1657
+ allow_additive_models=expanded_additive_models,
1645
1658
  environment_ttl=environment_ttl,
1646
1659
  environment_suffix_target=self.config.environment_suffix_target,
1647
1660
  environment_catalog_mapping=self.environment_catalog_mapping,
@@ -39,7 +39,7 @@ from sqlmesh.core.engine_adapter.shared import (
39
39
  set_catalog,
40
40
  )
41
41
  from sqlmesh.core.model.kind import TimeColumn
42
- from sqlmesh.core.schema_diff import SchemaDiffer
42
+ from sqlmesh.core.schema_diff import SchemaDiffer, TableAlterOperation
43
43
  from sqlmesh.utils import (
44
44
  CorrelationId,
45
45
  columns_to_types_all_known,
@@ -376,6 +376,13 @@ class EngineAdapter:
376
376
  target_columns_to_types = columns_to_types_from_df(t.cast(pd.DataFrame, query_or_df))
377
377
  if not source_columns and target_columns_to_types:
378
378
  source_columns = list(target_columns_to_types)
379
+ # source columns should only contain columns that are defined in the target. If there are extras then
380
+ # that means they are intended to be ignored and will be excluded
381
+ source_columns = (
382
+ [x for x in source_columns if x in target_columns_to_types]
383
+ if source_columns and target_columns_to_types
384
+ else None
385
+ )
379
386
  return target_columns_to_types, source_columns
380
387
 
381
388
  def recycle(self) -> None:
@@ -1074,32 +1081,39 @@ class EngineAdapter:
1074
1081
  """
1075
1082
  self.execute(exp.Drop(this=exp.to_table(name), kind=kind, exists=exists, **drop_args))
1076
1083
 
1077
- def get_alter_expressions(
1084
+ def get_alter_operations(
1078
1085
  self,
1079
1086
  current_table_name: TableName,
1080
1087
  target_table_name: TableName,
1081
1088
  *,
1082
1089
  ignore_destructive: bool = False,
1083
- ) -> t.List[exp.Alter]:
1090
+ ignore_additive: bool = False,
1091
+ ) -> t.List[TableAlterOperation]:
1084
1092
  """
1085
1093
  Determines the alter statements needed to change the current table into the structure of the target table.
1086
1094
  """
1087
- return self.SCHEMA_DIFFER.compare_columns(
1088
- current_table_name,
1089
- self.columns(current_table_name),
1090
- self.columns(target_table_name),
1091
- ignore_destructive=ignore_destructive,
1095
+ return t.cast(
1096
+ t.List[TableAlterOperation],
1097
+ self.SCHEMA_DIFFER.compare_columns(
1098
+ current_table_name,
1099
+ self.columns(current_table_name),
1100
+ self.columns(target_table_name),
1101
+ ignore_destructive=ignore_destructive,
1102
+ ignore_additive=ignore_additive,
1103
+ ),
1092
1104
  )
1093
1105
 
1094
1106
  def alter_table(
1095
1107
  self,
1096
- alter_expressions: t.List[exp.Alter],
1108
+ alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]],
1097
1109
  ) -> None:
1098
1110
  """
1099
1111
  Performs the alter statements to change the current table into the structure of the target table.
1100
1112
  """
1101
1113
  with self.transaction():
1102
- for alter_expression in alter_expressions:
1114
+ for alter_expression in [
1115
+ x.expression if isinstance(x, TableAlterOperation) else x for x in alter_expressions
1116
+ ]:
1103
1117
  self.execute(alter_expression)
1104
1118
 
1105
1119
  def create_view(
@@ -12,6 +12,7 @@ from sqlmesh.core.engine_adapter.mixins import (
12
12
  InsertOverwriteWithMergeMixin,
13
13
  ClusteredByMixin,
14
14
  RowDiffMixin,
15
+ TableAlterClusterByOperation,
15
16
  )
16
17
  from sqlmesh.core.engine_adapter.shared import (
17
18
  CatalogSupport,
@@ -21,7 +22,7 @@ from sqlmesh.core.engine_adapter.shared import (
21
22
  set_catalog,
22
23
  )
23
24
  from sqlmesh.core.node import IntervalUnit
24
- from sqlmesh.core.schema_diff import SchemaDiffer
25
+ from sqlmesh.core.schema_diff import SchemaDiffer, TableAlterOperation
25
26
  from sqlmesh.utils import optional_import, get_source_columns_to_types
26
27
  from sqlmesh.utils.date import to_datetime
27
28
  from sqlmesh.utils.errors import SQLMeshError
@@ -51,9 +52,6 @@ bigframes_pd = optional_import("bigframes.pandas")
51
52
  NestedField = t.Tuple[str, str, t.List[str]]
52
53
  NestedFieldsDict = t.Dict[str, t.List[NestedField]]
53
54
 
54
- # used to tag AST nodes to be specially handled in alter_table()
55
- _CLUSTERING_META_KEY = "__sqlmesh_update_table_clustering"
56
-
57
55
 
58
56
  @set_catalog()
59
57
  class BigQueryEngineAdapter(InsertOverwriteWithMergeMixin, ClusteredByMixin, RowDiffMixin):
@@ -394,29 +392,31 @@ class BigQueryEngineAdapter(InsertOverwriteWithMergeMixin, ClusteredByMixin, Row
394
392
 
395
393
  def alter_table(
396
394
  self,
397
- alter_expressions: t.List[exp.Alter],
395
+ alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]],
398
396
  ) -> None:
399
397
  """
400
398
  Performs the alter statements to change the current table into the structure of the target table,
401
399
  and uses the API to add columns to structs, where SQL is not supported.
402
400
  """
401
+ if not alter_expressions:
402
+ return
403
+
404
+ cluster_by_operations, alter_statements = [], []
405
+ for e in alter_expressions:
406
+ if isinstance(e, TableAlterClusterByOperation):
407
+ cluster_by_operations.append(e)
408
+ elif isinstance(e, TableAlterOperation):
409
+ alter_statements.append(e.expression)
410
+ else:
411
+ alter_statements.append(e)
403
412
 
404
- nested_fields, non_nested_expressions = self._split_alter_expressions(alter_expressions)
413
+ for op in cluster_by_operations:
414
+ self._update_clustering_key(op)
405
415
 
406
- if nested_fields:
407
- self._update_table_schema_nested_fields(nested_fields, alter_expressions[0].this)
416
+ nested_fields, non_nested_expressions = self._split_alter_expressions(alter_statements)
408
417
 
409
- # this is easier than trying to detect exp.Cluster nodes
410
- # or exp.Command nodes that contain the string "DROP CLUSTERING KEY"
411
- clustering_change_operations = [
412
- e for e in non_nested_expressions if _CLUSTERING_META_KEY in e.meta
413
- ]
414
- for op in clustering_change_operations:
415
- non_nested_expressions.remove(op)
416
- table, cluster_by = op.meta[_CLUSTERING_META_KEY]
417
- assert isinstance(table, str) or isinstance(table, exp.Table)
418
-
419
- self._update_clustering_key(table, cluster_by)
418
+ if nested_fields:
419
+ self._update_table_schema_nested_fields(nested_fields, alter_statements[0].this)
420
420
 
421
421
  if non_nested_expressions:
422
422
  super().alter_table(non_nested_expressions)
@@ -1179,35 +1179,27 @@ class BigQueryEngineAdapter(InsertOverwriteWithMergeMixin, ClusteredByMixin, Row
1179
1179
  for row in df.itertuples()
1180
1180
  ]
1181
1181
 
1182
- def _change_clustering_key_expr(
1183
- self, table: exp.Table, cluster_by: t.List[exp.Expression]
1184
- ) -> exp.Alter:
1185
- expr = super()._change_clustering_key_expr(table=table, cluster_by=cluster_by)
1186
- expr.meta[_CLUSTERING_META_KEY] = (table, cluster_by)
1187
- return expr
1182
+ def _update_clustering_key(self, operation: TableAlterClusterByOperation) -> None:
1183
+ cluster_key_expressions = getattr(operation, "cluster_key_expressions", [])
1184
+ bq_table = self._get_table(operation.target_table)
1188
1185
 
1189
- def _drop_clustering_key_expr(self, table: exp.Table) -> exp.Alter:
1190
- expr = super()._drop_clustering_key_expr(table=table)
1191
- expr.meta[_CLUSTERING_META_KEY] = (table, None)
1192
- return expr
1193
-
1194
- def _update_clustering_key(
1195
- self, table_name: TableName, cluster_by: t.Optional[t.List[exp.Expression]]
1196
- ) -> None:
1197
- cluster_by = cluster_by or []
1198
- bq_table = self._get_table(table_name)
1199
-
1200
- rendered_columns = [c.sql(dialect=self.dialect) for c in cluster_by]
1186
+ rendered_columns = [c.sql(dialect=self.dialect) for c in cluster_key_expressions]
1201
1187
  bq_table.clustering_fields = (
1202
1188
  rendered_columns or None
1203
1189
  ) # causes a drop of the key if cluster_by is empty or None
1204
1190
 
1205
1191
  self._db_call(self.client.update_table, table=bq_table, fields=["clustering_fields"])
1206
1192
 
1207
- if cluster_by:
1193
+ if cluster_key_expressions:
1208
1194
  # BigQuery only applies new clustering going forward, so this rewrites the columns to apply the new clustering to historical data
1209
1195
  # ref: https://cloud.google.com/bigquery/docs/creating-clustered-tables#modifying-cluster-spec
1210
- self.execute(exp.update(table_name, {c: c for c in cluster_by}, where=exp.true()))
1196
+ self.execute(
1197
+ exp.update(
1198
+ operation.target_table,
1199
+ {c: c for c in cluster_key_expressions},
1200
+ where=exp.true(),
1201
+ )
1202
+ )
1211
1203
 
1212
1204
  def _normalize_decimal_value(self, col: exp.Expression, precision: int) -> exp.Expression:
1213
1205
  return exp.func("FORMAT", exp.Literal.string(f"%.{precision}f"), col)
@@ -15,7 +15,7 @@ from sqlmesh.core.engine_adapter.shared import (
15
15
  CommentCreationView,
16
16
  InsertOverwriteStrategy,
17
17
  )
18
- from sqlmesh.core.schema_diff import SchemaDiffer
18
+ from sqlmesh.core.schema_diff import SchemaDiffer, TableAlterOperation
19
19
  from sqlmesh.utils import get_source_columns_to_types
20
20
 
21
21
  if t.TYPE_CHECKING:
@@ -596,13 +596,15 @@ class ClickhouseEngineAdapter(EngineAdapterWithIndexSupport, LogicalMergeMixin):
596
596
 
597
597
  def alter_table(
598
598
  self,
599
- alter_expressions: t.List[exp.Alter],
599
+ alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]],
600
600
  ) -> None:
601
601
  """
602
602
  Performs the alter statements to change the current table into the structure of the target table.
603
603
  """
604
604
  with self.transaction():
605
- for alter_expression in alter_expressions:
605
+ for alter_expression in [
606
+ x.expression if isinstance(x, TableAlterOperation) else x for x in alter_expressions
607
+ ]:
606
608
  if self.engine_run_mode.is_cluster:
607
609
  alter_expression.set(
608
610
  "cluster", exp.OnCluster(this=exp.to_identifier(self.cluster))
@@ -1,7 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import abc
3
4
  import logging
4
5
  import typing as t
6
+ from dataclasses import dataclass
5
7
 
6
8
  from sqlglot import exp, parse_one
7
9
  from sqlglot.helper import seq_get
@@ -10,6 +12,7 @@ from sqlmesh.core.engine_adapter.base import EngineAdapter
10
12
  from sqlmesh.core.engine_adapter.shared import InsertOverwriteStrategy, SourceQuery
11
13
  from sqlmesh.core.node import IntervalUnit
12
14
  from sqlmesh.core.dialect import schema_
15
+ from sqlmesh.core.schema_diff import TableAlterOperation
13
16
  from sqlmesh.utils.errors import SQLMeshError
14
17
 
15
18
  if t.TYPE_CHECKING:
@@ -340,35 +343,74 @@ class VarcharSizeWorkaroundMixin(EngineAdapter):
340
343
  return statement
341
344
 
342
345
 
343
- class ClusteredByMixin(EngineAdapter):
344
- def _build_clustered_by_exp(
345
- self,
346
- clustered_by: t.List[exp.Expression],
347
- **kwargs: t.Any,
348
- ) -> t.Optional[exp.Cluster]:
349
- return exp.Cluster(expressions=[c.copy() for c in clustered_by])
346
+ @dataclass(frozen=True)
347
+ class TableAlterClusterByOperation(TableAlterOperation, abc.ABC):
348
+ pass
349
+
350
350
 
351
- def _parse_clustering_key(self, clustering_key: t.Optional[str]) -> t.List[exp.Expression]:
352
- if not clustering_key:
353
- return []
351
+ @dataclass(frozen=True)
352
+ class TableAlterChangeClusterKeyOperation(TableAlterClusterByOperation):
353
+ clustering_key: str
354
+ dialect: str
354
355
 
356
+ @property
357
+ def is_additive(self) -> bool:
358
+ return False
359
+
360
+ @property
361
+ def is_destructive(self) -> bool:
362
+ return False
363
+
364
+ @property
365
+ def _alter_actions(self) -> t.List[exp.Expression]:
366
+ return [exp.Cluster(expressions=self.cluster_key_expressions)]
367
+
368
+ @property
369
+ def cluster_key_expressions(self) -> t.List[exp.Expression]:
355
370
  # Note: Assumes `clustering_key` as a string like:
356
371
  # - "(col_a)"
357
372
  # - "(col_a, col_b)"
358
373
  # - "func(col_a, transform(col_b))"
359
- parsed_cluster_key = parse_one(clustering_key, dialect=self.dialect)
360
-
374
+ parsed_cluster_key = parse_one(self.clustering_key, dialect=self.dialect)
361
375
  return parsed_cluster_key.expressions or [parsed_cluster_key.this]
362
376
 
363
- def get_alter_expressions(
377
+
378
+ @dataclass(frozen=True)
379
+ class TableAlterDropClusterKeyOperation(TableAlterClusterByOperation):
380
+ @property
381
+ def is_additive(self) -> bool:
382
+ return False
383
+
384
+ @property
385
+ def is_destructive(self) -> bool:
386
+ return False
387
+
388
+ @property
389
+ def _alter_actions(self) -> t.List[exp.Expression]:
390
+ return [exp.Command(this="DROP", expression="CLUSTERING KEY")]
391
+
392
+
393
+ class ClusteredByMixin(EngineAdapter):
394
+ def _build_clustered_by_exp(
395
+ self,
396
+ clustered_by: t.List[exp.Expression],
397
+ **kwargs: t.Any,
398
+ ) -> t.Optional[exp.Cluster]:
399
+ return exp.Cluster(expressions=[c.copy() for c in clustered_by])
400
+
401
+ def get_alter_operations(
364
402
  self,
365
403
  current_table_name: TableName,
366
404
  target_table_name: TableName,
367
405
  *,
368
406
  ignore_destructive: bool = False,
369
- ) -> t.List[exp.Alter]:
370
- expressions = super().get_alter_expressions(
371
- current_table_name, target_table_name, ignore_destructive=ignore_destructive
407
+ ignore_additive: bool = False,
408
+ ) -> t.List[TableAlterOperation]:
409
+ operations = super().get_alter_operations(
410
+ current_table_name,
411
+ target_table_name,
412
+ ignore_destructive=ignore_destructive,
413
+ ignore_additive=ignore_additive,
372
414
  )
373
415
 
374
416
  # check for a change in clustering
@@ -390,32 +432,17 @@ class ClusteredByMixin(EngineAdapter):
390
432
  if target_table_info.clustering_key and (
391
433
  current_table_info.clustering_key != target_table_info.clustering_key
392
434
  ):
393
- expressions.append(
394
- self._change_clustering_key_expr(
395
- current_table,
396
- self._parse_clustering_key(target_table_info.clustering_key),
435
+ operations.append(
436
+ TableAlterChangeClusterKeyOperation(
437
+ target_table=current_table,
438
+ clustering_key=target_table_info.clustering_key,
439
+ dialect=self.dialect,
397
440
  )
398
441
  )
399
442
  elif current_table_info.is_clustered:
400
- expressions.append(self._drop_clustering_key_expr(current_table))
401
-
402
- return expressions
443
+ operations.append(TableAlterDropClusterKeyOperation(target_table=current_table))
403
444
 
404
- def _change_clustering_key_expr(
405
- self, table: exp.Table, cluster_by: t.List[exp.Expression]
406
- ) -> exp.Alter:
407
- return exp.Alter(
408
- this=table,
409
- kind="TABLE",
410
- actions=[exp.Cluster(expressions=cluster_by)],
411
- )
412
-
413
- def _drop_clustering_key_expr(self, table: exp.Table) -> exp.Alter:
414
- return exp.Alter(
415
- this=table,
416
- kind="TABLE",
417
- actions=[exp.Command(this="DROP", expression="CLUSTERING KEY")],
418
- )
445
+ return operations
419
446
 
420
447
 
421
448
  def logical_merge(