sqlmesh.core.engine_adapter.clickhouse
1from __future__ import annotations 2 3import typing as t 4import logging 5import re 6from sqlglot import exp, maybe_parse 7from sqlmesh.core.dialect import to_schema 8from sqlmesh.core.engine_adapter.mixins import LogicalMergeMixin 9from sqlmesh.core.engine_adapter.base import EngineAdapterWithIndexSupport 10from sqlmesh.core.engine_adapter.shared import ( 11 CatalogSupport, 12 DataObject, 13 DataObjectType, 14 EngineRunMode, 15 SourceQuery, 16 CommentCreationView, 17 InsertOverwriteStrategy, 18) 19from sqlmesh.core.schema_diff import TableAlterOperation 20from sqlmesh.utils import get_source_columns_to_types 21 22if t.TYPE_CHECKING: 23 import pandas as pd 24 25 from sqlmesh.core._typing import SchemaName, TableName 26 from sqlmesh.core.engine_adapter._typing import DF, Query, QueryOrDF 27 28 from sqlmesh.core.node import IntervalUnit 29 30 31logger = logging.getLogger(__name__) 32 33 34class ClickhouseEngineAdapter(EngineAdapterWithIndexSupport, LogicalMergeMixin): 35 DIALECT = "clickhouse" 36 SUPPORTS_TRANSACTIONS = False 37 SUPPORTS_VIEW_SCHEMA = False 38 SUPPORTS_REPLACE_TABLE = False 39 COMMENT_CREATION_VIEW = CommentCreationView.COMMENT_COMMAND_ONLY 40 41 SCHEMA_DIFFER_KWARGS = {} 42 43 DEFAULT_TABLE_ENGINE = "MergeTree" 44 ORDER_BY_TABLE_ENGINE_REGEX = "^.*?MergeTree.*$" 45 46 @property 47 def catalog_support(self) -> CatalogSupport: 48 # This property is intentionally dynamic: it transitions from UNSUPPORTED to 49 # SINGLE_CATALOG_ONLY after inject_virtual_catalog() sets _default_catalog. Callers must 50 # not cache the result — always read it live so they see the post-injection state. 51 if self._default_catalog: 52 return CatalogSupport.SINGLE_CATALOG_ONLY 53 return CatalogSupport.UNSUPPORTED 54 55 def supports_virtual_catalog(self) -> bool: 56 return True 57 58 def inject_virtual_catalog(self, gateway: str) -> None: 59 configured = self._extra_config.get("virtual_catalog") 60 self._default_catalog = f"__{gateway}__" if configured is None else configured 61 62 @property 63 def engine_run_mode(self) -> EngineRunMode: 64 if self._extra_config.get("cloud_mode"): 65 return EngineRunMode.CLOUD 66 # we use the user's specification of a cluster in the connection config to determine if 67 # the engine is in cluster mode 68 if self._extra_config.get("cluster"): 69 return EngineRunMode.CLUSTER 70 return EngineRunMode.STANDALONE 71 72 @property 73 def cluster(self) -> t.Optional[str]: 74 return self._extra_config.get("cluster") 75 76 # Workaround for clickhouse-connect cursor bug 77 # - cursor does not reset row index correctly on `close()`, so `fetchone()` and `fetchmany()` 78 # return the wrong (or no) rows after the very first cursor query that returns rows 79 # in the connection 80 # - cursor does reset the data rows correctly on `close()`, so `fetchall()` works because it 81 # doesn't use the row index at all 82 def fetchone( 83 self, 84 query: t.Union[exp.Expr, str], 85 ignore_unsupported_errors: bool = False, 86 quote_identifiers: bool = False, 87 ) -> t.Tuple: 88 with self.transaction(): 89 self.execute( 90 query, 91 ignore_unsupported_errors=ignore_unsupported_errors, 92 quote_identifiers=quote_identifiers, 93 ) 94 return self.cursor.fetchall()[0] 95 96 def _fetch_native_df( 97 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 98 ) -> pd.DataFrame: 99 """Fetches a Pandas DataFrame from the cursor""" 100 return self.cursor.client.query_df( 101 self._to_sql(query, quote=quote_identifiers) if isinstance(query, exp.Expr) else query, 102 use_extended_dtypes=True, 103 ) 104 105 def _df_to_source_queries( 106 self, 107 df: DF, 108 target_columns_to_types: t.Dict[str, exp.DataType], 109 batch_size: int, 110 target_table: TableName, 111 source_columns: t.Optional[t.List[str]] = None, 112 **kwargs: t.Any, 113 ) -> t.List[SourceQuery]: 114 temp_table = self._get_temp_table(target_table, **kwargs) 115 source_columns_to_types = get_source_columns_to_types( 116 target_columns_to_types, source_columns 117 ) 118 119 def query_factory() -> Query: 120 # It is possible for the factory to be called multiple times and if so then the temp table will already 121 # be created so we skip creating again. This means we are assuming the first call is the same result 122 # as later calls. 123 if not self.table_exists(temp_table): 124 self.create_table( 125 temp_table, 126 source_columns_to_types, 127 storage_format=exp.var("MergeTree"), 128 **kwargs, 129 ) 130 ordered_df = df[list(source_columns_to_types)] 131 132 self.cursor.client.insert_df(temp_table.sql(dialect=self.dialect), df=ordered_df) 133 134 return exp.select(*self._casted_columns(target_columns_to_types, source_columns)).from_( 135 temp_table 136 ) 137 138 return [ 139 SourceQuery( 140 query_factory=query_factory, 141 cleanup_func=lambda: self.drop_table(temp_table, **kwargs), 142 ) 143 ] 144 145 def _get_data_objects( 146 self, schema_name: SchemaName, object_names: t.Optional[t.Set[str]] = None 147 ) -> t.List[DataObject]: 148 """ 149 Returns all the data objects that exist in the given database. 150 """ 151 query = ( 152 exp.select( 153 exp.column("database").as_("schema_name"), 154 exp.column("name"), 155 exp.case(exp.column("engine")) 156 .when( 157 exp.Literal.string("View"), 158 exp.Literal.string("view"), 159 ) 160 .else_( 161 exp.Literal.string("table"), 162 ) 163 .as_("type"), 164 ) 165 .from_("system.tables") 166 .where(exp.column("database").eq(to_schema(schema_name).db)) 167 ) 168 if object_names: 169 query = query.where(exp.column("name").isin(*object_names)) 170 df = self.fetchdf(query) 171 return [ 172 DataObject( 173 catalog=None, 174 schema=row.schema_name, 175 name=row.name, 176 type=DataObjectType.from_str(row.type), # type: ignore 177 ) 178 for row in df.itertuples() 179 ] 180 181 def create_schema( 182 self, 183 schema_name: SchemaName, 184 ignore_if_exists: bool = True, 185 warn_on_error: bool = True, 186 properties: t.List[exp.Expr] = [], 187 ) -> None: 188 """Create a Clickhouse database from a name or qualified table name. 189 190 Clickhouse has a two-level naming scheme [database].[table]. 191 """ 192 from sqlmesh.utils.errors import SQLMeshError 193 194 properties_copy = properties.copy() 195 if self.engine_run_mode.is_cluster: 196 properties_copy.append(exp.OnCluster(this=exp.to_identifier(self.cluster))) 197 198 # ClickHouse does not support catalogs. When a virtual catalog has been injected 199 # (self._default_catalog is set), strip it from the schema name. This mirrors the 200 # SINGLE_CATALOG_ONLY branch in the set_catalog decorator, which does not apply here 201 # because this override is not wrapped by @set_catalog(). 202 if self._default_catalog: 203 schema_exp = to_schema(schema_name) 204 catalog_name = schema_exp.catalog 205 if catalog_name: 206 if catalog_name != self._default_catalog: 207 raise SQLMeshError( 208 f"clickhouse requires that all catalog operations be against a single catalog: " 209 f"{self._default_catalog}. Provided catalog: {catalog_name}" 210 ) 211 schema_name = self._strip_virtual_catalog(schema_exp) 212 213 # can't call super() because it will try to set a catalog 214 return self._create_schema( 215 schema_name=schema_name, 216 ignore_if_exists=ignore_if_exists, 217 warn_on_error=warn_on_error, 218 properties=properties_copy, 219 # sqlglot transpiles CREATE SCHEMA to CREATE DATABASE, but this text is used in an error message 220 kind="DATABASE", 221 ) 222 223 def _insert_overwrite_by_condition( 224 self, 225 table_name: TableName, 226 source_queries: t.List[SourceQuery], 227 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 228 where: t.Optional[exp.Condition] = None, 229 insert_overwrite_strategy_override: t.Optional[InsertOverwriteStrategy] = None, 230 **kwargs: t.Any, 231 ) -> None: 232 """ 233 Implements the table or partition swap approach to insert-overwriting records. 234 235 Because this method executes multiple variants (full table replace, replace by time 236 range, replace by key, replace by partition), some upstream caller info is needed and 237 passed via kwargs. 238 239 Args: 240 table_name: Name of target table 241 source_queries: Source queries returning records to insert 242 target_columns_to_types: Column names and data types of target table 243 where: SQLGlot expression determining which target table rows should be overwritten 244 insert_overwrite_strategy_override: Not used by Clickhouse 245 kwargs: 246 dynamic_key: Key columns (replace by key only) 247 dynamic_key_exp: Expression to build key (replace by key only) 248 dynamic_key_unique: Whether more than one record can exist per key value (replace by key only) 249 250 keep_existing_partition_rows: Whether to overwrite partitions with only new records (incremental by partition only) 251 252 Returns: 253 Side effects only: execution of insert-overwrite operation. 254 """ 255 target_table = exp.to_table(table_name) 256 target_columns_to_types = target_columns_to_types or self.columns(target_table) 257 258 temp_table = self._get_temp_table(target_table) 259 self.create_table_like(temp_table, target_table) 260 261 # REPLACE BY KEY: extract kwargs if present 262 dynamic_key = kwargs.get("dynamic_key") 263 if dynamic_key: 264 dynamic_key_exp = t.cast(exp.Expr, kwargs.get("dynamic_key_exp")) 265 dynamic_key_unique = t.cast(bool, kwargs.get("dynamic_key_unique")) 266 267 try: 268 # insert new records into temp table 269 for source_query in source_queries: 270 with source_query as query: 271 # REPLACE BY KEY: if unique key, DISTINCTify by key columns so only one row is present per key 272 if dynamic_key and dynamic_key_unique: 273 query = query.distinct(*dynamic_key) # type: ignore 274 275 query = self._order_projections_and_filter( 276 query, target_columns_to_types, where=where 277 ) 278 self._insert_append_query( 279 temp_table, 280 query, 281 target_columns_to_types=target_columns_to_types, 282 order_projections=False, 283 ) 284 285 # REPLACE BY KEY: build `where` expression as "key IN (new rows' key values)" 286 if dynamic_key: 287 key_query = exp.select(dynamic_key_exp).from_(temp_table) 288 if not dynamic_key_unique: 289 key_query = key_query.distinct() 290 where = dynamic_key_exp.isin(query=key_query) 291 292 # get target table partition key to confirm it's actually partitioned 293 table_partition_exp = self.fetchone( 294 exp.select("partition_key") 295 .from_("system.tables") 296 .where( 297 exp.column("database").eq(target_table.db), 298 exp.column("name").eq(target_table.name), 299 ) 300 ) 301 302 all_affected_partitions: t.Set[str] = set() 303 304 if where: 305 # identify existing records to keep by inverting the delete `where` clause 306 existing_records_insert_exp = exp.insert( 307 self._select_columns(target_columns_to_types) 308 .from_(target_table) 309 .where(exp.paren(expression=where).not_()), 310 temp_table, 311 ) 312 313 # if target table is partitioned, modify insert expression to only insert 314 # existing records that are in one of the affected partitions 315 if table_partition_exp: 316 partitions_temp_table_name = self._get_temp_table( 317 exp.to_table(f"{target_table.db}._affected_partitions") 318 ) 319 all_affected_partitions, existing_records_insert_exp = ( 320 self._get_affected_partitions_and_insert_exp( 321 target_table, 322 temp_table, 323 where, 324 existing_records_insert_exp, 325 partitions_temp_table_name, 326 ) 327 ) 328 329 try: 330 self.execute(existing_records_insert_exp, track_rows_processed=True) 331 finally: 332 if table_partition_exp: 333 self.drop_table(partitions_temp_table_name) 334 335 # process by partition if: 336 # 1. The table is partitioned AND 337 # (2a. There are existing records to keep (`where`) OR 338 # 2b. We're overwriting existing partition rows (incremental by partition model)) 339 if table_partition_exp and ( 340 where or kwargs.get("keep_existing_partition_rows") is False 341 ): 342 # only replace partitions that have records in temp_table 343 partitions_to_replace = self._get_partition_ids(temp_table) 344 345 # drop affected partitions that have no records in temp_table 346 # - NOTE: `all_affected_partitions` will be empty when keep_existing_partition_rows=False 347 # because previous code block is skipped 348 partitions_to_drop = all_affected_partitions - partitions_to_replace 349 350 if partitions_to_replace or partitions_to_drop: 351 self.alter_table( 352 [ 353 self._build_alter_partition_exp( 354 target_table, temp_table, partitions_to_replace, partitions_to_drop 355 ) 356 ] 357 ) 358 else: 359 self._exchange_tables(target_table, temp_table) 360 finally: 361 self.drop_table(temp_table) 362 363 def _get_affected_partitions_and_insert_exp( 364 self, 365 target_table: exp.Table, 366 temp_table: exp.Table, 367 where: exp.Condition, 368 existing_records_insert_exp: exp.Insert, 369 partitions_temp_table_name: exp.Table, 370 ) -> tuple[t.Set[str], exp.Insert]: 371 # identify all affected partition IDs 372 # - store in temp table so we can reuse results 373 self.ctas( 374 partitions_temp_table_name, 375 exp.select("partition_id") 376 .distinct() 377 .from_( 378 exp.union( 379 # target table partitions with records in `where` 380 exp.select(exp.column("_partition_id").as_("partition_id")) 381 .from_(target_table) 382 .where(where), 383 # temp table partitions with new records to insert 384 exp.select( 385 exp.column("_partition_id").as_("partition_id"), 386 ).from_(temp_table), 387 ).subquery("_affected_partitions") 388 ), 389 ) 390 391 # read all affected partition IDs into memory 392 all_affected_partitions = self._get_partition_ids( 393 partitions_temp_table_name, "partition_id" 394 ) 395 396 # limit existing records insert expression WHERE to affected target table partitions 397 # by adding `AND _partition_id IN (SELECT partition_id FROM partitions_temp_table)` 398 existing_records_insert_exp.set( 399 "expression", 400 existing_records_insert_exp.expression.where( 401 exp.column("_partition_id").isin( 402 exp.select("partition_id").from_(partitions_temp_table_name) 403 ) 404 ), 405 ) 406 407 return all_affected_partitions, existing_records_insert_exp 408 409 def _build_alter_partition_exp( 410 self, 411 target_table: exp.Table, 412 temp_table: exp.Table, 413 partitions_to_replace: t.Set[str], 414 partitions_to_drop: t.Set[str], 415 ) -> exp.Alter: 416 alter_expr = exp.Alter(this=target_table, kind="TABLE") 417 418 for partition in partitions_to_replace: 419 alter_expr.append( 420 "actions", 421 exp.ReplacePartition( 422 expression=exp.Partition( 423 expressions=[exp.PartitionId(this=exp.Literal.string(str(partition)))] 424 ), 425 source=temp_table, 426 ), 427 ) 428 429 for partition in partitions_to_drop: 430 alter_expr.append( 431 "actions", 432 exp.DropPartition( 433 expressions=[ 434 exp.Partition( 435 expressions=[exp.PartitionId(this=exp.Literal.string(str(partition)))] 436 ) 437 ], 438 source=temp_table, 439 ), 440 ) 441 442 return alter_expr 443 444 def _replace_by_key( 445 self, 446 target_table: TableName, 447 source_table: QueryOrDF, 448 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], 449 key: t.Sequence[exp.Expr], 450 is_unique_key: bool, 451 source_columns: t.Optional[t.List[str]] = None, 452 ) -> None: 453 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 454 source_table, 455 target_columns_to_types, 456 target_table=target_table, 457 source_columns=source_columns, 458 ) 459 460 key_exp = ( 461 exp.func("CONCAT_WS", "'__SQLMESH_DELIM__'", *key, dialect=self.dialect) 462 if len(key) > 1 463 else key[0] 464 ) 465 466 self._insert_overwrite_by_condition( 467 target_table, 468 source_queries, 469 target_columns_to_types, 470 dynamic_key=key, 471 dynamic_key_exp=key_exp, 472 dynamic_key_unique=is_unique_key, 473 ) 474 475 def insert_overwrite_by_partition( 476 self, 477 table_name: TableName, 478 query_or_df: QueryOrDF, 479 partitioned_by: t.List[exp.Expr], 480 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 481 source_columns: t.Optional[t.List[str]] = None, 482 ) -> None: 483 table_name = self._strip_virtual_catalog(table_name) 484 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 485 query_or_df, 486 target_columns_to_types, 487 target_table=table_name, 488 source_columns=source_columns, 489 ) 490 491 self._insert_overwrite_by_condition( 492 table_name, source_queries, target_columns_to_types, keep_existing_partition_rows=False 493 ) 494 495 def _create_table_like( 496 self, 497 target_table_name: TableName, 498 source_table_name: TableName, 499 exists: bool, 500 **kwargs: t.Any, 501 ) -> None: 502 """Create table with identical structure as source table""" 503 self.execute( 504 f"CREATE TABLE {target_table_name}{self._on_cluster_sql()} AS {source_table_name}" 505 ) 506 507 def _get_partition_ids( 508 self, 509 table: exp.Table, 510 partition_col_name: str = "_partition_id", 511 where: t.Optional[exp.Condition] = None, 512 limit: t.Optional[int] = None, 513 ) -> t.Set[t.Any]: 514 """List partition IDs present in table""" 515 partitions_query = exp.select(partition_col_name).distinct().from_(table) 516 if where: 517 partitions_query = partitions_query.where(where) 518 if limit: 519 partitions_query = partitions_query.limit(limit) 520 partitions = self.fetchall(partitions_query) 521 522 return set([part[0] for part in partitions] if partitions else []) 523 524 def _create_table( 525 self, 526 table_name_or_schema: t.Union[exp.Schema, TableName], 527 expression: t.Optional[exp.Expr], 528 exists: bool = True, 529 replace: bool = False, 530 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 531 table_description: t.Optional[str] = None, 532 column_descriptions: t.Optional[t.Dict[str, str]] = None, 533 table_kind: t.Optional[str] = None, 534 track_rows_processed: bool = True, 535 **kwargs: t.Any, 536 ) -> None: 537 """Creates a table in the database. 538 539 Clickhouse Cloud requires doing CTAS in two steps. 540 541 First, we add the `EMPTY` property to the CTAS call to create a table with the proper 542 schema, then insert the data with the CTAS query. 543 """ 544 # ensure columns used for partitioning are non-Nullable 545 # - normally user's responsibility, but we automatically partition by time column in 546 # incremental by time models 547 if kwargs.get("partitioned_by"): 548 partition_cols = [ 549 col.name 550 for part_expr in kwargs["partitioned_by"] 551 for col in part_expr.find_all(exp.Column) 552 ] 553 if isinstance(table_name_or_schema, exp.Schema): 554 for coldef in table_name_or_schema.expressions: 555 if coldef.name in partition_cols: 556 coldef.kind.set("nullable", False) 557 if target_columns_to_types: 558 for col in partition_cols: 559 target_columns_to_types[col].set("nullable", False) 560 561 super()._create_table( 562 table_name_or_schema, 563 expression, 564 exists, 565 replace, 566 target_columns_to_types, 567 table_description, 568 column_descriptions, 569 table_kind, 570 empty_ctas=(self.engine_run_mode.is_cloud and expression is not None), 571 track_rows_processed=track_rows_processed, 572 **kwargs, 573 ) 574 575 # execute the second INSERT step if on cloud and creating a table 576 # - Additional clause is to avoid clickhouse-connect HTTP client bug where CTAS LIMIT 0 577 # returns a success code but malformed response 578 if ( 579 self.engine_run_mode.is_cloud 580 and table_kind != "VIEW" 581 and expression 582 and not ( 583 expression.args.get("limit") is not None 584 and expression.args["limit"].expression.this == "0" 585 ) 586 ): 587 table_name = ( 588 table_name_or_schema.this 589 if isinstance(table_name_or_schema, exp.Schema) 590 else table_name_or_schema 591 ) 592 self._insert_append_query( 593 table_name, 594 expression, # type: ignore 595 target_columns_to_types or self.columns(table_name), 596 ) 597 598 def _strip_virtual_catalog(self, name: "TableName") -> exp.Table: 599 """Strip the virtual catalog prefix from a table name if present. 600 601 When a virtual catalog has been injected, ClickHouse table names carry a 602 synthetic catalog prefix (e.g. ``__gw__``) so they match the 3-level FQN 603 depth of catalog-aware peers. This helper removes that prefix before any 604 SQL is sent to the wire, since ClickHouse only supports a two-level 605 ``[database].[table]`` naming scheme. 606 """ 607 table = exp.to_table(name) 608 if self._default_catalog and table.catalog == self._default_catalog: 609 table.set("catalog", None) 610 return table 611 612 def _exchange_tables( 613 self, 614 old_table_name: TableName, 615 new_table_name: TableName, 616 ) -> None: 617 from clickhouse_connect.driver.exceptions import DatabaseError # type: ignore 618 619 old_table_sql = exp.to_table(old_table_name).sql(dialect=self.dialect, identify=True) 620 new_table_sql = exp.to_table(new_table_name).sql(dialect=self.dialect, identify=True) 621 622 try: 623 self.execute( 624 f"EXCHANGE TABLES {old_table_sql} AND {new_table_sql}{self._on_cluster_sql()}" 625 ) 626 except DatabaseError as e: 627 if "NOT_IMPLEMENTED" in str(e): 628 # If someone is using an old Clickhouse version, an OS that doesn't support atomic exchanges, 629 # or a database engine that doesn't support atomic exchanges, we do a non-atomic rename instead. 630 # 631 # Executing multiple renames in one call like `RENAME TABLE a to b, c to a` is supported 632 # but not an atomic operation. Because it is not atomic, doing it in two calls is equivalent 633 # and does not require defining an additional method. 634 throwaway_table_name = self._get_temp_table(old_table_name) 635 self._rename_table(old_table_name, throwaway_table_name) 636 self._rename_table(new_table_name, old_table_name) 637 self.drop_table(throwaway_table_name) 638 639 def _rename_table( 640 self, 641 old_table_name: TableName, 642 new_table_name: TableName, 643 ) -> None: 644 old_table_sql = exp.to_table(old_table_name).sql(dialect=self.dialect, identify=True) 645 new_table_sql = exp.to_table(new_table_name).sql(dialect=self.dialect, identify=True) 646 647 self.execute(f"RENAME TABLE {old_table_sql} TO {new_table_sql}{self._on_cluster_sql()}") 648 649 def delete_from(self, table_name: TableName, where: t.Union[str, exp.Expr]) -> None: 650 delete_expr = exp.delete(self._strip_virtual_catalog(table_name), where) 651 if self.engine_run_mode.is_cluster: 652 delete_expr.set("cluster", exp.OnCluster(this=exp.to_identifier(self.cluster))) 653 self.execute(delete_expr) 654 655 def alter_table( 656 self, 657 alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]], 658 ) -> None: 659 """ 660 Performs the alter statements to change the current table into the structure of the target table. 661 """ 662 with self.transaction(): 663 for alter_expression in [ 664 x.expression if isinstance(x, TableAlterOperation) else x for x in alter_expressions 665 ]: 666 if self._default_catalog and isinstance(alter_expression.this, exp.Table): 667 if alter_expression.this.catalog == self._default_catalog: 668 alter_expression.this.set("catalog", None) 669 if self.engine_run_mode.is_cluster: 670 alter_expression.set( 671 "cluster", exp.OnCluster(this=exp.to_identifier(self.cluster)) 672 ) 673 self.execute(alter_expression) 674 675 def _drop_object( 676 self, 677 name: TableName | SchemaName, 678 exists: bool = True, 679 kind: str = "TABLE", 680 cascade: bool = False, 681 **drop_args: t.Any, 682 ) -> None: 683 """Drops an object. 684 685 An object could be a DATABASE, SCHEMA, VIEW, TABLE, DYNAMIC TABLE, TEMPORARY TABLE etc depending on the :kind. 686 687 Args: 688 name: The name of the table to drop. 689 exists: If exists, defaults to True. 690 kind: What kind of object to drop. Defaults to TABLE 691 **drop_args: Any extra arguments to set on the Drop expression 692 """ 693 super()._drop_object( 694 name=name, 695 exists=exists, 696 kind=kind, 697 cascade=cascade, 698 cluster=exp.OnCluster(this=exp.to_identifier(self.cluster)) 699 if self.engine_run_mode.is_cluster 700 else None, 701 **drop_args, 702 ) 703 704 def _build_partitioned_by_exp( 705 self, 706 partitioned_by: t.List[exp.Expr], 707 **kwargs: t.Any, 708 ) -> t.Optional[t.Union[exp.PartitionedByProperty, exp.Property]]: 709 return exp.PartitionedByProperty( 710 this=exp.Schema(expressions=partitioned_by), 711 ) 712 713 def ensure_nulls_for_unmatched_after_join( 714 self, 715 query: Query, 716 ) -> Query: 717 # Set `join_use_nulls = 1` in a query's SETTINGS clause 718 query.append("settings", exp.var("join_use_nulls").eq(exp.Literal.number("1"))) 719 return query 720 721 def use_server_nulls_for_unmatched_after_join( 722 self, 723 query: Query, 724 ) -> Query: 725 # Set the `join_use_nulls` server value in a query's SETTINGS clause 726 # 727 # Use in SCD models: 728 # - The SCD query we build must include the setting `join_use_nulls = 1` to ensure that empty cells in a join 729 # are filled with NULL instead of the default data type value. The default join_use_nulls value is `0`. 730 # - The SCD embeds the user's original query in the `source` CTE 731 # - Settings are dynamically scoped, so our setting may override the server's default setting the user expects 732 # for their query. 733 # - To prevent this, we: 734 # - If the user query sets `join_use_nulls`, we do nothing 735 # - If the user query does not set `join_use_nulls`, we query the server for the current setting 736 # - If the server value is 1, we do nothing 737 # - If the server values is not 1, we inject its `join_use_nulls` value into the user query 738 # - We do not need to check user subqueries because our injected setting operates at the same scope the 739 # server value would normally operate at 740 setting_name = "join_use_nulls" 741 setting_value = "1" 742 743 user_settings = query.args.get("settings") 744 # if user has not already set it explicitly 745 if not ( 746 user_settings 747 and any( 748 [ 749 isinstance(setting, exp.EQ) and setting.name == setting_name 750 for setting in user_settings 751 ] 752 ) 753 ): 754 server_value = self.fetchone( 755 exp.select("value") 756 .from_("system.settings") 757 .where(exp.column("name").eq(exp.Literal.string(setting_name))) 758 )[0] 759 # only inject the setting if the server value isn't 1 760 inject_setting = setting_value != server_value 761 setting_value = server_value if inject_setting else setting_value 762 763 if inject_setting: 764 query.append( 765 "settings", exp.var(setting_name).eq(exp.Literal.number(setting_value)) 766 ) 767 768 return query 769 770 def _build_settings_property( 771 self, settings: t.Mapping[str, exp.Expr | str | int | float] 772 ) -> exp.SettingsProperty: 773 # ClickHouse requires every key=value pair to live under a single 774 # SETTINGS clause (`SETTINGS a = 1, b = 2`). Emitting one 775 # SettingsProperty per pair produces repeated SETTINGS keywords and a 776 # syntax error at execution time. 777 return exp.SettingsProperty( 778 expressions=[ 779 exp.EQ( 780 this=exp.var(key.lower()), 781 expression=value 782 if isinstance(value, exp.Expr) 783 else exp.Literal(this=value, is_string=isinstance(value, str)), 784 ) 785 for key, value in settings.items() 786 ] 787 ) 788 789 def _build_table_properties_exp( 790 self, 791 catalog_name: t.Optional[str] = None, 792 table_format: t.Optional[str] = None, 793 storage_format: t.Optional[str] = None, 794 partitioned_by: t.Optional[t.List[exp.Expr]] = None, 795 partition_interval_unit: t.Optional[IntervalUnit] = None, 796 clustered_by: t.Optional[t.List[exp.Expr]] = None, 797 table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, 798 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 799 table_description: t.Optional[str] = None, 800 table_kind: t.Optional[str] = None, 801 empty_ctas: bool = False, 802 **kwargs: t.Any, 803 ) -> t.Optional[exp.Properties]: 804 properties: t.List[exp.Expr] = [] 805 806 table_engine = self.DEFAULT_TABLE_ENGINE 807 if storage_format: 808 table_engine = ( 809 storage_format.this if isinstance(storage_format, exp.Var) else storage_format # type: ignore 810 ) 811 properties.append(exp.EngineProperty(this=table_engine)) 812 813 # copy of table_properties so we can pop items off below then consume the rest later 814 table_properties_copy = { 815 k.upper(): v for k, v in (table_properties.copy() if table_properties else {}).items() 816 } 817 818 mergetree_engine = bool(re.search(self.ORDER_BY_TABLE_ENGINE_REGEX, table_engine)) 819 ordered_by_raw = table_properties_copy.pop("ORDER_BY", None) 820 if mergetree_engine: 821 ordered_by_exprs = [] 822 if ordered_by_raw: 823 ordered_by_vals = [] 824 825 if isinstance(ordered_by_raw, (exp.Tuple, exp.Array)): 826 ordered_by_vals = ordered_by_raw.expressions 827 if isinstance(ordered_by_raw, exp.Paren): 828 ordered_by_vals = [ordered_by_raw.this] 829 830 if not ordered_by_vals: 831 ordered_by_vals = ( 832 ordered_by_raw if isinstance(ordered_by_raw, list) else [ordered_by_raw] 833 ) 834 835 for col in ordered_by_vals: 836 ordered_by_exprs.append( 837 col 838 if isinstance(col, exp.Column) 839 else maybe_parse( 840 col.name if isinstance(col, exp.Literal) else col, 841 dialect=self.dialect, 842 into=exp.Ordered, 843 ) 844 ) 845 846 properties.append(exp.Order(expressions=[exp.Tuple(expressions=ordered_by_exprs)])) 847 848 primary_key = table_properties_copy.pop("PRIMARY_KEY", None) 849 if mergetree_engine and primary_key: 850 primary_key_vals = [] 851 if isinstance(primary_key, (exp.Tuple, exp.Array)): 852 primary_key_vals = primary_key.expressions 853 if isinstance(ordered_by_raw, exp.Paren): 854 primary_key_vals = [primary_key.this] 855 856 if not primary_key_vals: 857 primary_key_vals = primary_key if isinstance(primary_key, list) else [primary_key] 858 859 properties.append( 860 exp.PrimaryKey( 861 expressions=[ 862 exp.to_column(k.name if isinstance(k, exp.Literal) else k) 863 for k in primary_key_vals 864 ] 865 ) 866 ) 867 868 ttl = table_properties_copy.pop("TTL", None) 869 if ttl: 870 properties.append( 871 exp.MergeTreeTTL(expressions=[ttl if isinstance(ttl, exp.Expr) else exp.var(ttl)]) 872 ) 873 874 if ( 875 partitioned_by 876 and (partitioned_by_prop := self._build_partitioned_by_exp(partitioned_by)) is not None 877 ): 878 properties.append(partitioned_by_prop) 879 880 if self.engine_run_mode.is_cluster: 881 properties.append(exp.OnCluster(this=exp.to_identifier(self.cluster))) 882 883 if empty_ctas: 884 properties.append(exp.EmptyProperty()) 885 886 if table_properties_copy: 887 properties.append(self._build_settings_property(table_properties_copy)) 888 889 if table_description: 890 properties.append( 891 exp.SchemaCommentProperty( 892 this=exp.Literal.string(self._truncate_table_comment(table_description)) 893 ) 894 ) 895 896 if properties: 897 return exp.Properties(expressions=properties) 898 899 return None 900 901 def _build_view_properties_exp( 902 self, 903 view_properties: t.Optional[t.Dict[str, exp.Expr]] = None, 904 table_description: t.Optional[str] = None, 905 **kwargs: t.Any, 906 ) -> t.Optional[exp.Properties]: 907 """Creates a SQLGlot table properties expression for view""" 908 properties: t.List[exp.Expr] = [] 909 910 view_properties_copy = view_properties.copy() if view_properties else {} 911 912 if self.engine_run_mode.is_cluster: 913 properties.append(exp.OnCluster(this=exp.to_identifier(self.cluster))) 914 915 if view_properties_copy: 916 properties.append(self._build_settings_property(view_properties_copy)) 917 918 if table_description: 919 properties.append( 920 exp.SchemaCommentProperty( 921 this=exp.Literal.string(self._truncate_table_comment(table_description)) 922 ) 923 ) 924 925 if properties: 926 return exp.Properties(expressions=properties) 927 return None 928 929 def _build_create_comment_table_exp( 930 self, table: exp.Table, table_comment: str, table_kind: str, **kwargs: t.Any 931 ) -> exp.Comment | str: 932 table_sql = table.sql(dialect=self.dialect, identify=True) 933 934 truncated_comment = self._truncate_table_comment(table_comment) 935 comment_sql = exp.Literal.string(truncated_comment).sql(dialect=self.dialect) 936 937 return f"ALTER TABLE {table_sql}{self._on_cluster_sql()} MODIFY COMMENT {comment_sql}" 938 939 def _build_create_comment_column_exp( 940 self, 941 table: exp.Table, 942 column_name: str, 943 column_comment: str, 944 table_kind: str = "TABLE", 945 **kwargs: t.Any, 946 ) -> exp.Comment | str: 947 table_sql = table.sql(dialect=self.dialect, identify=True) 948 column_sql = exp.to_column(column_name).sql(dialect=self.dialect, identify=True) 949 950 truncated_comment = self._truncate_table_comment(column_comment) 951 comment_sql = exp.Literal.string(truncated_comment).sql(dialect=self.dialect) 952 953 return f"ALTER TABLE {table_sql}{self._on_cluster_sql()} COMMENT COLUMN {column_sql} {comment_sql}" 954 955 def _on_cluster_sql(self) -> str: 956 if self.engine_run_mode.is_cluster: 957 cluster_name = exp.to_identifier(self.cluster, quoted=True).sql(dialect=self.dialect) # type: ignore 958 return f" ON CLUSTER {cluster_name} " 959 return ""
35class ClickhouseEngineAdapter(EngineAdapterWithIndexSupport, LogicalMergeMixin): 36 DIALECT = "clickhouse" 37 SUPPORTS_TRANSACTIONS = False 38 SUPPORTS_VIEW_SCHEMA = False 39 SUPPORTS_REPLACE_TABLE = False 40 COMMENT_CREATION_VIEW = CommentCreationView.COMMENT_COMMAND_ONLY 41 42 SCHEMA_DIFFER_KWARGS = {} 43 44 DEFAULT_TABLE_ENGINE = "MergeTree" 45 ORDER_BY_TABLE_ENGINE_REGEX = "^.*?MergeTree.*$" 46 47 @property 48 def catalog_support(self) -> CatalogSupport: 49 # This property is intentionally dynamic: it transitions from UNSUPPORTED to 50 # SINGLE_CATALOG_ONLY after inject_virtual_catalog() sets _default_catalog. Callers must 51 # not cache the result — always read it live so they see the post-injection state. 52 if self._default_catalog: 53 return CatalogSupport.SINGLE_CATALOG_ONLY 54 return CatalogSupport.UNSUPPORTED 55 56 def supports_virtual_catalog(self) -> bool: 57 return True 58 59 def inject_virtual_catalog(self, gateway: str) -> None: 60 configured = self._extra_config.get("virtual_catalog") 61 self._default_catalog = f"__{gateway}__" if configured is None else configured 62 63 @property 64 def engine_run_mode(self) -> EngineRunMode: 65 if self._extra_config.get("cloud_mode"): 66 return EngineRunMode.CLOUD 67 # we use the user's specification of a cluster in the connection config to determine if 68 # the engine is in cluster mode 69 if self._extra_config.get("cluster"): 70 return EngineRunMode.CLUSTER 71 return EngineRunMode.STANDALONE 72 73 @property 74 def cluster(self) -> t.Optional[str]: 75 return self._extra_config.get("cluster") 76 77 # Workaround for clickhouse-connect cursor bug 78 # - cursor does not reset row index correctly on `close()`, so `fetchone()` and `fetchmany()` 79 # return the wrong (or no) rows after the very first cursor query that returns rows 80 # in the connection 81 # - cursor does reset the data rows correctly on `close()`, so `fetchall()` works because it 82 # doesn't use the row index at all 83 def fetchone( 84 self, 85 query: t.Union[exp.Expr, str], 86 ignore_unsupported_errors: bool = False, 87 quote_identifiers: bool = False, 88 ) -> t.Tuple: 89 with self.transaction(): 90 self.execute( 91 query, 92 ignore_unsupported_errors=ignore_unsupported_errors, 93 quote_identifiers=quote_identifiers, 94 ) 95 return self.cursor.fetchall()[0] 96 97 def _fetch_native_df( 98 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 99 ) -> pd.DataFrame: 100 """Fetches a Pandas DataFrame from the cursor""" 101 return self.cursor.client.query_df( 102 self._to_sql(query, quote=quote_identifiers) if isinstance(query, exp.Expr) else query, 103 use_extended_dtypes=True, 104 ) 105 106 def _df_to_source_queries( 107 self, 108 df: DF, 109 target_columns_to_types: t.Dict[str, exp.DataType], 110 batch_size: int, 111 target_table: TableName, 112 source_columns: t.Optional[t.List[str]] = None, 113 **kwargs: t.Any, 114 ) -> t.List[SourceQuery]: 115 temp_table = self._get_temp_table(target_table, **kwargs) 116 source_columns_to_types = get_source_columns_to_types( 117 target_columns_to_types, source_columns 118 ) 119 120 def query_factory() -> Query: 121 # It is possible for the factory to be called multiple times and if so then the temp table will already 122 # be created so we skip creating again. This means we are assuming the first call is the same result 123 # as later calls. 124 if not self.table_exists(temp_table): 125 self.create_table( 126 temp_table, 127 source_columns_to_types, 128 storage_format=exp.var("MergeTree"), 129 **kwargs, 130 ) 131 ordered_df = df[list(source_columns_to_types)] 132 133 self.cursor.client.insert_df(temp_table.sql(dialect=self.dialect), df=ordered_df) 134 135 return exp.select(*self._casted_columns(target_columns_to_types, source_columns)).from_( 136 temp_table 137 ) 138 139 return [ 140 SourceQuery( 141 query_factory=query_factory, 142 cleanup_func=lambda: self.drop_table(temp_table, **kwargs), 143 ) 144 ] 145 146 def _get_data_objects( 147 self, schema_name: SchemaName, object_names: t.Optional[t.Set[str]] = None 148 ) -> t.List[DataObject]: 149 """ 150 Returns all the data objects that exist in the given database. 151 """ 152 query = ( 153 exp.select( 154 exp.column("database").as_("schema_name"), 155 exp.column("name"), 156 exp.case(exp.column("engine")) 157 .when( 158 exp.Literal.string("View"), 159 exp.Literal.string("view"), 160 ) 161 .else_( 162 exp.Literal.string("table"), 163 ) 164 .as_("type"), 165 ) 166 .from_("system.tables") 167 .where(exp.column("database").eq(to_schema(schema_name).db)) 168 ) 169 if object_names: 170 query = query.where(exp.column("name").isin(*object_names)) 171 df = self.fetchdf(query) 172 return [ 173 DataObject( 174 catalog=None, 175 schema=row.schema_name, 176 name=row.name, 177 type=DataObjectType.from_str(row.type), # type: ignore 178 ) 179 for row in df.itertuples() 180 ] 181 182 def create_schema( 183 self, 184 schema_name: SchemaName, 185 ignore_if_exists: bool = True, 186 warn_on_error: bool = True, 187 properties: t.List[exp.Expr] = [], 188 ) -> None: 189 """Create a Clickhouse database from a name or qualified table name. 190 191 Clickhouse has a two-level naming scheme [database].[table]. 192 """ 193 from sqlmesh.utils.errors import SQLMeshError 194 195 properties_copy = properties.copy() 196 if self.engine_run_mode.is_cluster: 197 properties_copy.append(exp.OnCluster(this=exp.to_identifier(self.cluster))) 198 199 # ClickHouse does not support catalogs. When a virtual catalog has been injected 200 # (self._default_catalog is set), strip it from the schema name. This mirrors the 201 # SINGLE_CATALOG_ONLY branch in the set_catalog decorator, which does not apply here 202 # because this override is not wrapped by @set_catalog(). 203 if self._default_catalog: 204 schema_exp = to_schema(schema_name) 205 catalog_name = schema_exp.catalog 206 if catalog_name: 207 if catalog_name != self._default_catalog: 208 raise SQLMeshError( 209 f"clickhouse requires that all catalog operations be against a single catalog: " 210 f"{self._default_catalog}. Provided catalog: {catalog_name}" 211 ) 212 schema_name = self._strip_virtual_catalog(schema_exp) 213 214 # can't call super() because it will try to set a catalog 215 return self._create_schema( 216 schema_name=schema_name, 217 ignore_if_exists=ignore_if_exists, 218 warn_on_error=warn_on_error, 219 properties=properties_copy, 220 # sqlglot transpiles CREATE SCHEMA to CREATE DATABASE, but this text is used in an error message 221 kind="DATABASE", 222 ) 223 224 def _insert_overwrite_by_condition( 225 self, 226 table_name: TableName, 227 source_queries: t.List[SourceQuery], 228 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 229 where: t.Optional[exp.Condition] = None, 230 insert_overwrite_strategy_override: t.Optional[InsertOverwriteStrategy] = None, 231 **kwargs: t.Any, 232 ) -> None: 233 """ 234 Implements the table or partition swap approach to insert-overwriting records. 235 236 Because this method executes multiple variants (full table replace, replace by time 237 range, replace by key, replace by partition), some upstream caller info is needed and 238 passed via kwargs. 239 240 Args: 241 table_name: Name of target table 242 source_queries: Source queries returning records to insert 243 target_columns_to_types: Column names and data types of target table 244 where: SQLGlot expression determining which target table rows should be overwritten 245 insert_overwrite_strategy_override: Not used by Clickhouse 246 kwargs: 247 dynamic_key: Key columns (replace by key only) 248 dynamic_key_exp: Expression to build key (replace by key only) 249 dynamic_key_unique: Whether more than one record can exist per key value (replace by key only) 250 251 keep_existing_partition_rows: Whether to overwrite partitions with only new records (incremental by partition only) 252 253 Returns: 254 Side effects only: execution of insert-overwrite operation. 255 """ 256 target_table = exp.to_table(table_name) 257 target_columns_to_types = target_columns_to_types or self.columns(target_table) 258 259 temp_table = self._get_temp_table(target_table) 260 self.create_table_like(temp_table, target_table) 261 262 # REPLACE BY KEY: extract kwargs if present 263 dynamic_key = kwargs.get("dynamic_key") 264 if dynamic_key: 265 dynamic_key_exp = t.cast(exp.Expr, kwargs.get("dynamic_key_exp")) 266 dynamic_key_unique = t.cast(bool, kwargs.get("dynamic_key_unique")) 267 268 try: 269 # insert new records into temp table 270 for source_query in source_queries: 271 with source_query as query: 272 # REPLACE BY KEY: if unique key, DISTINCTify by key columns so only one row is present per key 273 if dynamic_key and dynamic_key_unique: 274 query = query.distinct(*dynamic_key) # type: ignore 275 276 query = self._order_projections_and_filter( 277 query, target_columns_to_types, where=where 278 ) 279 self._insert_append_query( 280 temp_table, 281 query, 282 target_columns_to_types=target_columns_to_types, 283 order_projections=False, 284 ) 285 286 # REPLACE BY KEY: build `where` expression as "key IN (new rows' key values)" 287 if dynamic_key: 288 key_query = exp.select(dynamic_key_exp).from_(temp_table) 289 if not dynamic_key_unique: 290 key_query = key_query.distinct() 291 where = dynamic_key_exp.isin(query=key_query) 292 293 # get target table partition key to confirm it's actually partitioned 294 table_partition_exp = self.fetchone( 295 exp.select("partition_key") 296 .from_("system.tables") 297 .where( 298 exp.column("database").eq(target_table.db), 299 exp.column("name").eq(target_table.name), 300 ) 301 ) 302 303 all_affected_partitions: t.Set[str] = set() 304 305 if where: 306 # identify existing records to keep by inverting the delete `where` clause 307 existing_records_insert_exp = exp.insert( 308 self._select_columns(target_columns_to_types) 309 .from_(target_table) 310 .where(exp.paren(expression=where).not_()), 311 temp_table, 312 ) 313 314 # if target table is partitioned, modify insert expression to only insert 315 # existing records that are in one of the affected partitions 316 if table_partition_exp: 317 partitions_temp_table_name = self._get_temp_table( 318 exp.to_table(f"{target_table.db}._affected_partitions") 319 ) 320 all_affected_partitions, existing_records_insert_exp = ( 321 self._get_affected_partitions_and_insert_exp( 322 target_table, 323 temp_table, 324 where, 325 existing_records_insert_exp, 326 partitions_temp_table_name, 327 ) 328 ) 329 330 try: 331 self.execute(existing_records_insert_exp, track_rows_processed=True) 332 finally: 333 if table_partition_exp: 334 self.drop_table(partitions_temp_table_name) 335 336 # process by partition if: 337 # 1. The table is partitioned AND 338 # (2a. There are existing records to keep (`where`) OR 339 # 2b. We're overwriting existing partition rows (incremental by partition model)) 340 if table_partition_exp and ( 341 where or kwargs.get("keep_existing_partition_rows") is False 342 ): 343 # only replace partitions that have records in temp_table 344 partitions_to_replace = self._get_partition_ids(temp_table) 345 346 # drop affected partitions that have no records in temp_table 347 # - NOTE: `all_affected_partitions` will be empty when keep_existing_partition_rows=False 348 # because previous code block is skipped 349 partitions_to_drop = all_affected_partitions - partitions_to_replace 350 351 if partitions_to_replace or partitions_to_drop: 352 self.alter_table( 353 [ 354 self._build_alter_partition_exp( 355 target_table, temp_table, partitions_to_replace, partitions_to_drop 356 ) 357 ] 358 ) 359 else: 360 self._exchange_tables(target_table, temp_table) 361 finally: 362 self.drop_table(temp_table) 363 364 def _get_affected_partitions_and_insert_exp( 365 self, 366 target_table: exp.Table, 367 temp_table: exp.Table, 368 where: exp.Condition, 369 existing_records_insert_exp: exp.Insert, 370 partitions_temp_table_name: exp.Table, 371 ) -> tuple[t.Set[str], exp.Insert]: 372 # identify all affected partition IDs 373 # - store in temp table so we can reuse results 374 self.ctas( 375 partitions_temp_table_name, 376 exp.select("partition_id") 377 .distinct() 378 .from_( 379 exp.union( 380 # target table partitions with records in `where` 381 exp.select(exp.column("_partition_id").as_("partition_id")) 382 .from_(target_table) 383 .where(where), 384 # temp table partitions with new records to insert 385 exp.select( 386 exp.column("_partition_id").as_("partition_id"), 387 ).from_(temp_table), 388 ).subquery("_affected_partitions") 389 ), 390 ) 391 392 # read all affected partition IDs into memory 393 all_affected_partitions = self._get_partition_ids( 394 partitions_temp_table_name, "partition_id" 395 ) 396 397 # limit existing records insert expression WHERE to affected target table partitions 398 # by adding `AND _partition_id IN (SELECT partition_id FROM partitions_temp_table)` 399 existing_records_insert_exp.set( 400 "expression", 401 existing_records_insert_exp.expression.where( 402 exp.column("_partition_id").isin( 403 exp.select("partition_id").from_(partitions_temp_table_name) 404 ) 405 ), 406 ) 407 408 return all_affected_partitions, existing_records_insert_exp 409 410 def _build_alter_partition_exp( 411 self, 412 target_table: exp.Table, 413 temp_table: exp.Table, 414 partitions_to_replace: t.Set[str], 415 partitions_to_drop: t.Set[str], 416 ) -> exp.Alter: 417 alter_expr = exp.Alter(this=target_table, kind="TABLE") 418 419 for partition in partitions_to_replace: 420 alter_expr.append( 421 "actions", 422 exp.ReplacePartition( 423 expression=exp.Partition( 424 expressions=[exp.PartitionId(this=exp.Literal.string(str(partition)))] 425 ), 426 source=temp_table, 427 ), 428 ) 429 430 for partition in partitions_to_drop: 431 alter_expr.append( 432 "actions", 433 exp.DropPartition( 434 expressions=[ 435 exp.Partition( 436 expressions=[exp.PartitionId(this=exp.Literal.string(str(partition)))] 437 ) 438 ], 439 source=temp_table, 440 ), 441 ) 442 443 return alter_expr 444 445 def _replace_by_key( 446 self, 447 target_table: TableName, 448 source_table: QueryOrDF, 449 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], 450 key: t.Sequence[exp.Expr], 451 is_unique_key: bool, 452 source_columns: t.Optional[t.List[str]] = None, 453 ) -> None: 454 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 455 source_table, 456 target_columns_to_types, 457 target_table=target_table, 458 source_columns=source_columns, 459 ) 460 461 key_exp = ( 462 exp.func("CONCAT_WS", "'__SQLMESH_DELIM__'", *key, dialect=self.dialect) 463 if len(key) > 1 464 else key[0] 465 ) 466 467 self._insert_overwrite_by_condition( 468 target_table, 469 source_queries, 470 target_columns_to_types, 471 dynamic_key=key, 472 dynamic_key_exp=key_exp, 473 dynamic_key_unique=is_unique_key, 474 ) 475 476 def insert_overwrite_by_partition( 477 self, 478 table_name: TableName, 479 query_or_df: QueryOrDF, 480 partitioned_by: t.List[exp.Expr], 481 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 482 source_columns: t.Optional[t.List[str]] = None, 483 ) -> None: 484 table_name = self._strip_virtual_catalog(table_name) 485 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 486 query_or_df, 487 target_columns_to_types, 488 target_table=table_name, 489 source_columns=source_columns, 490 ) 491 492 self._insert_overwrite_by_condition( 493 table_name, source_queries, target_columns_to_types, keep_existing_partition_rows=False 494 ) 495 496 def _create_table_like( 497 self, 498 target_table_name: TableName, 499 source_table_name: TableName, 500 exists: bool, 501 **kwargs: t.Any, 502 ) -> None: 503 """Create table with identical structure as source table""" 504 self.execute( 505 f"CREATE TABLE {target_table_name}{self._on_cluster_sql()} AS {source_table_name}" 506 ) 507 508 def _get_partition_ids( 509 self, 510 table: exp.Table, 511 partition_col_name: str = "_partition_id", 512 where: t.Optional[exp.Condition] = None, 513 limit: t.Optional[int] = None, 514 ) -> t.Set[t.Any]: 515 """List partition IDs present in table""" 516 partitions_query = exp.select(partition_col_name).distinct().from_(table) 517 if where: 518 partitions_query = partitions_query.where(where) 519 if limit: 520 partitions_query = partitions_query.limit(limit) 521 partitions = self.fetchall(partitions_query) 522 523 return set([part[0] for part in partitions] if partitions else []) 524 525 def _create_table( 526 self, 527 table_name_or_schema: t.Union[exp.Schema, TableName], 528 expression: t.Optional[exp.Expr], 529 exists: bool = True, 530 replace: bool = False, 531 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 532 table_description: t.Optional[str] = None, 533 column_descriptions: t.Optional[t.Dict[str, str]] = None, 534 table_kind: t.Optional[str] = None, 535 track_rows_processed: bool = True, 536 **kwargs: t.Any, 537 ) -> None: 538 """Creates a table in the database. 539 540 Clickhouse Cloud requires doing CTAS in two steps. 541 542 First, we add the `EMPTY` property to the CTAS call to create a table with the proper 543 schema, then insert the data with the CTAS query. 544 """ 545 # ensure columns used for partitioning are non-Nullable 546 # - normally user's responsibility, but we automatically partition by time column in 547 # incremental by time models 548 if kwargs.get("partitioned_by"): 549 partition_cols = [ 550 col.name 551 for part_expr in kwargs["partitioned_by"] 552 for col in part_expr.find_all(exp.Column) 553 ] 554 if isinstance(table_name_or_schema, exp.Schema): 555 for coldef in table_name_or_schema.expressions: 556 if coldef.name in partition_cols: 557 coldef.kind.set("nullable", False) 558 if target_columns_to_types: 559 for col in partition_cols: 560 target_columns_to_types[col].set("nullable", False) 561 562 super()._create_table( 563 table_name_or_schema, 564 expression, 565 exists, 566 replace, 567 target_columns_to_types, 568 table_description, 569 column_descriptions, 570 table_kind, 571 empty_ctas=(self.engine_run_mode.is_cloud and expression is not None), 572 track_rows_processed=track_rows_processed, 573 **kwargs, 574 ) 575 576 # execute the second INSERT step if on cloud and creating a table 577 # - Additional clause is to avoid clickhouse-connect HTTP client bug where CTAS LIMIT 0 578 # returns a success code but malformed response 579 if ( 580 self.engine_run_mode.is_cloud 581 and table_kind != "VIEW" 582 and expression 583 and not ( 584 expression.args.get("limit") is not None 585 and expression.args["limit"].expression.this == "0" 586 ) 587 ): 588 table_name = ( 589 table_name_or_schema.this 590 if isinstance(table_name_or_schema, exp.Schema) 591 else table_name_or_schema 592 ) 593 self._insert_append_query( 594 table_name, 595 expression, # type: ignore 596 target_columns_to_types or self.columns(table_name), 597 ) 598 599 def _strip_virtual_catalog(self, name: "TableName") -> exp.Table: 600 """Strip the virtual catalog prefix from a table name if present. 601 602 When a virtual catalog has been injected, ClickHouse table names carry a 603 synthetic catalog prefix (e.g. ``__gw__``) so they match the 3-level FQN 604 depth of catalog-aware peers. This helper removes that prefix before any 605 SQL is sent to the wire, since ClickHouse only supports a two-level 606 ``[database].[table]`` naming scheme. 607 """ 608 table = exp.to_table(name) 609 if self._default_catalog and table.catalog == self._default_catalog: 610 table.set("catalog", None) 611 return table 612 613 def _exchange_tables( 614 self, 615 old_table_name: TableName, 616 new_table_name: TableName, 617 ) -> None: 618 from clickhouse_connect.driver.exceptions import DatabaseError # type: ignore 619 620 old_table_sql = exp.to_table(old_table_name).sql(dialect=self.dialect, identify=True) 621 new_table_sql = exp.to_table(new_table_name).sql(dialect=self.dialect, identify=True) 622 623 try: 624 self.execute( 625 f"EXCHANGE TABLES {old_table_sql} AND {new_table_sql}{self._on_cluster_sql()}" 626 ) 627 except DatabaseError as e: 628 if "NOT_IMPLEMENTED" in str(e): 629 # If someone is using an old Clickhouse version, an OS that doesn't support atomic exchanges, 630 # or a database engine that doesn't support atomic exchanges, we do a non-atomic rename instead. 631 # 632 # Executing multiple renames in one call like `RENAME TABLE a to b, c to a` is supported 633 # but not an atomic operation. Because it is not atomic, doing it in two calls is equivalent 634 # and does not require defining an additional method. 635 throwaway_table_name = self._get_temp_table(old_table_name) 636 self._rename_table(old_table_name, throwaway_table_name) 637 self._rename_table(new_table_name, old_table_name) 638 self.drop_table(throwaway_table_name) 639 640 def _rename_table( 641 self, 642 old_table_name: TableName, 643 new_table_name: TableName, 644 ) -> None: 645 old_table_sql = exp.to_table(old_table_name).sql(dialect=self.dialect, identify=True) 646 new_table_sql = exp.to_table(new_table_name).sql(dialect=self.dialect, identify=True) 647 648 self.execute(f"RENAME TABLE {old_table_sql} TO {new_table_sql}{self._on_cluster_sql()}") 649 650 def delete_from(self, table_name: TableName, where: t.Union[str, exp.Expr]) -> None: 651 delete_expr = exp.delete(self._strip_virtual_catalog(table_name), where) 652 if self.engine_run_mode.is_cluster: 653 delete_expr.set("cluster", exp.OnCluster(this=exp.to_identifier(self.cluster))) 654 self.execute(delete_expr) 655 656 def alter_table( 657 self, 658 alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]], 659 ) -> None: 660 """ 661 Performs the alter statements to change the current table into the structure of the target table. 662 """ 663 with self.transaction(): 664 for alter_expression in [ 665 x.expression if isinstance(x, TableAlterOperation) else x for x in alter_expressions 666 ]: 667 if self._default_catalog and isinstance(alter_expression.this, exp.Table): 668 if alter_expression.this.catalog == self._default_catalog: 669 alter_expression.this.set("catalog", None) 670 if self.engine_run_mode.is_cluster: 671 alter_expression.set( 672 "cluster", exp.OnCluster(this=exp.to_identifier(self.cluster)) 673 ) 674 self.execute(alter_expression) 675 676 def _drop_object( 677 self, 678 name: TableName | SchemaName, 679 exists: bool = True, 680 kind: str = "TABLE", 681 cascade: bool = False, 682 **drop_args: t.Any, 683 ) -> None: 684 """Drops an object. 685 686 An object could be a DATABASE, SCHEMA, VIEW, TABLE, DYNAMIC TABLE, TEMPORARY TABLE etc depending on the :kind. 687 688 Args: 689 name: The name of the table to drop. 690 exists: If exists, defaults to True. 691 kind: What kind of object to drop. Defaults to TABLE 692 **drop_args: Any extra arguments to set on the Drop expression 693 """ 694 super()._drop_object( 695 name=name, 696 exists=exists, 697 kind=kind, 698 cascade=cascade, 699 cluster=exp.OnCluster(this=exp.to_identifier(self.cluster)) 700 if self.engine_run_mode.is_cluster 701 else None, 702 **drop_args, 703 ) 704 705 def _build_partitioned_by_exp( 706 self, 707 partitioned_by: t.List[exp.Expr], 708 **kwargs: t.Any, 709 ) -> t.Optional[t.Union[exp.PartitionedByProperty, exp.Property]]: 710 return exp.PartitionedByProperty( 711 this=exp.Schema(expressions=partitioned_by), 712 ) 713 714 def ensure_nulls_for_unmatched_after_join( 715 self, 716 query: Query, 717 ) -> Query: 718 # Set `join_use_nulls = 1` in a query's SETTINGS clause 719 query.append("settings", exp.var("join_use_nulls").eq(exp.Literal.number("1"))) 720 return query 721 722 def use_server_nulls_for_unmatched_after_join( 723 self, 724 query: Query, 725 ) -> Query: 726 # Set the `join_use_nulls` server value in a query's SETTINGS clause 727 # 728 # Use in SCD models: 729 # - The SCD query we build must include the setting `join_use_nulls = 1` to ensure that empty cells in a join 730 # are filled with NULL instead of the default data type value. The default join_use_nulls value is `0`. 731 # - The SCD embeds the user's original query in the `source` CTE 732 # - Settings are dynamically scoped, so our setting may override the server's default setting the user expects 733 # for their query. 734 # - To prevent this, we: 735 # - If the user query sets `join_use_nulls`, we do nothing 736 # - If the user query does not set `join_use_nulls`, we query the server for the current setting 737 # - If the server value is 1, we do nothing 738 # - If the server values is not 1, we inject its `join_use_nulls` value into the user query 739 # - We do not need to check user subqueries because our injected setting operates at the same scope the 740 # server value would normally operate at 741 setting_name = "join_use_nulls" 742 setting_value = "1" 743 744 user_settings = query.args.get("settings") 745 # if user has not already set it explicitly 746 if not ( 747 user_settings 748 and any( 749 [ 750 isinstance(setting, exp.EQ) and setting.name == setting_name 751 for setting in user_settings 752 ] 753 ) 754 ): 755 server_value = self.fetchone( 756 exp.select("value") 757 .from_("system.settings") 758 .where(exp.column("name").eq(exp.Literal.string(setting_name))) 759 )[0] 760 # only inject the setting if the server value isn't 1 761 inject_setting = setting_value != server_value 762 setting_value = server_value if inject_setting else setting_value 763 764 if inject_setting: 765 query.append( 766 "settings", exp.var(setting_name).eq(exp.Literal.number(setting_value)) 767 ) 768 769 return query 770 771 def _build_settings_property( 772 self, settings: t.Mapping[str, exp.Expr | str | int | float] 773 ) -> exp.SettingsProperty: 774 # ClickHouse requires every key=value pair to live under a single 775 # SETTINGS clause (`SETTINGS a = 1, b = 2`). Emitting one 776 # SettingsProperty per pair produces repeated SETTINGS keywords and a 777 # syntax error at execution time. 778 return exp.SettingsProperty( 779 expressions=[ 780 exp.EQ( 781 this=exp.var(key.lower()), 782 expression=value 783 if isinstance(value, exp.Expr) 784 else exp.Literal(this=value, is_string=isinstance(value, str)), 785 ) 786 for key, value in settings.items() 787 ] 788 ) 789 790 def _build_table_properties_exp( 791 self, 792 catalog_name: t.Optional[str] = None, 793 table_format: t.Optional[str] = None, 794 storage_format: t.Optional[str] = None, 795 partitioned_by: t.Optional[t.List[exp.Expr]] = None, 796 partition_interval_unit: t.Optional[IntervalUnit] = None, 797 clustered_by: t.Optional[t.List[exp.Expr]] = None, 798 table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, 799 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 800 table_description: t.Optional[str] = None, 801 table_kind: t.Optional[str] = None, 802 empty_ctas: bool = False, 803 **kwargs: t.Any, 804 ) -> t.Optional[exp.Properties]: 805 properties: t.List[exp.Expr] = [] 806 807 table_engine = self.DEFAULT_TABLE_ENGINE 808 if storage_format: 809 table_engine = ( 810 storage_format.this if isinstance(storage_format, exp.Var) else storage_format # type: ignore 811 ) 812 properties.append(exp.EngineProperty(this=table_engine)) 813 814 # copy of table_properties so we can pop items off below then consume the rest later 815 table_properties_copy = { 816 k.upper(): v for k, v in (table_properties.copy() if table_properties else {}).items() 817 } 818 819 mergetree_engine = bool(re.search(self.ORDER_BY_TABLE_ENGINE_REGEX, table_engine)) 820 ordered_by_raw = table_properties_copy.pop("ORDER_BY", None) 821 if mergetree_engine: 822 ordered_by_exprs = [] 823 if ordered_by_raw: 824 ordered_by_vals = [] 825 826 if isinstance(ordered_by_raw, (exp.Tuple, exp.Array)): 827 ordered_by_vals = ordered_by_raw.expressions 828 if isinstance(ordered_by_raw, exp.Paren): 829 ordered_by_vals = [ordered_by_raw.this] 830 831 if not ordered_by_vals: 832 ordered_by_vals = ( 833 ordered_by_raw if isinstance(ordered_by_raw, list) else [ordered_by_raw] 834 ) 835 836 for col in ordered_by_vals: 837 ordered_by_exprs.append( 838 col 839 if isinstance(col, exp.Column) 840 else maybe_parse( 841 col.name if isinstance(col, exp.Literal) else col, 842 dialect=self.dialect, 843 into=exp.Ordered, 844 ) 845 ) 846 847 properties.append(exp.Order(expressions=[exp.Tuple(expressions=ordered_by_exprs)])) 848 849 primary_key = table_properties_copy.pop("PRIMARY_KEY", None) 850 if mergetree_engine and primary_key: 851 primary_key_vals = [] 852 if isinstance(primary_key, (exp.Tuple, exp.Array)): 853 primary_key_vals = primary_key.expressions 854 if isinstance(ordered_by_raw, exp.Paren): 855 primary_key_vals = [primary_key.this] 856 857 if not primary_key_vals: 858 primary_key_vals = primary_key if isinstance(primary_key, list) else [primary_key] 859 860 properties.append( 861 exp.PrimaryKey( 862 expressions=[ 863 exp.to_column(k.name if isinstance(k, exp.Literal) else k) 864 for k in primary_key_vals 865 ] 866 ) 867 ) 868 869 ttl = table_properties_copy.pop("TTL", None) 870 if ttl: 871 properties.append( 872 exp.MergeTreeTTL(expressions=[ttl if isinstance(ttl, exp.Expr) else exp.var(ttl)]) 873 ) 874 875 if ( 876 partitioned_by 877 and (partitioned_by_prop := self._build_partitioned_by_exp(partitioned_by)) is not None 878 ): 879 properties.append(partitioned_by_prop) 880 881 if self.engine_run_mode.is_cluster: 882 properties.append(exp.OnCluster(this=exp.to_identifier(self.cluster))) 883 884 if empty_ctas: 885 properties.append(exp.EmptyProperty()) 886 887 if table_properties_copy: 888 properties.append(self._build_settings_property(table_properties_copy)) 889 890 if table_description: 891 properties.append( 892 exp.SchemaCommentProperty( 893 this=exp.Literal.string(self._truncate_table_comment(table_description)) 894 ) 895 ) 896 897 if properties: 898 return exp.Properties(expressions=properties) 899 900 return None 901 902 def _build_view_properties_exp( 903 self, 904 view_properties: t.Optional[t.Dict[str, exp.Expr]] = None, 905 table_description: t.Optional[str] = None, 906 **kwargs: t.Any, 907 ) -> t.Optional[exp.Properties]: 908 """Creates a SQLGlot table properties expression for view""" 909 properties: t.List[exp.Expr] = [] 910 911 view_properties_copy = view_properties.copy() if view_properties else {} 912 913 if self.engine_run_mode.is_cluster: 914 properties.append(exp.OnCluster(this=exp.to_identifier(self.cluster))) 915 916 if view_properties_copy: 917 properties.append(self._build_settings_property(view_properties_copy)) 918 919 if table_description: 920 properties.append( 921 exp.SchemaCommentProperty( 922 this=exp.Literal.string(self._truncate_table_comment(table_description)) 923 ) 924 ) 925 926 if properties: 927 return exp.Properties(expressions=properties) 928 return None 929 930 def _build_create_comment_table_exp( 931 self, table: exp.Table, table_comment: str, table_kind: str, **kwargs: t.Any 932 ) -> exp.Comment | str: 933 table_sql = table.sql(dialect=self.dialect, identify=True) 934 935 truncated_comment = self._truncate_table_comment(table_comment) 936 comment_sql = exp.Literal.string(truncated_comment).sql(dialect=self.dialect) 937 938 return f"ALTER TABLE {table_sql}{self._on_cluster_sql()} MODIFY COMMENT {comment_sql}" 939 940 def _build_create_comment_column_exp( 941 self, 942 table: exp.Table, 943 column_name: str, 944 column_comment: str, 945 table_kind: str = "TABLE", 946 **kwargs: t.Any, 947 ) -> exp.Comment | str: 948 table_sql = table.sql(dialect=self.dialect, identify=True) 949 column_sql = exp.to_column(column_name).sql(dialect=self.dialect, identify=True) 950 951 truncated_comment = self._truncate_table_comment(column_comment) 952 comment_sql = exp.Literal.string(truncated_comment).sql(dialect=self.dialect) 953 954 return f"ALTER TABLE {table_sql}{self._on_cluster_sql()} COMMENT COLUMN {column_sql} {comment_sql}" 955 956 def _on_cluster_sql(self) -> str: 957 if self.engine_run_mode.is_cluster: 958 cluster_name = exp.to_identifier(self.cluster, quoted=True).sql(dialect=self.dialect) # type: ignore 959 return f" ON CLUSTER {cluster_name} " 960 return ""
Base class wrapping a Database API compliant connection.
The EngineAdapter is an easily-subclassable interface that interacts with the underlying engine and data store.
Arguments:
- connection_factory_or_pool: a callable which produces a new Database API-compliant connection on every call.
- dialect: The dialect with which this adapter is associated.
- multithreaded: Indicates whether this adapter will be used by more than one thread.
47 @property 48 def catalog_support(self) -> CatalogSupport: 49 # This property is intentionally dynamic: it transitions from UNSUPPORTED to 50 # SINGLE_CATALOG_ONLY after inject_virtual_catalog() sets _default_catalog. Callers must 51 # not cache the result — always read it live so they see the post-injection state. 52 if self._default_catalog: 53 return CatalogSupport.SINGLE_CATALOG_ONLY 54 return CatalogSupport.UNSUPPORTED
Return True if this adapter can accept a virtual catalog for multi-gateway nesting alignment.
When a project mixes catalog-aware gateways (e.g. DuckDB) with catalog-unsupported gateways (e.g. ClickHouse), all adapters need a uniform 3-level FQN so MappingSchema nesting stays consistent. Adapters that return True here opt in to receiving an injected virtual catalog via inject_virtual_catalog(), which causes the set_catalog decorator to strip the catalog from DDL expressions rather than raising UnsupportedCatalogOperationError.
59 def inject_virtual_catalog(self, gateway: str) -> None: 60 configured = self._extra_config.get("virtual_catalog") 61 self._default_catalog = f"__{gateway}__" if configured is None else configured
Inject a gateway name to configure the adapter's virtual catalog.
The adapter determines the final catalog name from the gateway name (e.g. ClickHouse wraps it as __{gateway}__). Only call this on adapters that return True from supports_virtual_catalog(). After injection, catalog_support should return SINGLE_CATALOG_ONLY so the set_catalog decorator strips the virtual catalog from DDL expressions instead of raising an error.
63 @property 64 def engine_run_mode(self) -> EngineRunMode: 65 if self._extra_config.get("cloud_mode"): 66 return EngineRunMode.CLOUD 67 # we use the user's specification of a cluster in the connection config to determine if 68 # the engine is in cluster mode 69 if self._extra_config.get("cluster"): 70 return EngineRunMode.CLUSTER 71 return EngineRunMode.STANDALONE
83 def fetchone( 84 self, 85 query: t.Union[exp.Expr, str], 86 ignore_unsupported_errors: bool = False, 87 quote_identifiers: bool = False, 88 ) -> t.Tuple: 89 with self.transaction(): 90 self.execute( 91 query, 92 ignore_unsupported_errors=ignore_unsupported_errors, 93 quote_identifiers=quote_identifiers, 94 ) 95 return self.cursor.fetchall()[0]
182 def create_schema( 183 self, 184 schema_name: SchemaName, 185 ignore_if_exists: bool = True, 186 warn_on_error: bool = True, 187 properties: t.List[exp.Expr] = [], 188 ) -> None: 189 """Create a Clickhouse database from a name or qualified table name. 190 191 Clickhouse has a two-level naming scheme [database].[table]. 192 """ 193 from sqlmesh.utils.errors import SQLMeshError 194 195 properties_copy = properties.copy() 196 if self.engine_run_mode.is_cluster: 197 properties_copy.append(exp.OnCluster(this=exp.to_identifier(self.cluster))) 198 199 # ClickHouse does not support catalogs. When a virtual catalog has been injected 200 # (self._default_catalog is set), strip it from the schema name. This mirrors the 201 # SINGLE_CATALOG_ONLY branch in the set_catalog decorator, which does not apply here 202 # because this override is not wrapped by @set_catalog(). 203 if self._default_catalog: 204 schema_exp = to_schema(schema_name) 205 catalog_name = schema_exp.catalog 206 if catalog_name: 207 if catalog_name != self._default_catalog: 208 raise SQLMeshError( 209 f"clickhouse requires that all catalog operations be against a single catalog: " 210 f"{self._default_catalog}. Provided catalog: {catalog_name}" 211 ) 212 schema_name = self._strip_virtual_catalog(schema_exp) 213 214 # can't call super() because it will try to set a catalog 215 return self._create_schema( 216 schema_name=schema_name, 217 ignore_if_exists=ignore_if_exists, 218 warn_on_error=warn_on_error, 219 properties=properties_copy, 220 # sqlglot transpiles CREATE SCHEMA to CREATE DATABASE, but this text is used in an error message 221 kind="DATABASE", 222 )
Create a Clickhouse database from a name or qualified table name.
Clickhouse has a two-level naming scheme [database].[table].
476 def insert_overwrite_by_partition( 477 self, 478 table_name: TableName, 479 query_or_df: QueryOrDF, 480 partitioned_by: t.List[exp.Expr], 481 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 482 source_columns: t.Optional[t.List[str]] = None, 483 ) -> None: 484 table_name = self._strip_virtual_catalog(table_name) 485 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 486 query_or_df, 487 target_columns_to_types, 488 target_table=table_name, 489 source_columns=source_columns, 490 ) 491 492 self._insert_overwrite_by_condition( 493 table_name, source_queries, target_columns_to_types, keep_existing_partition_rows=False 494 )
650 def delete_from(self, table_name: TableName, where: t.Union[str, exp.Expr]) -> None: 651 delete_expr = exp.delete(self._strip_virtual_catalog(table_name), where) 652 if self.engine_run_mode.is_cluster: 653 delete_expr.set("cluster", exp.OnCluster(this=exp.to_identifier(self.cluster))) 654 self.execute(delete_expr)
656 def alter_table( 657 self, 658 alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]], 659 ) -> None: 660 """ 661 Performs the alter statements to change the current table into the structure of the target table. 662 """ 663 with self.transaction(): 664 for alter_expression in [ 665 x.expression if isinstance(x, TableAlterOperation) else x for x in alter_expressions 666 ]: 667 if self._default_catalog and isinstance(alter_expression.this, exp.Table): 668 if alter_expression.this.catalog == self._default_catalog: 669 alter_expression.this.set("catalog", None) 670 if self.engine_run_mode.is_cluster: 671 alter_expression.set( 672 "cluster", exp.OnCluster(this=exp.to_identifier(self.cluster)) 673 ) 674 self.execute(alter_expression)
Performs the alter statements to change the current table into the structure of the target table.
722 def use_server_nulls_for_unmatched_after_join( 723 self, 724 query: Query, 725 ) -> Query: 726 # Set the `join_use_nulls` server value in a query's SETTINGS clause 727 # 728 # Use in SCD models: 729 # - The SCD query we build must include the setting `join_use_nulls = 1` to ensure that empty cells in a join 730 # are filled with NULL instead of the default data type value. The default join_use_nulls value is `0`. 731 # - The SCD embeds the user's original query in the `source` CTE 732 # - Settings are dynamically scoped, so our setting may override the server's default setting the user expects 733 # for their query. 734 # - To prevent this, we: 735 # - If the user query sets `join_use_nulls`, we do nothing 736 # - If the user query does not set `join_use_nulls`, we query the server for the current setting 737 # - If the server value is 1, we do nothing 738 # - If the server values is not 1, we inject its `join_use_nulls` value into the user query 739 # - We do not need to check user subqueries because our injected setting operates at the same scope the 740 # server value would normally operate at 741 setting_name = "join_use_nulls" 742 setting_value = "1" 743 744 user_settings = query.args.get("settings") 745 # if user has not already set it explicitly 746 if not ( 747 user_settings 748 and any( 749 [ 750 isinstance(setting, exp.EQ) and setting.name == setting_name 751 for setting in user_settings 752 ] 753 ) 754 ): 755 server_value = self.fetchone( 756 exp.select("value") 757 .from_("system.settings") 758 .where(exp.column("name").eq(exp.Literal.string(setting_name))) 759 )[0] 760 # only inject the setting if the server value isn't 1 761 inject_setting = setting_value != server_value 762 setting_value = server_value if inject_setting else setting_value 763 764 if inject_setting: 765 query.append( 766 "settings", exp.var(setting_name).eq(exp.Literal.number(setting_value)) 767 ) 768 769 return query
Inherited Members
- sqlmesh.core.engine_adapter.base.EngineAdapter
- EngineAdapter
- DEFAULT_BATCH_SIZE
- DATA_OBJECT_FILTER_BATCH_SIZE
- COMMENT_CREATION_TABLE
- MAX_TABLE_COMMENT_LENGTH
- MAX_COLUMN_COMMENT_LENGTH
- INSERT_OVERWRITE_STRATEGY
- SUPPORTS_MATERIALIZED_VIEWS
- SUPPORTS_MATERIALIZED_VIEW_SCHEMA
- SUPPORTS_CLONING
- SUPPORTS_MANAGED_MODELS
- SUPPORTS_CREATE_DROP_CATALOG
- SUPPORTED_DROP_CASCADE_OBJECT_KINDS
- SUPPORTS_TUPLE_IN
- HAS_VIEW_BINDING
- RECREATE_MATERIALIZED_VIEW_ON_EVALUATION
- SUPPORTS_GRANTS
- DEFAULT_CATALOG_TYPE
- QUOTE_IDENTIFIERS_IN_VIEWS
- MAX_IDENTIFIER_LENGTH
- ATTACH_CORRELATION_ID
- SUPPORTS_QUERY_EXECUTION_TRACKING
- SUPPORTS_METADATA_TABLE_LAST_MODIFIED_TS
- RESOLVE_TABLE_REFS_IN_PHYSICAL_PROPERTIES
- dialect
- correlation_id
- with_settings
- cursor
- connection
- spark
- snowpark
- bigframe
- comments_enabled
- schema_differ
- default_catalog
- recycle
- close
- get_current_catalog
- set_current_catalog
- get_catalog_type
- get_catalog_type_from_table
- current_catalog_type
- replace_query
- create_index
- create_table
- create_managed_table
- ctas
- create_state_table
- create_table_like
- clone_table
- drop_data_object
- drop_table
- drop_managed_table
- get_alter_operations
- create_view
- drop_schema
- drop_view
- create_catalog
- drop_catalog
- columns
- table_exists
- insert_append
- insert_overwrite_by_time_partition
- update_table
- scd_type_2_by_time
- scd_type_2_by_column
- rename_table
- get_data_object
- get_data_objects
- fetchall
- fetchdf
- fetch_pyspark_df
- wap_enabled
- wap_supported
- wap_table_name
- wap_prepare
- wap_publish
- sync_grants_config
- transaction
- session
- execute
- temp_table
- adjust_physical_properties_for_incremental
- drop_data_object_on_type_mismatch
- ping
- get_table_last_modified_ts