EngineAdapter
Engine adapters are how SQLMesh connects and interacts with various data stores. They allow SQLMesh to generalize its functionality to different engines that have Python Database API 2.0-compliant connections. Rather than executing queries directly against your data stores, SQLMesh components such as the SnapshotEvaluator delegate them to engine adapters so these components can be engine-agnostic.
1""" 2# EngineAdapter 3 4Engine adapters are how SQLMesh connects and interacts with various data stores. They allow SQLMesh to 5generalize its functionality to different engines that have Python Database API 2.0-compliant 6connections. Rather than executing queries directly against your data stores, SQLMesh components such as 7the SnapshotEvaluator delegate them to engine adapters so these components can be engine-agnostic. 8""" 9 10from __future__ import annotations 11 12import contextlib 13import itertools 14import logging 15import sys 16import typing as t 17from functools import cached_property, partial 18 19from sqlglot import Dialect, exp 20from sqlglot.errors import ErrorLevel 21from sqlglot.helper import ensure_list, seq_get 22from sqlglot.optimizer.qualify_columns import quote_identifiers 23 24from sqlmesh.core.dialect import ( 25 add_table, 26 schema_, 27 select_from_values_for_batch_range, 28 to_schema, 29) 30from sqlmesh.core.engine_adapter.shared import ( 31 CatalogSupport, 32 CommentCreationTable, 33 CommentCreationView, 34 DataObject, 35 DataObjectType, 36 EngineRunMode, 37 InsertOverwriteStrategy, 38 SourceQuery, 39 set_catalog, 40) 41from sqlmesh.core.model.kind import TimeColumn 42from sqlmesh.core.schema_diff import SchemaDiffer, TableAlterOperation 43from sqlmesh.core.snapshot.execution_tracker import QueryExecutionTracker 44from sqlmesh.utils import ( 45 CorrelationId, 46 columns_to_types_all_known, 47 random_id, 48 get_source_columns_to_types, 49) 50from sqlmesh.utils.connection_pool import ConnectionPool, create_connection_pool 51from sqlmesh.utils.date import TimeLike, make_inclusive, to_time_column 52from sqlmesh.utils.errors import ( 53 MissingDefaultCatalogError, 54 SQLMeshError, 55 UnsupportedCatalogOperationError, 56) 57from sqlmesh.utils.pandas import columns_to_types_from_df 58 59if t.TYPE_CHECKING: 60 import pandas as pd 61 62 from sqlmesh.core._typing import SchemaName, SessionProperties, TableName 63 from sqlmesh.core.engine_adapter._typing import ( 64 DF, 65 BigframeSession, 66 GrantsConfig, 67 PySparkDataFrame, 68 PySparkSession, 69 Query, 70 QueryOrDF, 71 SnowparkSession, 72 ) 73 from sqlmesh.core.node import IntervalUnit 74 75logger = logging.getLogger(__name__) 76 77MERGE_TARGET_ALIAS = "__MERGE_TARGET__" 78MERGE_SOURCE_ALIAS = "__MERGE_SOURCE__" 79 80KEY_FOR_CREATABLE_TYPE = "CREATABLE_TYPE" 81 82 83@set_catalog() 84class EngineAdapter: 85 """Base class wrapping a Database API compliant connection. 86 87 The EngineAdapter is an easily-subclassable interface that interacts 88 with the underlying engine and data store. 89 90 Args: 91 connection_factory_or_pool: a callable which produces a new Database API-compliant 92 connection on every call. 93 dialect: The dialect with which this adapter is associated. 94 multithreaded: Indicates whether this adapter will be used by more than one thread. 95 """ 96 97 DIALECT = "" 98 DEFAULT_BATCH_SIZE = 10000 99 DATA_OBJECT_FILTER_BATCH_SIZE = 4000 100 SUPPORTS_TRANSACTIONS = True 101 SUPPORTS_INDEXES = False 102 COMMENT_CREATION_TABLE = CommentCreationTable.IN_SCHEMA_DEF_CTAS 103 COMMENT_CREATION_VIEW = CommentCreationView.IN_SCHEMA_DEF_AND_COMMANDS 104 MAX_TABLE_COMMENT_LENGTH: t.Optional[int] = None 105 MAX_COLUMN_COMMENT_LENGTH: t.Optional[int] = None 106 INSERT_OVERWRITE_STRATEGY = InsertOverwriteStrategy.DELETE_INSERT 107 SUPPORTS_MATERIALIZED_VIEWS = False 108 SUPPORTS_MATERIALIZED_VIEW_SCHEMA = False 109 SUPPORTS_VIEW_SCHEMA = True 110 SUPPORTS_CLONING = False 111 SUPPORTS_MANAGED_MODELS = False 112 SUPPORTS_CREATE_DROP_CATALOG = False 113 SUPPORTED_DROP_CASCADE_OBJECT_KINDS: t.List[str] = [] 114 SCHEMA_DIFFER_KWARGS: t.Dict[str, t.Any] = {} 115 SUPPORTS_TUPLE_IN = True 116 HAS_VIEW_BINDING = False 117 RECREATE_MATERIALIZED_VIEW_ON_EVALUATION = True 118 SUPPORTS_REPLACE_TABLE = True 119 SUPPORTS_GRANTS = False 120 DEFAULT_CATALOG_TYPE = DIALECT 121 QUOTE_IDENTIFIERS_IN_VIEWS = True 122 MAX_IDENTIFIER_LENGTH: t.Optional[int] = None 123 ATTACH_CORRELATION_ID = True 124 SUPPORTS_QUERY_EXECUTION_TRACKING = False 125 SUPPORTS_METADATA_TABLE_LAST_MODIFIED_TS = False 126 RESOLVE_TABLE_REFS_IN_PHYSICAL_PROPERTIES: t.FrozenSet[str] = frozenset() 127 """Physical property keys whose values may contain logical model references that 128 should be resolved to physical table names during property rendering. Engines that 129 need such resolution (e.g. StarRocks' excluded_trigger_tables) override this set.""" 130 131 def __init__( 132 self, 133 connection_factory_or_pool: t.Union[t.Callable[[], t.Any], ConnectionPool], 134 dialect: str = "", 135 sql_gen_kwargs: t.Optional[t.Dict[str, Dialect | bool | str]] = None, 136 multithreaded: bool = False, 137 cursor_init: t.Optional[t.Callable[[t.Any], None]] = None, 138 default_catalog: t.Optional[str] = None, 139 execute_log_level: int = logging.DEBUG, 140 register_comments: bool = True, 141 pre_ping: bool = False, 142 pretty_sql: bool = False, 143 shared_connection: bool = False, 144 correlation_id: t.Optional[CorrelationId] = None, 145 schema_differ_overrides: t.Optional[t.Dict[str, t.Any]] = None, 146 query_execution_tracker: t.Optional[QueryExecutionTracker] = None, 147 **kwargs: t.Any, 148 ): 149 self.dialect = dialect.lower() or self.DIALECT 150 self._connection_pool = ( 151 connection_factory_or_pool 152 if isinstance(connection_factory_or_pool, ConnectionPool) 153 else create_connection_pool( 154 connection_factory_or_pool, 155 multithreaded, 156 shared_connection=shared_connection, 157 cursor_init=cursor_init, 158 ) 159 ) 160 self._sql_gen_kwargs = sql_gen_kwargs or {} 161 self._default_catalog = default_catalog 162 self._execute_log_level = execute_log_level 163 self._extra_config = kwargs 164 self._register_comments = register_comments 165 self._pre_ping = pre_ping 166 self._pretty_sql = pretty_sql 167 self._multithreaded = multithreaded 168 self.correlation_id = correlation_id 169 self._schema_differ_overrides = schema_differ_overrides 170 self._query_execution_tracker = query_execution_tracker 171 self._data_object_cache: t.Dict[str, t.Optional[DataObject]] = {} 172 173 def with_settings(self, **kwargs: t.Any) -> EngineAdapter: 174 extra_kwargs = { 175 "null_connection": True, 176 "execute_log_level": kwargs.pop("execute_log_level", self._execute_log_level), 177 "correlation_id": kwargs.pop("correlation_id", self.correlation_id), 178 "query_execution_tracker": kwargs.pop( 179 "query_execution_tracker", self._query_execution_tracker 180 ), 181 **self._extra_config, 182 **kwargs, 183 } 184 185 adapter = self.__class__( 186 self._connection_pool, 187 dialect=self.dialect, 188 sql_gen_kwargs=self._sql_gen_kwargs, 189 default_catalog=self._default_catalog, 190 register_comments=self._register_comments, 191 multithreaded=self._multithreaded, 192 pretty_sql=self._pretty_sql, 193 **extra_kwargs, 194 ) 195 196 return adapter 197 198 @property 199 def cursor(self) -> t.Any: 200 return self._connection_pool.get_cursor() 201 202 @property 203 def connection(self) -> t.Any: 204 return self._connection_pool.get() 205 206 @property 207 def spark(self) -> t.Optional[PySparkSession]: 208 return None 209 210 @property 211 def snowpark(self) -> t.Optional[SnowparkSession]: 212 return None 213 214 @property 215 def bigframe(self) -> t.Optional[BigframeSession]: 216 return None 217 218 @property 219 def comments_enabled(self) -> bool: 220 return self._register_comments and self.COMMENT_CREATION_TABLE.is_supported 221 222 @property 223 def catalog_support(self) -> CatalogSupport: 224 return CatalogSupport.UNSUPPORTED 225 226 def supports_virtual_catalog(self) -> bool: 227 """Return True if this adapter can accept a virtual catalog for multi-gateway nesting alignment. 228 229 When a project mixes catalog-aware gateways (e.g. DuckDB) with catalog-unsupported gateways 230 (e.g. ClickHouse), all adapters need a uniform 3-level FQN so MappingSchema nesting stays 231 consistent. Adapters that return True here opt in to receiving an injected virtual catalog 232 via inject_virtual_catalog(), which causes the set_catalog decorator to strip the catalog 233 from DDL expressions rather than raising UnsupportedCatalogOperationError. 234 """ 235 return False 236 237 def inject_virtual_catalog(self, gateway: str) -> None: 238 """Inject a gateway name to configure the adapter's virtual catalog. 239 240 The adapter determines the final catalog name from the gateway name (e.g. ClickHouse 241 wraps it as __{gateway}__). Only call this on adapters that return True from 242 supports_virtual_catalog(). After injection, catalog_support should return 243 SINGLE_CATALOG_ONLY so the set_catalog decorator strips the virtual catalog from DDL 244 expressions instead of raising an error. 245 """ 246 raise NotImplementedError( 247 f"{self.dialect} does not support virtual catalog injection. " 248 "Override supports_virtual_catalog() to return True and implement inject_virtual_catalog()." 249 ) 250 251 @cached_property 252 def schema_differ(self) -> SchemaDiffer: 253 return SchemaDiffer( 254 **{ 255 **self.SCHEMA_DIFFER_KWARGS, 256 **(self._schema_differ_overrides or {}), 257 } 258 ) 259 260 @property 261 def _catalog_type_overrides(self) -> t.Dict[str, str]: 262 return self._extra_config.get("catalog_type_overrides") or {} 263 264 @classmethod 265 def _casted_columns( 266 cls, 267 target_columns_to_types: t.Dict[str, exp.DataType], 268 source_columns: t.Optional[t.List[str]] = None, 269 ) -> t.List[exp.Expr]: 270 source_columns_lookup = set(source_columns or target_columns_to_types) 271 return [ 272 exp.alias_( 273 exp.cast( 274 exp.column(column, quoted=True) 275 if column in source_columns_lookup 276 else exp.Null(), 277 to=kind, 278 ), 279 column, 280 copy=False, 281 quoted=True, 282 ) 283 for column, kind in target_columns_to_types.items() 284 ] 285 286 @property 287 def default_catalog(self) -> t.Optional[str]: 288 if self.catalog_support.is_unsupported: 289 return None 290 default_catalog = self._default_catalog or self.get_current_catalog() 291 if not default_catalog: 292 raise MissingDefaultCatalogError( 293 "Could not determine a default catalog despite it being supported." 294 ) 295 return default_catalog 296 297 @property 298 def engine_run_mode(self) -> EngineRunMode: 299 return EngineRunMode.SINGLE_MODE_ENGINE 300 301 def _get_source_queries( 302 self, 303 query_or_df: QueryOrDF, 304 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], 305 target_table: TableName, 306 *, 307 batch_size: t.Optional[int] = None, 308 source_columns: t.Optional[t.List[str]] = None, 309 ) -> t.List[SourceQuery]: 310 import pandas as pd 311 312 batch_size = self.DEFAULT_BATCH_SIZE if batch_size is None else batch_size 313 if isinstance(query_or_df, exp.Query): 314 query_factory = lambda: query_or_df 315 if source_columns: 316 source_columns_lookup = set(source_columns) 317 if not target_columns_to_types: 318 raise SQLMeshError("columns_to_types must be set if source_columns is set") 319 if not set(target_columns_to_types).issubset(source_columns_lookup): 320 select_columns = [ 321 exp.column(c, quoted=True) 322 if c in source_columns_lookup 323 else exp.cast(exp.Null(), target_columns_to_types[c], copy=False).as_( 324 c, copy=False, quoted=True 325 ) 326 for c in target_columns_to_types 327 ] 328 query_factory = lambda: ( 329 exp.Select() 330 .select(*select_columns) 331 .from_(query_or_df.subquery("select_source_columns")) 332 ) 333 return [SourceQuery(query_factory=query_factory)] # type: ignore 334 335 if not target_columns_to_types: 336 raise SQLMeshError( 337 "It is expected that if a DataFrame is passed in then columns_to_types is set" 338 ) 339 340 if isinstance(query_or_df, pd.DataFrame) and query_or_df.empty: 341 raise SQLMeshError( 342 "Cannot construct source query from an empty DataFrame. This error is commonly " 343 "related to Python models that produce no data. For such models, consider yielding " 344 "from an empty generator if the resulting set is empty, i.e. use `yield from ()`." 345 ) 346 347 return self._df_to_source_queries( 348 query_or_df, 349 target_columns_to_types, 350 batch_size, 351 target_table=target_table, 352 source_columns=source_columns, 353 ) 354 355 def _df_to_source_queries( 356 self, 357 df: DF, 358 target_columns_to_types: t.Dict[str, exp.DataType], 359 batch_size: int, 360 target_table: TableName, 361 source_columns: t.Optional[t.List[str]] = None, 362 ) -> t.List[SourceQuery]: 363 import pandas as pd 364 365 assert isinstance(df, pd.DataFrame) 366 num_rows = len(df.index) 367 batch_size = sys.maxsize if batch_size == 0 else batch_size 368 369 # we need to ensure that the order of the columns in columns_to_types columns matches the order of the values 370 # they can differ if a user specifies columns() on a python model in a different order than what's in the DataFrame's emitted by that model 371 df = df[list(source_columns or target_columns_to_types)] 372 values = list(df.itertuples(index=False, name=None)) 373 374 return [ 375 SourceQuery( 376 query_factory=partial( 377 self._values_to_sql, 378 values=values, # type: ignore 379 target_columns_to_types=target_columns_to_types, 380 batch_start=i, 381 batch_end=min(i + batch_size, num_rows), 382 source_columns=source_columns, 383 ), 384 ) 385 for i in range(0, num_rows, batch_size) 386 ] 387 388 def _get_source_queries_and_columns_to_types( 389 self, 390 query_or_df: QueryOrDF, 391 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], 392 target_table: TableName, 393 *, 394 batch_size: t.Optional[int] = None, 395 source_columns: t.Optional[t.List[str]] = None, 396 ) -> t.Tuple[t.List[SourceQuery], t.Optional[t.Dict[str, exp.DataType]]]: 397 target_columns_to_types, source_columns = self._columns_to_types( 398 query_or_df, target_columns_to_types, source_columns 399 ) 400 source_queries = self._get_source_queries( 401 query_or_df, 402 target_columns_to_types, 403 target_table=target_table, 404 batch_size=batch_size, 405 source_columns=source_columns, 406 ) 407 return source_queries, target_columns_to_types 408 409 @t.overload 410 def _columns_to_types( 411 self, 412 query_or_df: DF, 413 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 414 source_columns: t.Optional[t.List[str]] = None, 415 ) -> t.Tuple[t.Dict[str, exp.DataType], t.List[str]]: ... 416 417 @t.overload 418 def _columns_to_types( 419 self, 420 query_or_df: Query, 421 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 422 source_columns: t.Optional[t.List[str]] = None, 423 ) -> t.Tuple[t.Optional[t.Dict[str, exp.DataType]], t.Optional[t.List[str]]]: ... 424 425 def _columns_to_types( 426 self, 427 query_or_df: QueryOrDF, 428 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 429 source_columns: t.Optional[t.List[str]] = None, 430 ) -> t.Tuple[t.Optional[t.Dict[str, exp.DataType]], t.Optional[t.List[str]]]: 431 import pandas as pd 432 433 if not target_columns_to_types and isinstance(query_or_df, pd.DataFrame): 434 target_columns_to_types = columns_to_types_from_df(t.cast(pd.DataFrame, query_or_df)) 435 if not source_columns and target_columns_to_types: 436 source_columns = list(target_columns_to_types) 437 # source columns should only contain columns that are defined in the target. If there are extras then 438 # that means they are intended to be ignored and will be excluded 439 source_columns = ( 440 [x for x in source_columns if x in target_columns_to_types] 441 if source_columns and target_columns_to_types 442 else None 443 ) 444 return target_columns_to_types, source_columns 445 446 def recycle(self) -> None: 447 """Closes all open connections and releases all allocated resources associated with any thread 448 except the calling one.""" 449 self._connection_pool.close_all(exclude_calling_thread=True) 450 451 def close(self) -> t.Any: 452 """Closes all open connections and releases all allocated resources.""" 453 self._connection_pool.close_all() 454 455 def get_current_catalog(self) -> t.Optional[str]: 456 """Returns the catalog name of the current connection.""" 457 raise NotImplementedError() 458 459 def set_current_catalog(self, catalog: str) -> None: 460 """Sets the catalog name of the current connection.""" 461 raise NotImplementedError() 462 463 def get_catalog_type(self, catalog: t.Optional[str]) -> str: 464 """Intended to be overridden for data virtualization systems like Trino that, 465 depending on the target catalog, require slightly different properties to be set when creating / updating tables 466 """ 467 if self.catalog_support.is_unsupported: 468 raise UnsupportedCatalogOperationError( 469 f"{self.dialect} does not support catalogs and a catalog was provided: {catalog}" 470 ) 471 return ( 472 self._catalog_type_overrides.get(catalog, self.DEFAULT_CATALOG_TYPE) 473 if catalog 474 else self.DEFAULT_CATALOG_TYPE 475 ) 476 477 def get_catalog_type_from_table(self, table: TableName) -> str: 478 """Get the catalog type from a table name if it has a catalog specified, otherwise return the current catalog type""" 479 catalog = exp.to_table(table).catalog or self.get_current_catalog() 480 return self.get_catalog_type(catalog) 481 482 @property 483 def current_catalog_type(self) -> str: 484 # `get_catalog_type_from_table` should be used over this property. Reason is that the table that is the target 485 # of the operation is what matters and not the catalog type of the connection. 486 # This still remains for legacy reasons and should be refactored out. 487 return self.get_catalog_type(self.get_current_catalog()) 488 489 def replace_query( 490 self, 491 table_name: TableName, 492 query_or_df: QueryOrDF, 493 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 494 table_description: t.Optional[str] = None, 495 column_descriptions: t.Optional[t.Dict[str, str]] = None, 496 source_columns: t.Optional[t.List[str]] = None, 497 supports_replace_table_override: t.Optional[bool] = None, 498 **kwargs: t.Any, 499 ) -> None: 500 """Replaces an existing table with a query. 501 502 For partition based engines (hive, spark), insert override is used. For other systems, create or replace is used. 503 504 Args: 505 table_name: The name of the table (eg. prod.table) 506 query_or_df: The SQL query to run or a dataframe. 507 target_columns_to_types: Only used if a dataframe is provided. A mapping between the column name and its data type. 508 Expected to be ordered to match the order of values in the dataframe. 509 kwargs: Optional create table properties. 510 """ 511 target_table = exp.to_table(table_name) 512 513 target_data_object = self.get_data_object(target_table) 514 table_exists = target_data_object is not None 515 if self.drop_data_object_on_type_mismatch(target_data_object, DataObjectType.TABLE): 516 table_exists = False 517 518 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 519 query_or_df, 520 target_columns_to_types, 521 target_table=target_table, 522 source_columns=source_columns, 523 ) 524 if not target_columns_to_types and table_exists: 525 target_columns_to_types = self.columns(target_table) 526 query = source_queries[0].query_factory() 527 self_referencing = any( 528 quote_identifiers(table) == quote_identifiers(target_table) 529 for table in query.find_all(exp.Table) 530 ) 531 # If a query references itself then it must have a table created regardless of approach used. 532 if self_referencing: 533 if not target_columns_to_types: 534 raise SQLMeshError( 535 f"Cannot create a self-referencing table {target_table.sql(dialect=self.dialect)} without knowing the column types. " 536 "Try casting the columns to an expected type or defining the columns in the model metadata. " 537 ) 538 self._create_table_from_columns( 539 target_table, 540 target_columns_to_types, 541 exists=True, 542 table_description=table_description, 543 column_descriptions=column_descriptions, 544 **kwargs, 545 ) 546 # All engines support `CREATE TABLE AS` so we use that if the table doesn't already exist and we 547 # use `CREATE OR REPLACE TABLE AS` if the engine supports it 548 supports_replace_table = ( 549 self.SUPPORTS_REPLACE_TABLE 550 if supports_replace_table_override is None 551 else supports_replace_table_override 552 ) 553 if supports_replace_table or not table_exists: 554 return self._create_table_from_source_queries( 555 target_table, 556 source_queries, 557 target_columns_to_types, 558 replace=supports_replace_table, 559 table_description=table_description, 560 column_descriptions=column_descriptions, 561 **kwargs, 562 ) 563 if self_referencing: 564 assert target_columns_to_types is not None 565 with self.temp_table( 566 self._select_columns(target_columns_to_types).from_(target_table), 567 name=target_table, 568 target_columns_to_types=target_columns_to_types, 569 **kwargs, 570 ) as temp_table: 571 for source_query in source_queries: 572 source_query.add_transform( 573 lambda node: ( # type: ignore 574 temp_table # type: ignore 575 if isinstance(node, exp.Table) 576 and quote_identifiers(node) == quote_identifiers(target_table) 577 else node 578 ) 579 ) 580 return self._insert_overwrite_by_condition( 581 target_table, 582 source_queries, 583 target_columns_to_types, 584 **kwargs, 585 ) 586 return self._insert_overwrite_by_condition( 587 target_table, 588 source_queries, 589 target_columns_to_types, 590 **kwargs, 591 ) 592 593 def create_index( 594 self, 595 table_name: TableName, 596 index_name: str, 597 columns: t.Tuple[str, ...], 598 exists: bool = True, 599 ) -> None: 600 """Creates a new index for the given table if supported 601 602 Args: 603 table_name: The name of the target table. 604 index_name: The name of the index. 605 columns: The list of columns that constitute the index. 606 exists: Indicates whether to include the IF NOT EXISTS check. 607 """ 608 if not self.SUPPORTS_INDEXES: 609 return 610 611 expression = exp.Create( 612 this=exp.Index( 613 this=exp.to_identifier(index_name), 614 table=exp.to_table(table_name), 615 params=exp.IndexParameters(columns=[exp.to_column(c) for c in columns]), 616 ), 617 kind="INDEX", 618 exists=exists, 619 ) 620 self.execute(expression) 621 622 def _pop_creatable_type_from_properties( 623 self, 624 properties: t.Dict[str, exp.Expr], 625 ) -> t.Optional[exp.Property]: 626 """Pop out the creatable_type from the properties dictionary (if exists (return it/remove it) else return none). 627 It also checks that none of the expressions are MATERIALIZE as that conflicts with the `materialize` parameter. 628 """ 629 for key in list(properties.keys()): 630 upper_key = key.upper() 631 if upper_key == KEY_FOR_CREATABLE_TYPE: 632 value = properties.pop(key).name 633 parsed_properties = exp.maybe_parse( 634 value, into=exp.Properties, dialect=self.dialect 635 ) 636 property, *others = parsed_properties.expressions 637 if others: 638 # Multiple properties are unsupported today, can look into it in the future if needed 639 raise SQLMeshError( 640 f"Invalid creatable_type value with multiple properties: {value}" 641 ) 642 if isinstance(property, exp.MaterializedProperty): 643 raise SQLMeshError( 644 f"Cannot use {value} as a creatable_type as it conflicts with the `materialize` parameter." 645 ) 646 return property 647 return None 648 649 def create_table( 650 self, 651 table_name: TableName, 652 target_columns_to_types: t.Dict[str, exp.DataType], 653 primary_key: t.Optional[t.Tuple[str, ...]] = None, 654 exists: bool = True, 655 table_description: t.Optional[str] = None, 656 column_descriptions: t.Optional[t.Dict[str, str]] = None, 657 **kwargs: t.Any, 658 ) -> None: 659 """Create a table using a DDL statement 660 661 Args: 662 table_name: The name of the table to create. Can be fully qualified or just table name. 663 target_columns_to_types: A mapping between the column name and its data type. 664 primary_key: Determines the table primary key. 665 exists: Indicates whether to include the IF NOT EXISTS check. 666 table_description: Optional table description from MODEL DDL. 667 column_descriptions: Optional column descriptions from model query. 668 kwargs: Optional create table properties. 669 """ 670 self._create_table_from_columns( 671 table_name, 672 target_columns_to_types, 673 primary_key, 674 exists, 675 table_description, 676 column_descriptions, 677 **kwargs, 678 ) 679 680 def create_managed_table( 681 self, 682 table_name: TableName, 683 query: Query, 684 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 685 partitioned_by: t.Optional[t.List[exp.Expr]] = None, 686 clustered_by: t.Optional[t.List[exp.Expr]] = None, 687 table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, 688 table_description: t.Optional[str] = None, 689 column_descriptions: t.Optional[t.Dict[str, str]] = None, 690 source_columns: t.Optional[t.List[str]] = None, 691 **kwargs: t.Any, 692 ) -> None: 693 """Create a managed table using a query. 694 695 "Managed" means that once the table is created, the data is kept up to date by the underlying database engine and not SQLMesh. 696 697 Args: 698 table_name: The name of the table to create. Can be fully qualified or just table name. 699 query: The SQL query for the engine to base the managed table on 700 target_columns_to_types: A mapping between the column name and its data type. 701 partitioned_by: The partition columns or engine specific expressions, only applicable in certain engines. (eg. (ds, hour)) 702 clustered_by: The cluster columns or engine specific expressions, only applicable in certain engines. (eg. (ds, hour)) 703 table_properties: Optional mapping of engine-specific properties to be set on the managed table 704 table_description: Optional table description from MODEL DDL. 705 column_descriptions: Optional column descriptions from model query. 706 kwargs: Optional create table properties. 707 """ 708 raise NotImplementedError(f"Engine does not support managed tables: {type(self)}") 709 710 def ctas( 711 self, 712 table_name: TableName, 713 query_or_df: QueryOrDF, 714 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 715 exists: bool = True, 716 table_description: t.Optional[str] = None, 717 column_descriptions: t.Optional[t.Dict[str, str]] = None, 718 source_columns: t.Optional[t.List[str]] = None, 719 **kwargs: t.Any, 720 ) -> None: 721 """Create a table using a CTAS statement 722 723 Args: 724 table_name: The name of the table to create. Can be fully qualified or just table name. 725 query_or_df: The SQL query to run or a dataframe for the CTAS. 726 target_columns_to_types: A mapping between the column name and its data type. Required if using a DataFrame. 727 exists: Indicates whether to include the IF NOT EXISTS check. 728 table_description: Optional table description from MODEL DDL. 729 column_descriptions: Optional column descriptions from model query. 730 kwargs: Optional create table properties. 731 """ 732 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 733 query_or_df, 734 target_columns_to_types, 735 target_table=table_name, 736 source_columns=source_columns, 737 ) 738 return self._create_table_from_source_queries( 739 table_name, 740 source_queries, 741 target_columns_to_types, 742 exists, 743 table_description=table_description, 744 column_descriptions=column_descriptions, 745 **kwargs, 746 ) 747 748 def create_state_table( 749 self, 750 table_name: str, 751 target_columns_to_types: t.Dict[str, exp.DataType], 752 primary_key: t.Optional[t.Tuple[str, ...]] = None, 753 ) -> None: 754 """Create a table to store SQLMesh internal state. 755 756 Args: 757 table_name: The name of the table to create. Can be fully qualified or just table name. 758 target_columns_to_types: A mapping between the column name and its data type. 759 primary_key: Determines the table primary key. 760 """ 761 self.create_table( 762 table_name, 763 target_columns_to_types, 764 primary_key=primary_key, 765 ) 766 767 def _create_table_from_columns( 768 self, 769 table_name: TableName, 770 target_columns_to_types: t.Dict[str, exp.DataType], 771 primary_key: t.Optional[t.Tuple[str, ...]] = None, 772 exists: bool = True, 773 table_description: t.Optional[str] = None, 774 column_descriptions: t.Optional[t.Dict[str, str]] = None, 775 **kwargs: t.Any, 776 ) -> None: 777 """ 778 Create a table using a DDL statement. 779 780 Args: 781 table_name: The name of the table to create. Can be fully qualified or just table name. 782 target_columns_to_types: Mapping between the column name and its data type. 783 primary_key: Determines the table primary key. 784 exists: Indicates whether to include the IF NOT EXISTS check. 785 table_description: Optional table description from MODEL DDL. 786 column_descriptions: Optional column descriptions from model query. 787 kwargs: Optional create table properties. 788 """ 789 table = exp.to_table(table_name) 790 791 if not columns_to_types_all_known(target_columns_to_types): 792 # It is ok if the columns types are not known if the table already exists and IF NOT EXISTS is set 793 if exists and self.table_exists(table_name): 794 return 795 raise SQLMeshError( 796 "Cannot create a table without knowing the column types. " 797 "Try casting the columns to an expected type or defining the columns in the model metadata. " 798 f"Columns to types: {target_columns_to_types}" 799 ) 800 801 primary_key_expression = ( 802 [exp.PrimaryKey(expressions=[exp.to_column(k) for k in primary_key])] 803 if primary_key and self.SUPPORTS_INDEXES 804 else [] 805 ) 806 807 schema = self._build_schema_exp( 808 table, 809 target_columns_to_types, 810 column_descriptions, 811 primary_key_expression, 812 ) 813 814 self._create_table( 815 schema, 816 None, 817 exists=exists, 818 target_columns_to_types=target_columns_to_types, 819 table_description=table_description, 820 **kwargs, 821 ) 822 823 # Register comments with commands if the engine doesn't support comments in the schema or CREATE 824 if ( 825 table_description 826 and self.COMMENT_CREATION_TABLE.is_comment_command_only 827 and self.comments_enabled 828 ): 829 self._create_table_comment(table_name, table_description) 830 if ( 831 column_descriptions 832 and self.COMMENT_CREATION_TABLE.is_comment_command_only 833 and self.comments_enabled 834 ): 835 self._create_column_comments(table_name, column_descriptions) 836 837 def _build_schema_exp( 838 self, 839 table: exp.Table, 840 target_columns_to_types: t.Dict[str, exp.DataType], 841 column_descriptions: t.Optional[t.Dict[str, str]] = None, 842 expressions: t.Optional[t.List[exp.PrimaryKey]] = None, 843 is_view: bool = False, 844 materialized: bool = False, 845 ) -> exp.Schema: 846 """ 847 Build a schema expression for a table, columns, column comments, and additional schema properties. 848 """ 849 expressions = expressions or [] 850 851 return exp.Schema( 852 this=table, 853 expressions=self._build_column_defs( 854 target_columns_to_types=target_columns_to_types, 855 column_descriptions=column_descriptions, 856 is_view=is_view, 857 materialized=materialized, 858 ) 859 + expressions, 860 ) 861 862 def _build_column_defs( 863 self, 864 target_columns_to_types: t.Dict[str, exp.DataType], 865 column_descriptions: t.Optional[t.Dict[str, str]] = None, 866 is_view: bool = False, 867 materialized: bool = False, 868 ) -> t.List[exp.ColumnDef]: 869 engine_supports_schema_comments = ( 870 self.COMMENT_CREATION_VIEW.supports_schema_def 871 if is_view 872 else self.COMMENT_CREATION_TABLE.supports_schema_def 873 ) 874 return [ 875 self._build_column_def( 876 column, 877 column_descriptions=column_descriptions, 878 engine_supports_schema_comments=engine_supports_schema_comments, 879 col_type=None if is_view else kind, # don't include column data type for views 880 ) 881 for column, kind in target_columns_to_types.items() 882 ] 883 884 def _build_column_def( 885 self, 886 col_name: str, 887 column_descriptions: t.Optional[t.Dict[str, str]] = None, 888 engine_supports_schema_comments: bool = False, 889 col_type: t.Optional[exp.DATA_TYPE] = None, 890 nested_names: t.List[str] = [], 891 ) -> exp.ColumnDef: 892 return exp.ColumnDef( 893 this=exp.to_identifier(col_name), 894 kind=col_type, 895 constraints=( 896 self._build_col_comment_exp(col_name, column_descriptions) 897 if engine_supports_schema_comments and self.comments_enabled and column_descriptions 898 else None 899 ), 900 ) 901 902 def _build_col_comment_exp( 903 self, col_name: str, column_descriptions: t.Dict[str, str] 904 ) -> t.List[exp.ColumnConstraint]: 905 comment = column_descriptions.get(col_name, None) 906 if comment: 907 return [ 908 exp.ColumnConstraint( 909 kind=exp.CommentColumnConstraint( 910 this=exp.Literal.string(self._truncate_column_comment(comment)) 911 ) 912 ) 913 ] 914 return [] 915 916 def _create_table_from_source_queries( 917 self, 918 table_name: TableName, 919 source_queries: t.List[SourceQuery], 920 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 921 exists: bool = True, 922 replace: bool = False, 923 table_description: t.Optional[str] = None, 924 column_descriptions: t.Optional[t.Dict[str, str]] = None, 925 table_kind: t.Optional[str] = None, 926 track_rows_processed: bool = True, 927 **kwargs: t.Any, 928 ) -> None: 929 table = exp.to_table(table_name) 930 931 # CTAS calls do not usually include a schema expression. However, most engines 932 # permit them in CTAS expressions, and they allow us to register all column comments 933 # in a single call rather than in a separate comment command call for each column. 934 # 935 # This block conditionally builds a schema expression with column comments if the engine 936 # supports it and we have columns_to_types. column_to_types is required because the 937 # schema expression must include at least column name, data type, and the comment - 938 # for example, `(colname INTEGER COMMENT 'comment')`. 939 # 940 # column_to_types will be available when loading from a DataFrame (by converting from 941 # pandas to SQL types), when a model is "annotated" by explicitly specifying column 942 # types, and for evaluation methods like `LogicalReplaceQueryMixin.replace_query()` 943 # calls and SCD Type 2 model calls. 944 schema = None 945 target_columns_to_types_known = target_columns_to_types and columns_to_types_all_known( 946 target_columns_to_types 947 ) 948 if ( 949 column_descriptions 950 and target_columns_to_types_known 951 and self.COMMENT_CREATION_TABLE.is_in_schema_def_ctas 952 and self.comments_enabled 953 ): 954 schema = self._build_schema_exp(table, target_columns_to_types, column_descriptions) # type: ignore 955 956 with self.transaction(condition=len(source_queries) > 1): 957 for i, source_query in enumerate(source_queries): 958 with source_query as query: 959 if target_columns_to_types and target_columns_to_types_known: 960 query = self._order_projections_and_filter( 961 query, target_columns_to_types, coerce_types=True 962 ) 963 if i == 0: 964 self._create_table( 965 schema if schema else table, 966 query, 967 target_columns_to_types=target_columns_to_types, 968 exists=exists, 969 replace=replace, 970 table_description=table_description, 971 table_kind=table_kind, 972 track_rows_processed=track_rows_processed, 973 **kwargs, 974 ) 975 else: 976 self._insert_append_query( 977 table_name, 978 query, 979 target_columns_to_types or self.columns(table), 980 track_rows_processed=track_rows_processed, 981 ) 982 983 # Register comments with commands if the engine supports comments and we weren't able to 984 # register them with the CTAS call's schema expression. 985 if ( 986 table_description 987 and self.COMMENT_CREATION_TABLE.is_comment_command_only 988 and self.comments_enabled 989 ): 990 self._create_table_comment(table_name, table_description) 991 if column_descriptions and schema is None and self.comments_enabled: 992 self._create_column_comments(table_name, column_descriptions) 993 994 def _create_table( 995 self, 996 table_name_or_schema: t.Union[exp.Schema, TableName], 997 expression: t.Optional[exp.Expr], 998 exists: bool = True, 999 replace: bool = False, 1000 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1001 table_description: t.Optional[str] = None, 1002 column_descriptions: t.Optional[t.Dict[str, str]] = None, 1003 table_kind: t.Optional[str] = None, 1004 track_rows_processed: bool = True, 1005 **kwargs: t.Any, 1006 ) -> None: 1007 self.execute( 1008 self._build_create_table_exp( 1009 table_name_or_schema, 1010 expression=expression, 1011 exists=exists, 1012 replace=replace, 1013 target_columns_to_types=target_columns_to_types, 1014 table_description=( 1015 table_description 1016 if self.COMMENT_CREATION_TABLE.supports_schema_def and self.comments_enabled 1017 else None 1018 ), 1019 table_kind=table_kind, 1020 **kwargs, 1021 ), 1022 track_rows_processed=track_rows_processed, 1023 ) 1024 # Extract table name to clear cache 1025 table_name = ( 1026 table_name_or_schema.this 1027 if isinstance(table_name_or_schema, exp.Schema) 1028 else table_name_or_schema 1029 ) 1030 self._clear_data_object_cache(table_name) 1031 1032 def _build_create_table_exp( 1033 self, 1034 table_name_or_schema: t.Union[exp.Schema, TableName], 1035 expression: t.Optional[exp.Expr], 1036 exists: bool = True, 1037 replace: bool = False, 1038 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1039 table_description: t.Optional[str] = None, 1040 table_kind: t.Optional[str] = None, 1041 **kwargs: t.Any, 1042 ) -> exp.Create: 1043 exists = False if replace else exists 1044 catalog_name = None 1045 if not isinstance(table_name_or_schema, exp.Schema): 1046 table_name_or_schema = exp.to_table(table_name_or_schema) 1047 catalog_name = table_name_or_schema.catalog 1048 else: 1049 if isinstance(table_name_or_schema.this, exp.Table): 1050 catalog_name = table_name_or_schema.this.catalog 1051 1052 properties = ( 1053 self._build_table_properties_exp( 1054 **kwargs, 1055 catalog_name=catalog_name, 1056 target_columns_to_types=target_columns_to_types, 1057 table_description=table_description, 1058 table_kind=table_kind, 1059 ) 1060 if kwargs or table_description 1061 else None 1062 ) 1063 return exp.Create( 1064 this=table_name_or_schema, 1065 kind=table_kind or "TABLE", 1066 replace=replace, 1067 exists=exists, 1068 expression=expression, 1069 properties=properties, 1070 ) 1071 1072 def create_table_like( 1073 self, 1074 target_table_name: TableName, 1075 source_table_name: TableName, 1076 exists: bool = True, 1077 **kwargs: t.Any, 1078 ) -> None: 1079 """Create a table to store SQLMesh internal state based on the definition of another table, including any 1080 column attributes and indexes defined in the original table. 1081 1082 Args: 1083 target_table_name: The name of the table to create. Can be fully qualified or just table name. 1084 source_table_name: The name of the table to base the new table on. 1085 """ 1086 self._create_table_like(target_table_name, source_table_name, exists=exists, **kwargs) 1087 self._clear_data_object_cache(target_table_name) 1088 1089 def clone_table( 1090 self, 1091 target_table_name: TableName, 1092 source_table_name: TableName, 1093 replace: bool = False, 1094 exists: bool = True, 1095 clone_kwargs: t.Optional[t.Dict[str, t.Any]] = None, 1096 **kwargs: t.Any, 1097 ) -> None: 1098 """Creates a table with the target name by cloning the source table. 1099 1100 Args: 1101 target_table_name: The name of the table that should be created. 1102 source_table_name: The name of the source table that should be cloned. 1103 replace: Whether or not to replace an existing table. 1104 exists: Indicates whether to include the IF NOT EXISTS check. 1105 """ 1106 if not self.SUPPORTS_CLONING: 1107 raise NotImplementedError(f"Engine does not support cloning: {type(self)}") 1108 1109 kwargs.pop("rendered_physical_properties", None) 1110 self.execute( 1111 exp.Create( 1112 this=exp.to_table(target_table_name), 1113 kind="TABLE", 1114 replace=replace, 1115 exists=exists, 1116 clone=exp.Clone( 1117 this=exp.to_table(source_table_name), 1118 **(clone_kwargs or {}), 1119 ), 1120 **kwargs, 1121 ) 1122 ) 1123 self._clear_data_object_cache(target_table_name) 1124 1125 def drop_data_object(self, data_object: DataObject, ignore_if_not_exists: bool = True) -> None: 1126 """Drops a data object of arbitrary type. 1127 1128 Args: 1129 data_object: The data object to drop. 1130 ignore_if_not_exists: If True, no error will be raised if the data object does not exist. 1131 """ 1132 if data_object.type.is_view: 1133 self.drop_view(data_object.to_table(), ignore_if_not_exists=ignore_if_not_exists) 1134 elif data_object.type.is_materialized_view: 1135 self.drop_view( 1136 data_object.to_table(), ignore_if_not_exists=ignore_if_not_exists, materialized=True 1137 ) 1138 elif data_object.type.is_table: 1139 self.drop_table(data_object.to_table(), exists=ignore_if_not_exists) 1140 elif data_object.type.is_managed_table: 1141 self.drop_managed_table(data_object.to_table(), exists=ignore_if_not_exists) 1142 else: 1143 raise SQLMeshError( 1144 f"Can't drop data object '{data_object.to_table().sql(dialect=self.dialect)}' of type '{data_object.type.value}'" 1145 ) 1146 1147 def drop_table(self, table_name: TableName, exists: bool = True, **kwargs: t.Any) -> None: 1148 """Drops a table. 1149 1150 Args: 1151 table_name: The name of the table to drop. 1152 exists: If exists, defaults to True. 1153 """ 1154 self._drop_object(name=table_name, exists=exists, **kwargs) 1155 1156 def drop_managed_table(self, table_name: TableName, exists: bool = True) -> None: 1157 """Drops a managed table. 1158 1159 Args: 1160 table_name: The name of the table to drop. 1161 exists: If exists, defaults to True. 1162 """ 1163 raise NotImplementedError(f"Engine does not support managed tables: {type(self)}") 1164 1165 def _drop_object( 1166 self, 1167 name: TableName | SchemaName, 1168 exists: bool = True, 1169 kind: str = "TABLE", 1170 cascade: bool = False, 1171 **drop_args: t.Any, 1172 ) -> None: 1173 """Drops an object. 1174 1175 An object could be a DATABASE, SCHEMA, VIEW, TABLE, DYNAMIC TABLE, TEMPORARY TABLE etc depending on the :kind. 1176 1177 Args: 1178 name: The name of the table to drop. 1179 exists: If exists, defaults to True. 1180 kind: What kind of object to drop. Defaults to TABLE 1181 cascade: Whether or not to DROP ... CASCADE. 1182 Note that this is ignored for :kind's that are not present in self.SUPPORTED_DROP_CASCADE_OBJECT_KINDS 1183 **drop_args: Any extra arguments to set on the Drop expression 1184 """ 1185 if cascade and kind.upper() in self.SUPPORTED_DROP_CASCADE_OBJECT_KINDS: 1186 drop_args["cascade"] = cascade 1187 1188 self.execute(exp.Drop(this=exp.to_table(name), kind=kind, exists=exists, **drop_args)) 1189 self._clear_data_object_cache(name) 1190 1191 def get_alter_operations( 1192 self, 1193 current_table_name: TableName, 1194 target_table_name: TableName, 1195 *, 1196 ignore_destructive: bool = False, 1197 ignore_additive: bool = False, 1198 ) -> t.List[TableAlterOperation]: 1199 """ 1200 Determines the alter statements needed to change the current table into the structure of the target table. 1201 """ 1202 return t.cast( 1203 t.List[TableAlterOperation], 1204 self.schema_differ.compare_columns( 1205 current_table_name, 1206 self.columns(current_table_name), 1207 self.columns(target_table_name), 1208 ignore_destructive=ignore_destructive, 1209 ignore_additive=ignore_additive, 1210 ), 1211 ) 1212 1213 def alter_table( 1214 self, 1215 alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]], 1216 ) -> None: 1217 """ 1218 Performs the alter statements to change the current table into the structure of the target table. 1219 """ 1220 with self.transaction(): 1221 for alter_expression in [ 1222 x.expression if isinstance(x, TableAlterOperation) else x for x in alter_expressions 1223 ]: 1224 self.execute(alter_expression) 1225 1226 def create_view( 1227 self, 1228 view_name: TableName, 1229 query_or_df: QueryOrDF, 1230 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1231 replace: bool = True, 1232 materialized: bool = False, 1233 materialized_properties: t.Optional[t.Dict[str, t.Any]] = None, 1234 table_description: t.Optional[str] = None, 1235 column_descriptions: t.Optional[t.Dict[str, str]] = None, 1236 view_properties: t.Optional[t.Dict[str, exp.Expr]] = None, 1237 source_columns: t.Optional[t.List[str]] = None, 1238 **create_kwargs: t.Any, 1239 ) -> None: 1240 """Create a view with a query or dataframe. 1241 1242 If a dataframe is passed in, it will be converted into a literal values statement. 1243 This should only be done if the dataframe is very small! 1244 1245 Args: 1246 view_name: The view name. 1247 query_or_df: A query or dataframe. 1248 target_columns_to_types: Columns to use in the view statement. 1249 replace: Whether or not to replace an existing view defaults to True. 1250 materialized: Whether to create a a materialized view. Only used for engines that support this feature. 1251 materialized_properties: Optional materialized view properties to add to the view. 1252 table_description: Optional table description from MODEL DDL. 1253 column_descriptions: Optional column descriptions from model query. 1254 view_properties: Optional view properties to add to the view. 1255 create_kwargs: Additional kwargs to pass into the Create expression 1256 """ 1257 import pandas as pd 1258 1259 if materialized_properties and not materialized: 1260 raise SQLMeshError("Materialized properties are only supported for materialized views") 1261 1262 query_or_df = self._native_df_to_pandas_df(query_or_df) 1263 1264 if isinstance(query_or_df, pd.DataFrame): 1265 values: t.List[t.Tuple[t.Any, ...]] = list( 1266 query_or_df.itertuples(index=False, name=None) 1267 ) 1268 target_columns_to_types, source_columns = self._columns_to_types( 1269 query_or_df, target_columns_to_types, source_columns 1270 ) 1271 if not target_columns_to_types: 1272 raise SQLMeshError("columns_to_types must be provided for dataframes") 1273 source_columns_to_types = get_source_columns_to_types( 1274 target_columns_to_types, source_columns 1275 ) 1276 query_or_df = self._values_to_sql( 1277 values, 1278 source_columns_to_types, 1279 batch_start=0, 1280 batch_end=len(values), 1281 ) 1282 1283 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 1284 query_or_df, 1285 target_columns_to_types, 1286 batch_size=0, 1287 target_table=view_name, 1288 source_columns=source_columns, 1289 ) 1290 if len(source_queries) != 1: 1291 raise SQLMeshError("Only one source query is supported for creating views") 1292 1293 schema: t.Union[exp.Table, exp.Schema] = exp.to_table(view_name) 1294 if target_columns_to_types: 1295 schema = self._build_schema_exp( 1296 exp.to_table(view_name), 1297 target_columns_to_types, 1298 column_descriptions, 1299 is_view=True, 1300 materialized=materialized, 1301 ) 1302 1303 properties = create_kwargs.pop("properties", None) 1304 if not properties: 1305 properties = exp.Properties(expressions=[]) 1306 1307 if view_properties: 1308 table_type = self._pop_creatable_type_from_properties(view_properties) 1309 if table_type: 1310 properties.append("expressions", table_type) 1311 1312 if materialized and self.SUPPORTS_MATERIALIZED_VIEWS: 1313 properties.append("expressions", exp.MaterializedProperty()) 1314 1315 if not self.SUPPORTS_MATERIALIZED_VIEW_SCHEMA and isinstance(schema, exp.Schema): 1316 schema = schema.this 1317 1318 if not self.SUPPORTS_VIEW_SCHEMA and isinstance(schema, exp.Schema): 1319 schema = schema.this 1320 1321 if materialized_properties: 1322 partitioned_by = materialized_properties.pop("partitioned_by", None) 1323 clustered_by = materialized_properties.pop("clustered_by", None) 1324 if ( 1325 partitioned_by 1326 and ( 1327 partitioned_by_prop := self._build_partitioned_by_exp( 1328 partitioned_by, **materialized_properties 1329 ) 1330 ) 1331 is not None 1332 ): 1333 materialized_properties["catalog_name"] = exp.to_table(view_name).catalog 1334 properties.append("expressions", partitioned_by_prop) 1335 if ( 1336 clustered_by 1337 and ( 1338 clustered_by_prop := self._build_clustered_by_exp( 1339 clustered_by, **materialized_properties 1340 ) 1341 ) 1342 is not None 1343 ): 1344 properties.append("expressions", clustered_by_prop) 1345 1346 create_view_properties = self._build_view_properties_exp( 1347 view_properties, 1348 ( 1349 table_description 1350 if self.COMMENT_CREATION_VIEW.supports_schema_def and self.comments_enabled 1351 else None 1352 ), 1353 physical_cluster=create_kwargs.pop("physical_cluster", None), 1354 ) 1355 if create_view_properties: 1356 for view_property in create_view_properties.expressions: 1357 # Small hack to make sure SECURE goes at the beginning before materialized as required by Snowflake 1358 if isinstance(view_property, exp.SecureProperty): 1359 properties.set("expressions", view_property, index=0, overwrite=False) 1360 else: 1361 properties.append("expressions", view_property) 1362 1363 if properties.expressions: 1364 create_kwargs["properties"] = properties 1365 1366 if replace: 1367 self.drop_data_object_on_type_mismatch( 1368 self.get_data_object(view_name), 1369 DataObjectType.VIEW if not materialized else DataObjectType.MATERIALIZED_VIEW, 1370 ) 1371 1372 with source_queries[0] as query: 1373 self.execute( 1374 exp.Create( 1375 this=schema, 1376 kind="VIEW", 1377 replace=replace, 1378 expression=query, 1379 **create_kwargs, 1380 ), 1381 quote_identifiers=self.QUOTE_IDENTIFIERS_IN_VIEWS, 1382 ) 1383 1384 self._clear_data_object_cache(view_name) 1385 1386 # Register table comment with commands if the engine doesn't support doing it in CREATE 1387 if ( 1388 table_description 1389 and self.COMMENT_CREATION_VIEW.is_comment_command_only 1390 and self.comments_enabled 1391 ): 1392 self._create_table_comment(view_name, table_description, "VIEW") 1393 # Register column comments with commands if the engine doesn't support doing it in 1394 # CREATE or we couldn't do it in the CREATE schema definition because we don't have 1395 # columns_to_types 1396 if ( 1397 column_descriptions 1398 and ( 1399 self.COMMENT_CREATION_VIEW.is_comment_command_only 1400 or ( 1401 self.COMMENT_CREATION_VIEW.is_in_schema_def_and_commands 1402 and not target_columns_to_types 1403 ) 1404 ) 1405 and self.comments_enabled 1406 ): 1407 self._create_column_comments(view_name, column_descriptions, "VIEW", materialized) 1408 1409 @set_catalog() 1410 def create_schema( 1411 self, 1412 schema_name: SchemaName, 1413 ignore_if_exists: bool = True, 1414 warn_on_error: bool = True, 1415 properties: t.Optional[t.List[exp.Expr]] = None, 1416 ) -> None: 1417 properties = properties or [] 1418 return self._create_schema( 1419 schema_name=schema_name, 1420 ignore_if_exists=ignore_if_exists, 1421 warn_on_error=warn_on_error, 1422 properties=properties, 1423 kind="SCHEMA", 1424 ) 1425 1426 def _create_schema( 1427 self, 1428 schema_name: SchemaName, 1429 ignore_if_exists: bool, 1430 warn_on_error: bool, 1431 properties: t.List[exp.Expr], 1432 kind: str, 1433 ) -> None: 1434 """Create a schema from a name or qualified table name.""" 1435 try: 1436 self.execute( 1437 exp.Create( 1438 this=to_schema(schema_name), 1439 kind=kind, 1440 exists=ignore_if_exists, 1441 properties=exp.Properties( # this renders as '' (empty string) if expressions is empty 1442 expressions=properties 1443 ), 1444 ) 1445 ) 1446 except Exception as e: 1447 if not warn_on_error: 1448 raise 1449 logger.warning("Failed to create %s '%s': %s", kind.lower(), schema_name, e) 1450 1451 def drop_schema( 1452 self, 1453 schema_name: SchemaName, 1454 ignore_if_not_exists: bool = True, 1455 cascade: bool = False, 1456 **drop_args: t.Dict[str, exp.Expr], 1457 ) -> None: 1458 return self._drop_object( 1459 name=schema_name, 1460 exists=ignore_if_not_exists, 1461 kind="SCHEMA", 1462 cascade=cascade, 1463 **drop_args, 1464 ) 1465 1466 def drop_view( 1467 self, 1468 view_name: TableName, 1469 ignore_if_not_exists: bool = True, 1470 materialized: bool = False, 1471 **kwargs: t.Any, 1472 ) -> None: 1473 """Drop a view.""" 1474 self._drop_object( 1475 name=view_name, 1476 exists=ignore_if_not_exists, 1477 kind="VIEW", 1478 materialized=materialized and self.SUPPORTS_MATERIALIZED_VIEWS, 1479 **kwargs, 1480 ) 1481 1482 def create_catalog(self, catalog_name: str | exp.Identifier) -> None: 1483 return self._create_catalog(exp.parse_identifier(catalog_name, dialect=self.dialect)) 1484 1485 def _create_catalog(self, catalog_name: exp.Identifier) -> None: 1486 raise SQLMeshError( 1487 f"Unable to create catalog '{catalog_name.sql(dialect=self.dialect)}' as automatic catalog management is not implemented in the {self.dialect} engine." 1488 ) 1489 1490 def drop_catalog(self, catalog_name: str | exp.Identifier) -> None: 1491 return self._drop_catalog(exp.parse_identifier(catalog_name, dialect=self.dialect)) 1492 1493 def _drop_catalog(self, catalog_name: exp.Identifier) -> None: 1494 raise SQLMeshError( 1495 f"Unable to drop catalog '{catalog_name.sql(dialect=self.dialect)}' as automatic catalog management is not implemented in the {self.dialect} engine." 1496 ) 1497 1498 def columns( 1499 self, table_name: TableName, include_pseudo_columns: bool = False 1500 ) -> t.Dict[str, exp.DataType]: 1501 """Fetches column names and types for the target table.""" 1502 self.execute(exp.Describe(this=exp.to_table(table_name), kind="TABLE")) 1503 describe_output = self.cursor.fetchall() 1504 return { 1505 # Note: MySQL returns the column type as bytes. 1506 column_name: exp.DataType.build(_decoded_str(column_type), dialect=self.dialect) 1507 for column_name, column_type, *_ in itertools.takewhile( 1508 lambda t: not t[0].startswith("#"), 1509 describe_output, 1510 ) 1511 if column_name and column_name.strip() and column_type and column_type.strip() 1512 } 1513 1514 def table_exists(self, table_name: TableName) -> bool: 1515 table = exp.to_table(table_name) 1516 data_object_cache_key = _get_data_object_cache_key(table.catalog, table.db, table.name) 1517 if data_object_cache_key in self._data_object_cache: 1518 logger.debug("Table existence cache hit: %s", data_object_cache_key) 1519 return self._data_object_cache[data_object_cache_key] is not None 1520 1521 try: 1522 self.execute(exp.Describe(this=table, kind="TABLE")) 1523 return True 1524 except Exception: 1525 return False 1526 1527 def delete_from(self, table_name: TableName, where: t.Union[str, exp.Expr]) -> None: 1528 self.execute(exp.delete(table_name, where)) 1529 1530 def insert_append( 1531 self, 1532 table_name: TableName, 1533 query_or_df: QueryOrDF, 1534 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1535 track_rows_processed: bool = True, 1536 source_columns: t.Optional[t.List[str]] = None, 1537 ) -> None: 1538 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 1539 query_or_df, 1540 target_columns_to_types, 1541 target_table=table_name, 1542 source_columns=source_columns, 1543 ) 1544 self._insert_append_source_queries( 1545 table_name, source_queries, target_columns_to_types, track_rows_processed 1546 ) 1547 1548 def _insert_append_source_queries( 1549 self, 1550 table_name: TableName, 1551 source_queries: t.List[SourceQuery], 1552 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1553 track_rows_processed: bool = True, 1554 ) -> None: 1555 with self.transaction(condition=len(source_queries) > 0): 1556 target_columns_to_types = target_columns_to_types or self.columns(table_name) 1557 for source_query in source_queries: 1558 with source_query as query: 1559 self._insert_append_query( 1560 table_name, 1561 query, 1562 target_columns_to_types, 1563 track_rows_processed=track_rows_processed, 1564 ) 1565 1566 def _insert_append_query( 1567 self, 1568 table_name: TableName, 1569 query: Query, 1570 target_columns_to_types: t.Dict[str, exp.DataType], 1571 order_projections: bool = True, 1572 track_rows_processed: bool = True, 1573 ) -> None: 1574 if order_projections: 1575 query = self._order_projections_and_filter(query, target_columns_to_types) 1576 self.execute( 1577 exp.insert(query, table_name, columns=list(target_columns_to_types)), 1578 track_rows_processed=track_rows_processed, 1579 ) 1580 1581 def insert_overwrite_by_partition( 1582 self, 1583 table_name: TableName, 1584 query_or_df: QueryOrDF, 1585 partitioned_by: t.List[exp.Expr], 1586 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1587 source_columns: t.Optional[t.List[str]] = None, 1588 ) -> None: 1589 if self.INSERT_OVERWRITE_STRATEGY.is_insert_overwrite: 1590 target_table = exp.to_table(table_name) 1591 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 1592 query_or_df, 1593 target_columns_to_types, 1594 target_table=target_table, 1595 source_columns=source_columns, 1596 ) 1597 self._insert_overwrite_by_condition( 1598 table_name, source_queries, target_columns_to_types=target_columns_to_types 1599 ) 1600 else: 1601 self._replace_by_key( 1602 table_name, 1603 query_or_df, 1604 target_columns_to_types, 1605 partitioned_by, 1606 is_unique_key=False, 1607 source_columns=source_columns, 1608 ) 1609 1610 def insert_overwrite_by_time_partition( 1611 self, 1612 table_name: TableName, 1613 query_or_df: QueryOrDF, 1614 start: TimeLike, 1615 end: TimeLike, 1616 time_formatter: t.Callable[[TimeLike, t.Optional[t.Dict[str, exp.DataType]]], exp.Expr], 1617 time_column: TimeColumn | exp.Expr | str, 1618 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1619 source_columns: t.Optional[t.List[str]] = None, 1620 **kwargs: t.Any, 1621 ) -> None: 1622 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 1623 query_or_df, 1624 target_columns_to_types, 1625 target_table=table_name, 1626 source_columns=source_columns, 1627 ) 1628 if not target_columns_to_types or not columns_to_types_all_known(target_columns_to_types): 1629 target_columns_to_types = self.columns(table_name) 1630 low, high = [ 1631 time_formatter(dt, target_columns_to_types) 1632 for dt in make_inclusive(start, end, self.dialect) 1633 ] 1634 if isinstance(time_column, TimeColumn): 1635 time_column = time_column.column 1636 where = exp.Between( 1637 this=exp.to_column(time_column) if isinstance(time_column, str) else time_column, 1638 low=low, 1639 high=high, 1640 ) 1641 return self._insert_overwrite_by_time_partition( 1642 table_name, source_queries, target_columns_to_types, where, **kwargs 1643 ) 1644 1645 def _insert_overwrite_by_time_partition( 1646 self, 1647 table_name: TableName, 1648 source_queries: t.List[SourceQuery], 1649 target_columns_to_types: t.Dict[str, exp.DataType], 1650 where: exp.Condition, 1651 **kwargs: t.Any, 1652 ) -> None: 1653 return self._insert_overwrite_by_condition( 1654 table_name, source_queries, target_columns_to_types, where, **kwargs 1655 ) 1656 1657 def _values_to_sql( 1658 self, 1659 values: t.List[t.Tuple[t.Any, ...]], 1660 target_columns_to_types: t.Dict[str, exp.DataType], 1661 batch_start: int, 1662 batch_end: int, 1663 alias: str = "t", 1664 source_columns: t.Optional[t.List[str]] = None, 1665 ) -> Query: 1666 return select_from_values_for_batch_range( 1667 values=values, 1668 target_columns_to_types=target_columns_to_types, 1669 batch_start=batch_start, 1670 batch_end=batch_end, 1671 alias=alias, 1672 source_columns=source_columns, 1673 ) 1674 1675 def _insert_overwrite_by_condition( 1676 self, 1677 table_name: TableName, 1678 source_queries: t.List[SourceQuery], 1679 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1680 where: t.Optional[exp.Condition] = None, 1681 insert_overwrite_strategy_override: t.Optional[InsertOverwriteStrategy] = None, 1682 **kwargs: t.Any, 1683 ) -> None: 1684 table = exp.to_table(table_name) 1685 insert_overwrite_strategy = ( 1686 insert_overwrite_strategy_override or self.INSERT_OVERWRITE_STRATEGY 1687 ) 1688 with self.transaction( 1689 condition=len(source_queries) > 0 or insert_overwrite_strategy.is_delete_insert 1690 ): 1691 target_columns_to_types = target_columns_to_types or self.columns(table_name) 1692 for i, source_query in enumerate(source_queries): 1693 with source_query as query: 1694 query = self._order_projections_and_filter( 1695 query, target_columns_to_types, where=where 1696 ) 1697 if i > 0 or insert_overwrite_strategy.is_delete_insert: 1698 if i == 0: 1699 self.delete_from(table_name, where=where or exp.true()) 1700 self._insert_append_query( 1701 table_name, 1702 query, 1703 target_columns_to_types=target_columns_to_types, 1704 order_projections=False, 1705 ) 1706 elif insert_overwrite_strategy.is_merge: 1707 columns = [exp.column(col) for col in target_columns_to_types] 1708 when_not_matched_by_source = exp.When( 1709 matched=False, 1710 source=True, 1711 condition=where, 1712 then=exp.Delete(), 1713 ) 1714 when_not_matched_by_target = exp.When( 1715 matched=False, 1716 source=False, 1717 then=exp.Insert( 1718 this=exp.Tuple(expressions=columns), 1719 expression=exp.Tuple(expressions=columns), 1720 ), 1721 ) 1722 self._merge( 1723 target_table=table_name, 1724 query=query, 1725 on=exp.false(), 1726 whens=exp.Whens( 1727 expressions=[when_not_matched_by_source, when_not_matched_by_target] 1728 ), 1729 ) 1730 else: 1731 insert_exp = exp.insert( 1732 query, 1733 table, 1734 columns=( 1735 list(target_columns_to_types) 1736 if not insert_overwrite_strategy.is_replace_where 1737 else None 1738 ), 1739 overwrite=insert_overwrite_strategy.is_insert_overwrite, 1740 ) 1741 if insert_overwrite_strategy.is_replace_where: 1742 insert_exp.set("where", where or exp.true()) 1743 self.execute(insert_exp, track_rows_processed=True) 1744 1745 def update_table( 1746 self, 1747 table_name: TableName, 1748 properties: t.Dict[str, t.Any], 1749 where: t.Optional[str | exp.Condition] = None, 1750 ) -> None: 1751 self.execute(exp.update(table_name, properties, where=where)) 1752 1753 def _merge( 1754 self, 1755 target_table: TableName, 1756 query: Query, 1757 on: exp.Expr, 1758 whens: exp.Whens, 1759 ) -> None: 1760 this = exp.alias_(exp.to_table(target_table), alias=MERGE_TARGET_ALIAS, table=True) 1761 using = exp.alias_( 1762 exp.Subquery(this=query), alias=MERGE_SOURCE_ALIAS, copy=False, table=True 1763 ) 1764 self.execute( 1765 exp.Merge(this=this, using=using, on=on, whens=whens), track_rows_processed=True 1766 ) 1767 1768 def scd_type_2_by_time( 1769 self, 1770 target_table: TableName, 1771 source_table: QueryOrDF, 1772 unique_key: t.Sequence[exp.Expr], 1773 valid_from_col: exp.Column, 1774 valid_to_col: exp.Column, 1775 execution_time: t.Union[TimeLike, exp.Column], 1776 updated_at_col: exp.Column, 1777 invalidate_hard_deletes: bool = True, 1778 updated_at_as_valid_from: bool = False, 1779 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1780 table_description: t.Optional[str] = None, 1781 column_descriptions: t.Optional[t.Dict[str, str]] = None, 1782 truncate: bool = False, 1783 source_columns: t.Optional[t.List[str]] = None, 1784 **kwargs: t.Any, 1785 ) -> None: 1786 self._scd_type_2( 1787 target_table=target_table, 1788 source_table=source_table, 1789 unique_key=unique_key, 1790 valid_from_col=valid_from_col, 1791 valid_to_col=valid_to_col, 1792 execution_time=execution_time, 1793 updated_at_col=updated_at_col, 1794 invalidate_hard_deletes=invalidate_hard_deletes, 1795 updated_at_as_valid_from=updated_at_as_valid_from, 1796 target_columns_to_types=target_columns_to_types, 1797 table_description=table_description, 1798 column_descriptions=column_descriptions, 1799 truncate=truncate, 1800 source_columns=source_columns, 1801 **kwargs, 1802 ) 1803 1804 def scd_type_2_by_column( 1805 self, 1806 target_table: TableName, 1807 source_table: QueryOrDF, 1808 unique_key: t.Sequence[exp.Expr], 1809 valid_from_col: exp.Column, 1810 valid_to_col: exp.Column, 1811 execution_time: t.Union[TimeLike, exp.Column], 1812 check_columns: t.Union[exp.Star, t.Sequence[exp.Expr]], 1813 invalidate_hard_deletes: bool = True, 1814 execution_time_as_valid_from: bool = False, 1815 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1816 table_description: t.Optional[str] = None, 1817 column_descriptions: t.Optional[t.Dict[str, str]] = None, 1818 truncate: bool = False, 1819 source_columns: t.Optional[t.List[str]] = None, 1820 **kwargs: t.Any, 1821 ) -> None: 1822 self._scd_type_2( 1823 target_table=target_table, 1824 source_table=source_table, 1825 unique_key=unique_key, 1826 valid_from_col=valid_from_col, 1827 valid_to_col=valid_to_col, 1828 execution_time=execution_time, 1829 check_columns=check_columns, 1830 target_columns_to_types=target_columns_to_types, 1831 invalidate_hard_deletes=invalidate_hard_deletes, 1832 execution_time_as_valid_from=execution_time_as_valid_from, 1833 table_description=table_description, 1834 column_descriptions=column_descriptions, 1835 truncate=truncate, 1836 source_columns=source_columns, 1837 **kwargs, 1838 ) 1839 1840 def _scd_type_2( 1841 self, 1842 target_table: TableName, 1843 source_table: QueryOrDF, 1844 unique_key: t.Sequence[exp.Expr], 1845 valid_from_col: exp.Column, 1846 valid_to_col: exp.Column, 1847 execution_time: t.Union[TimeLike, exp.Column], 1848 invalidate_hard_deletes: bool = True, 1849 updated_at_col: t.Optional[exp.Column] = None, 1850 check_columns: t.Optional[t.Union[exp.Star, t.Sequence[exp.Expr]]] = None, 1851 updated_at_as_valid_from: bool = False, 1852 execution_time_as_valid_from: bool = False, 1853 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1854 table_description: t.Optional[str] = None, 1855 column_descriptions: t.Optional[t.Dict[str, str]] = None, 1856 truncate: bool = False, 1857 source_columns: t.Optional[t.List[str]] = None, 1858 **kwargs: t.Any, 1859 ) -> None: 1860 def remove_managed_columns( 1861 cols_to_types: t.Dict[str, exp.DataType], 1862 ) -> t.Dict[str, exp.DataType]: 1863 return { 1864 k: v for k, v in cols_to_types.items() if k not in {valid_from_name, valid_to_name} 1865 } 1866 1867 valid_from_name = valid_from_col.name 1868 valid_to_name = valid_to_col.name 1869 target_columns_to_types = target_columns_to_types or self.columns(target_table) 1870 if ( 1871 valid_from_name not in target_columns_to_types 1872 or valid_to_name not in target_columns_to_types 1873 or not columns_to_types_all_known(target_columns_to_types) 1874 ): 1875 target_columns_to_types = self.columns(target_table) 1876 unmanaged_columns_to_types = ( 1877 remove_managed_columns(target_columns_to_types) if target_columns_to_types else None 1878 ) 1879 source_queries, unmanaged_columns_to_types = self._get_source_queries_and_columns_to_types( 1880 source_table, 1881 unmanaged_columns_to_types, 1882 target_table=target_table, 1883 batch_size=0, 1884 source_columns=source_columns, 1885 ) 1886 updated_at_name = updated_at_col.name if updated_at_col else None 1887 if not target_columns_to_types: 1888 raise SQLMeshError(f"Could not get columns_to_types. Does {target_table} exist?") 1889 unmanaged_columns_to_types = unmanaged_columns_to_types or remove_managed_columns( 1890 target_columns_to_types 1891 ) 1892 if not unique_key: 1893 raise SQLMeshError("unique_key must be provided for SCD Type 2") 1894 if check_columns and updated_at_col: 1895 raise SQLMeshError( 1896 "Cannot use both `check_columns` and `updated_at_name` for SCD Type 2" 1897 ) 1898 if check_columns and updated_at_as_valid_from: 1899 raise SQLMeshError( 1900 "Cannot use both `check_columns` and `updated_at_as_valid_from` for SCD Type 2" 1901 ) 1902 if execution_time_as_valid_from and not check_columns: 1903 raise SQLMeshError( 1904 "Cannot use `execution_time_as_valid_from` without `check_columns` for SCD Type 2" 1905 ) 1906 if updated_at_name and updated_at_name not in target_columns_to_types: 1907 raise SQLMeshError( 1908 f"Column {updated_at_name} not found in {target_table}. Table must contain an `updated_at` timestamp for SCD Type 2" 1909 ) 1910 time_data_type = target_columns_to_types[valid_from_name] 1911 select_source_columns: t.List[t.Union[str, exp.Alias]] = [ 1912 col for col in unmanaged_columns_to_types if col != updated_at_name 1913 ] 1914 table_columns = [exp.column(c, quoted=True) for c in target_columns_to_types] 1915 if updated_at_name: 1916 select_source_columns.append( 1917 exp.cast(updated_at_col, time_data_type).as_(updated_at_col.this) # type: ignore 1918 ) 1919 1920 # If a star is provided, we include all unmanaged columns in the check. 1921 # This unnecessarily includes unique key columns but since they are used in the join, and therefore we know 1922 # they are equal or not, the extra check is not a problem and we gain simplified logic here. 1923 # If we want to change this, then we just need to check the expressions in unique_key and pull out the 1924 # column names and then remove them from the unmanaged_columns 1925 if check_columns: 1926 # Handle both Star directly and [Star()] (which can happen during serialization/deserialization) 1927 if isinstance(seq_get(ensure_list(check_columns), 0), exp.Star): 1928 check_columns = [exp.column(col) for col in unmanaged_columns_to_types] 1929 execution_ts = ( 1930 exp.cast(execution_time, time_data_type, dialect=self.dialect) 1931 if isinstance(execution_time, exp.Column) 1932 else to_time_column(execution_time, time_data_type, self.dialect, nullable=True) 1933 ) 1934 if updated_at_as_valid_from: 1935 if not updated_at_col: 1936 raise SQLMeshError( 1937 "Cannot use `updated_at_as_valid_from` without `updated_at_name` for SCD Type 2" 1938 ) 1939 update_valid_from_start: t.Union[str, exp.Expr] = updated_at_col 1940 # If using check_columns and the user doesn't always want execution_time for valid from 1941 # then we only use epoch 0 if we are truncating the table and loading rows for the first time. 1942 # All future new rows should have execution time. 1943 elif check_columns and (execution_time_as_valid_from or not truncate): 1944 update_valid_from_start = execution_ts 1945 else: 1946 update_valid_from_start = to_time_column( 1947 "1970-01-01 00:00:00+00:00", time_data_type, self.dialect, nullable=True 1948 ) 1949 insert_valid_from_start = execution_ts if check_columns else updated_at_col # type: ignore 1950 # joined._exists IS NULL is saying "if the row is deleted" 1951 delete_check = ( 1952 exp.column("_exists", "joined").is_(exp.Null()) if invalidate_hard_deletes else None 1953 ) 1954 prefixed_valid_to_col = valid_to_col.copy() 1955 prefixed_valid_to_col.this.set("this", f"t_{prefixed_valid_to_col.name}") 1956 prefixed_valid_from_col = valid_from_col.copy() 1957 prefixed_valid_from_col.this.set("this", f"t_{valid_from_col.name}") 1958 if check_columns: 1959 row_check_conditions = [] 1960 for col in check_columns: 1961 col_qualified = col.copy() 1962 col_qualified.set("table", exp.to_identifier("joined")) 1963 1964 t_col = col_qualified.copy() 1965 for column in t_col.find_all(exp.Column): 1966 column.this.set("this", f"t_{column.name}") 1967 1968 row_check_conditions.extend( 1969 [ 1970 col_qualified.neq(t_col), 1971 exp.and_(t_col.is_(exp.Null()), col_qualified.is_(exp.Null()).not_()), 1972 exp.and_(t_col.is_(exp.Null()).not_(), col_qualified.is_(exp.Null())), 1973 ] 1974 ) 1975 row_value_check = exp.or_(*row_check_conditions) 1976 unique_key_conditions = [] 1977 for key in unique_key: 1978 key_qualified = key.copy() 1979 key_qualified.set("table", exp.to_identifier("joined")) 1980 t_key = key_qualified.copy() 1981 for col in t_key.find_all(exp.Column): 1982 col.this.set("this", f"t_{col.name}") 1983 unique_key_conditions.extend( 1984 [t_key.is_(exp.Null()).not_(), key_qualified.is_(exp.Null()).not_()] 1985 ) 1986 unique_key_check = exp.and_(*unique_key_conditions) 1987 # unique_key_check is saying "if the row is updated" 1988 # row_value_check is saying "if the row has changed" 1989 updated_row_filter = exp.and_(unique_key_check, row_value_check) 1990 valid_to_case_stmt = ( 1991 exp.Case() 1992 .when( 1993 exp.and_( 1994 exp.or_( 1995 delete_check, 1996 updated_row_filter, 1997 ) 1998 ), 1999 execution_ts, 2000 ) 2001 .else_(prefixed_valid_to_col) 2002 .as_(valid_to_col.this) 2003 ) 2004 valid_from_case_stmt = exp.func( 2005 "COALESCE", 2006 prefixed_valid_from_col, 2007 update_valid_from_start, 2008 ).as_(valid_from_col.this) 2009 else: 2010 assert updated_at_col is not None 2011 updated_at_col_qualified = updated_at_col.copy() 2012 updated_at_col_qualified.set("table", exp.to_identifier("joined")) 2013 prefixed_updated_at_col = updated_at_col_qualified.copy() 2014 prefixed_updated_at_col.this.set("this", f"t_{updated_at_col_qualified.name}") 2015 updated_row_filter = updated_at_col_qualified > prefixed_updated_at_col 2016 2017 valid_to_case_stmt_builder = exp.Case().when( 2018 updated_row_filter, updated_at_col_qualified 2019 ) 2020 if delete_check: 2021 valid_to_case_stmt_builder = valid_to_case_stmt_builder.when( 2022 delete_check, execution_ts 2023 ) 2024 valid_to_case_stmt = valid_to_case_stmt_builder.else_(prefixed_valid_to_col).as_( 2025 valid_to_col.this 2026 ) 2027 2028 valid_from_case_stmt = ( 2029 exp.Case() 2030 .when( 2031 exp.and_( 2032 prefixed_valid_from_col.is_(exp.Null()), 2033 exp.column("_exists", "latest_deleted").is_(exp.Null()).not_(), 2034 ), 2035 exp.Case() 2036 .when( 2037 exp.column(valid_to_col.this, "latest_deleted") > updated_at_col, 2038 exp.column(valid_to_col.this, "latest_deleted"), 2039 ) 2040 .else_(updated_at_col), 2041 ) 2042 .when(prefixed_valid_from_col.is_(exp.Null()), update_valid_from_start) 2043 .else_(prefixed_valid_from_col) 2044 ).as_(valid_from_col.this) 2045 2046 existing_rows_query = exp.select(*table_columns, exp.true().as_("_exists")).from_( 2047 target_table 2048 ) 2049 if truncate: 2050 existing_rows_query = existing_rows_query.limit(0) 2051 2052 with source_queries[0] as source_query: 2053 prefixed_columns_to_types = [] 2054 for column in target_columns_to_types: 2055 prefixed_col = exp.column(column).copy() 2056 prefixed_col.this.set("this", f"t_{prefixed_col.name}") 2057 prefixed_columns_to_types.append(prefixed_col) 2058 prefixed_unmanaged_columns = [] 2059 for column in unmanaged_columns_to_types: 2060 prefixed_col = exp.column(column).copy() 2061 prefixed_col.this.set("this", f"t_{prefixed_col.name}") 2062 prefixed_unmanaged_columns.append(prefixed_col) 2063 query = ( 2064 exp.Select() # type: ignore 2065 .select(*table_columns) 2066 .from_("static") 2067 .union( 2068 exp.select(*table_columns).from_("updated_rows"), 2069 distinct=False, 2070 ) 2071 .union( 2072 exp.select(*table_columns).from_("inserted_rows"), 2073 distinct=False, 2074 ) 2075 .with_( 2076 "source", 2077 exp.select(exp.true().as_("_exists"), *select_source_columns) 2078 .distinct(*unique_key) 2079 .from_( 2080 self.use_server_nulls_for_unmatched_after_join(source_query).subquery( # type: ignore 2081 "raw_source" 2082 ) 2083 ), 2084 ) 2085 # Historical Records that Do Not Change 2086 .with_( 2087 "static", 2088 existing_rows_query.where(valid_to_col.is_(exp.Null()).not_()), 2089 ) 2090 # Latest Records that can be updated 2091 .with_( 2092 "latest", 2093 existing_rows_query.where(valid_to_col.is_(exp.Null())), 2094 ) 2095 # Deleted records which can be used to determine `valid_from` for undeleted source records 2096 .with_( 2097 "deleted", 2098 exp.select(*[exp.column(col, "static") for col in target_columns_to_types]) 2099 .from_("static") 2100 .join( 2101 "latest", 2102 on=exp.and_( 2103 *[ 2104 add_table(key, "static").eq(add_table(key, "latest")) 2105 for key in unique_key 2106 ] 2107 ), 2108 join_type="left", 2109 ) 2110 .where(exp.column(valid_to_col.this, "latest").is_(exp.Null())), 2111 ) 2112 # Get the latest `valid_to` deleted record for each unique key 2113 .with_( 2114 "latest_deleted", 2115 exp.select( 2116 exp.true().as_("_exists"), 2117 *(part.as_(f"_key{i}") for i, part in enumerate(unique_key)), 2118 exp.Max(this=valid_to_col).as_(valid_to_col.this), 2119 ) 2120 .from_("deleted") 2121 .group_by(*unique_key), 2122 ) 2123 # Do a full join between latest records and source table in order to combine them together 2124 # MySQL doesn't support full join so going to do a left then right join and remove dups with union 2125 # We do a left/right and filter right on only matching to remove the need to do union distinct 2126 # which allows scd type 2 to be compatible with unhashable data types 2127 .with_( 2128 "joined", 2129 exp.select( 2130 exp.column("_exists", table="source").as_("_exists"), 2131 *( 2132 exp.column(col, table="latest").as_(prefixed_columns_to_types[i].this) 2133 for i, col in enumerate(target_columns_to_types) 2134 ), 2135 *( 2136 exp.column(col, table="source").as_(col) 2137 for col in unmanaged_columns_to_types 2138 ), 2139 ) 2140 .from_("latest") 2141 .join( 2142 "source", 2143 on=exp.and_( 2144 *[ 2145 add_table(key, "latest").eq(add_table(key, "source")) 2146 for key in unique_key 2147 ] 2148 ), 2149 join_type="left", 2150 ) 2151 .union( 2152 exp.select( 2153 exp.column("_exists", table="source").as_("_exists"), 2154 *( 2155 exp.column(col, table="latest").as_( 2156 prefixed_columns_to_types[i].this 2157 ) 2158 for i, col in enumerate(target_columns_to_types) 2159 ), 2160 *( 2161 exp.column(col, table="source").as_(col) 2162 for col in unmanaged_columns_to_types 2163 ), 2164 ) 2165 .from_("latest") 2166 .join( 2167 "source", 2168 on=exp.and_( 2169 *[ 2170 add_table(key, "latest").eq(add_table(key, "source")) 2171 for key in unique_key 2172 ] 2173 ), 2174 join_type="right", 2175 ) 2176 .where(exp.column("_exists", table="latest").is_(exp.Null())), 2177 distinct=False, 2178 ), 2179 ) 2180 # Get deleted, new, no longer current, or unchanged records 2181 .with_( 2182 "updated_rows", 2183 exp.select( 2184 *( 2185 exp.func( 2186 "COALESCE", 2187 exp.column(prefixed_unmanaged_columns[i].this, table="joined"), 2188 exp.column(col, table="joined"), 2189 ).as_(col) 2190 for i, col in enumerate(unmanaged_columns_to_types) 2191 ), 2192 valid_from_case_stmt, 2193 valid_to_case_stmt, 2194 ) 2195 .from_("joined") 2196 .join( 2197 "latest_deleted", 2198 on=exp.and_( 2199 *[ 2200 add_table(part, "joined").eq( 2201 exp.column(f"_key{i}", "latest_deleted") 2202 ) 2203 for i, part in enumerate(unique_key) 2204 ] 2205 ), 2206 join_type="left", 2207 ), 2208 ) 2209 # Get records that have been "updated" which means inserting a new record with previous `valid_from` 2210 .with_( 2211 "inserted_rows", 2212 exp.select( 2213 *unmanaged_columns_to_types, 2214 insert_valid_from_start.as_(valid_from_col.this), # type: ignore 2215 to_time_column(exp.null(), time_data_type, self.dialect, nullable=True).as_( 2216 valid_to_col.this 2217 ), 2218 ) 2219 .from_("joined") 2220 .where(updated_row_filter), 2221 ) 2222 ) 2223 2224 self.replace_query( 2225 target_table, 2226 self.ensure_nulls_for_unmatched_after_join(query), 2227 target_columns_to_types=target_columns_to_types, 2228 table_description=table_description, 2229 column_descriptions=column_descriptions, 2230 **kwargs, 2231 ) 2232 2233 def merge( 2234 self, 2235 target_table: TableName, 2236 source_table: QueryOrDF, 2237 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], 2238 unique_key: t.Sequence[exp.Expr], 2239 when_matched: t.Optional[exp.Whens] = None, 2240 merge_filter: t.Optional[exp.Expr] = None, 2241 source_columns: t.Optional[t.List[str]] = None, 2242 **kwargs: t.Any, 2243 ) -> None: 2244 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 2245 source_table, 2246 target_columns_to_types, 2247 target_table=target_table, 2248 source_columns=source_columns, 2249 ) 2250 target_columns_to_types = target_columns_to_types or self.columns(target_table) 2251 on = exp.and_( 2252 *( 2253 add_table(part, MERGE_TARGET_ALIAS).eq(add_table(part, MERGE_SOURCE_ALIAS)) 2254 for part in unique_key 2255 ) 2256 ) 2257 if merge_filter: 2258 on = exp.and_(merge_filter, on) 2259 2260 if not when_matched: 2261 match_expressions = [ 2262 exp.When( 2263 matched=True, 2264 source=False, 2265 then=exp.Update( 2266 expressions=[ 2267 exp.column(col, MERGE_TARGET_ALIAS).eq( 2268 exp.column(col, MERGE_SOURCE_ALIAS) 2269 ) 2270 for col in target_columns_to_types 2271 ], 2272 ), 2273 ) 2274 ] 2275 else: 2276 match_expressions = when_matched.copy().expressions 2277 2278 match_expressions.append( 2279 exp.When( 2280 matched=False, 2281 source=False, 2282 then=exp.Insert( 2283 this=exp.Tuple( 2284 expressions=[exp.column(col) for col in target_columns_to_types] 2285 ), 2286 expression=exp.Tuple( 2287 expressions=[ 2288 exp.column(col, MERGE_SOURCE_ALIAS) for col in target_columns_to_types 2289 ] 2290 ), 2291 ), 2292 ) 2293 ) 2294 for source_query in source_queries: 2295 with source_query as query: 2296 self._merge( 2297 target_table=target_table, 2298 query=query, 2299 on=on, 2300 whens=exp.Whens(expressions=match_expressions), 2301 ) 2302 2303 def rename_table( 2304 self, 2305 old_table_name: TableName, 2306 new_table_name: TableName, 2307 ) -> None: 2308 new_table = exp.to_table(new_table_name) 2309 if new_table.catalog: 2310 old_table = exp.to_table(old_table_name) 2311 catalog = old_table.catalog or self.get_current_catalog() 2312 if catalog != new_table.catalog: 2313 raise UnsupportedCatalogOperationError( 2314 "Tried to rename table across catalogs which is not supported" 2315 ) 2316 self._rename_table(old_table_name, new_table_name) 2317 self._clear_data_object_cache(old_table_name) 2318 self._clear_data_object_cache(new_table_name) 2319 2320 def get_data_object( 2321 self, target_name: TableName, safe_to_cache: bool = False 2322 ) -> t.Optional[DataObject]: 2323 target_table = exp.to_table(target_name) 2324 existing_data_objects = self.get_data_objects( 2325 schema_(target_table.db, target_table.catalog), 2326 {target_table.name}, 2327 safe_to_cache=safe_to_cache, 2328 ) 2329 if existing_data_objects: 2330 return existing_data_objects[0] 2331 return None 2332 2333 def get_data_objects( 2334 self, 2335 schema_name: SchemaName, 2336 object_names: t.Optional[t.Set[str]] = None, 2337 safe_to_cache: bool = False, 2338 ) -> t.List[DataObject]: 2339 """Lists all data objects in the target schema. 2340 2341 Args: 2342 schema_name: The name of the schema to list data objects from. 2343 object_names: If provided, only return data objects with these names. 2344 safe_to_cache: Whether it is safe to cache the results of this call. 2345 2346 Returns: 2347 A list of data objects in the target schema. 2348 """ 2349 if object_names is not None: 2350 if not object_names: 2351 return [] 2352 2353 # Check cache for each object name 2354 target_schema = to_schema(schema_name) 2355 cached_objects = [] 2356 missing_names = set() 2357 2358 for name in object_names: 2359 cache_key = _get_data_object_cache_key( 2360 target_schema.catalog, target_schema.db, name 2361 ) 2362 if cache_key in self._data_object_cache: 2363 logger.debug("Data object cache hit: %s", cache_key) 2364 data_object = self._data_object_cache[cache_key] 2365 # If the object is none, then the table was previously looked for but not found 2366 if data_object: 2367 cached_objects.append(data_object) 2368 else: 2369 logger.debug("Data object cache miss: %s", cache_key) 2370 missing_names.add(name) 2371 2372 # Fetch missing objects from database 2373 if missing_names: 2374 object_names_list = list(missing_names) 2375 batches = [ 2376 object_names_list[i : i + self.DATA_OBJECT_FILTER_BATCH_SIZE] 2377 for i in range(0, len(object_names_list), self.DATA_OBJECT_FILTER_BATCH_SIZE) 2378 ] 2379 2380 fetched_objects = [] 2381 fetched_object_names = set() 2382 for batch in batches: 2383 objects = self._get_data_objects(schema_name, set(batch)) 2384 for obj in objects: 2385 if safe_to_cache: 2386 cache_key = _get_data_object_cache_key( 2387 obj.catalog, obj.schema_name, obj.name 2388 ) 2389 self._data_object_cache[cache_key] = obj 2390 fetched_objects.append(obj) 2391 fetched_object_names.add(obj.name) 2392 2393 if safe_to_cache: 2394 for missing_name in missing_names - fetched_object_names: 2395 cache_key = _get_data_object_cache_key( 2396 target_schema.catalog, target_schema.db, missing_name 2397 ) 2398 self._data_object_cache[cache_key] = None 2399 2400 return cached_objects + fetched_objects 2401 2402 return cached_objects 2403 2404 fetched_objects = self._get_data_objects(schema_name) 2405 if safe_to_cache: 2406 for obj in fetched_objects: 2407 cache_key = _get_data_object_cache_key(obj.catalog, obj.schema_name, obj.name) 2408 self._data_object_cache[cache_key] = obj 2409 return fetched_objects 2410 2411 def fetchone( 2412 self, 2413 query: t.Union[exp.Expr, str], 2414 ignore_unsupported_errors: bool = False, 2415 quote_identifiers: bool = False, 2416 ) -> t.Optional[t.Tuple]: 2417 with self.transaction(): 2418 self.execute( 2419 query, 2420 ignore_unsupported_errors=ignore_unsupported_errors, 2421 quote_identifiers=quote_identifiers, 2422 ) 2423 return self.cursor.fetchone() 2424 2425 def fetchall( 2426 self, 2427 query: t.Union[exp.Expr, str], 2428 ignore_unsupported_errors: bool = False, 2429 quote_identifiers: bool = False, 2430 ) -> t.List[t.Tuple]: 2431 with self.transaction(): 2432 self.execute( 2433 query, 2434 ignore_unsupported_errors=ignore_unsupported_errors, 2435 quote_identifiers=quote_identifiers, 2436 ) 2437 return self.cursor.fetchall() 2438 2439 def _fetch_native_df( 2440 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 2441 ) -> DF: 2442 """Fetches a DataFrame that can be either Pandas or PySpark from the cursor""" 2443 with self.transaction(): 2444 self.execute(query, quote_identifiers=quote_identifiers) 2445 return self.cursor.fetchdf() 2446 2447 def _native_df_to_pandas_df( 2448 self, 2449 query_or_df: QueryOrDF, 2450 ) -> t.Union[Query, pd.DataFrame]: 2451 """ 2452 Take a "native" DataFrame (eg Pyspark, Bigframe, Snowpark etc) and convert it to Pandas 2453 """ 2454 import pandas as pd 2455 2456 if isinstance(query_or_df, (exp.Query, pd.DataFrame)): 2457 return query_or_df 2458 2459 # EngineAdapter subclasses that have native DataFrame types should override this 2460 raise NotImplementedError(f"Unable to convert {type(query_or_df)} to Pandas") 2461 2462 def fetchdf( 2463 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 2464 ) -> pd.DataFrame: 2465 """Fetches a Pandas DataFrame from the cursor""" 2466 import pandas as pd 2467 2468 df = self._fetch_native_df(query, quote_identifiers=quote_identifiers) 2469 if not isinstance(df, pd.DataFrame): 2470 raise NotImplementedError( 2471 "The cursor's `fetch_native_df` method is not returning a pandas DataFrame. Need to update `fetchdf` so a Pandas DataFrame is returned" 2472 ) 2473 return df 2474 2475 def fetch_pyspark_df( 2476 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 2477 ) -> PySparkDataFrame: 2478 """Fetches a PySpark DataFrame from the cursor""" 2479 raise NotImplementedError(f"Engine does not support PySpark DataFrames: {type(self)}") 2480 2481 @property 2482 def wap_enabled(self) -> bool: 2483 """Returns whether WAP is enabled for this engine.""" 2484 return self._extra_config.get("wap_enabled", False) 2485 2486 def wap_supported(self, table_name: TableName) -> bool: 2487 """Returns whether WAP for the target table is supported.""" 2488 return False 2489 2490 def wap_table_name(self, table_name: TableName, wap_id: str) -> str: 2491 """Returns the updated table name for the given WAP ID. 2492 2493 Args: 2494 table_name: The name of the target table. 2495 wap_id: The WAP ID to prepare. 2496 2497 Returns: 2498 The updated table name that should be used for writing. 2499 """ 2500 raise NotImplementedError(f"Engine does not support WAP: {type(self)}") 2501 2502 def wap_prepare(self, table_name: TableName, wap_id: str) -> str: 2503 """Prepares the target table for WAP and returns the updated table name. 2504 2505 Args: 2506 table_name: The name of the target table. 2507 wap_id: The WAP ID to prepare. 2508 2509 Returns: 2510 The updated table name that should be used for writing. 2511 """ 2512 raise NotImplementedError(f"Engine does not support WAP: {type(self)}") 2513 2514 def wap_publish(self, table_name: TableName, wap_id: str) -> None: 2515 """Publishes changes with the given WAP ID to the target table. 2516 2517 Args: 2518 table_name: The name of the target table. 2519 wap_id: The WAP ID to publish. 2520 """ 2521 raise NotImplementedError(f"Engine does not support WAP: {type(self)}") 2522 2523 def sync_grants_config( 2524 self, 2525 table: exp.Table, 2526 grants_config: GrantsConfig, 2527 table_type: DataObjectType = DataObjectType.TABLE, 2528 ) -> None: 2529 """Applies the grants_config to a table authoritatively. 2530 It first compares the specified grants against the current grants, and then 2531 applies the diffs to the table by revoking and granting privileges as needed. 2532 2533 Args: 2534 table: The table/view to apply grants to. 2535 grants_config: Dictionary mapping privileges to lists of grantees. 2536 table_type: The type of database object (TABLE, VIEW, MATERIALIZED_VIEW). 2537 """ 2538 if not self.SUPPORTS_GRANTS: 2539 raise NotImplementedError(f"Engine does not support grants: {type(self)}") 2540 2541 current_grants = self._get_current_grants_config(table) 2542 new_grants, revoked_grants = self._diff_grants_configs(grants_config, current_grants) 2543 revoke_exprs = self._revoke_grants_config_expr(table, revoked_grants, table_type) 2544 grant_exprs = self._apply_grants_config_expr(table, new_grants, table_type) 2545 dcl_exprs = revoke_exprs + grant_exprs 2546 2547 if dcl_exprs: 2548 self.execute(dcl_exprs) 2549 2550 @contextlib.contextmanager 2551 def transaction( 2552 self, 2553 condition: t.Optional[bool] = None, 2554 ) -> t.Iterator[None]: 2555 """A transaction context manager.""" 2556 if ( 2557 self._connection_pool.is_transaction_active 2558 or not self.SUPPORTS_TRANSACTIONS 2559 or (condition is not None and not condition) 2560 ): 2561 yield 2562 return 2563 2564 if self._pre_ping: 2565 try: 2566 logger.debug("Pinging the database to check the connection") 2567 self.ping() 2568 except Exception: 2569 logger.info("Connection to the database was lost. Reconnecting...") 2570 self._connection_pool.close() 2571 2572 self._connection_pool.begin() 2573 try: 2574 yield 2575 except Exception as e: 2576 self._connection_pool.rollback() 2577 raise e 2578 else: 2579 self._connection_pool.commit() 2580 2581 @contextlib.contextmanager 2582 def session(self, properties: SessionProperties) -> t.Iterator[None]: 2583 """A session context manager.""" 2584 if self._is_session_active(): 2585 yield 2586 return 2587 2588 self._begin_session(properties) 2589 try: 2590 yield 2591 finally: 2592 self._end_session() 2593 2594 def _begin_session(self, properties: SessionProperties) -> t.Any: 2595 """Begin a new session.""" 2596 2597 def _end_session(self) -> None: 2598 """End the existing session.""" 2599 2600 def _is_session_active(self) -> bool: 2601 """Indicates whether or not a session is active.""" 2602 return False 2603 2604 def execute( 2605 self, 2606 expressions: t.Union[str, exp.Expr, t.Sequence[exp.Expr]], 2607 ignore_unsupported_errors: bool = False, 2608 quote_identifiers: bool = True, 2609 track_rows_processed: bool = False, 2610 **kwargs: t.Any, 2611 ) -> None: 2612 """Execute a sql query.""" 2613 to_sql_kwargs = ( 2614 {"unsupported_level": ErrorLevel.IGNORE} if ignore_unsupported_errors else {} 2615 ) 2616 with self.transaction(): 2617 for e in ensure_list(expressions): 2618 if isinstance(e, exp.Expr): 2619 self._check_identifier_length(e) 2620 sql = self._to_sql(e, quote=quote_identifiers, **to_sql_kwargs) 2621 else: 2622 sql = t.cast(str, e) 2623 2624 sql = self._attach_correlation_id(sql) 2625 2626 self._log_sql( 2627 sql, 2628 expression=e if isinstance(e, exp.Expr) else None, 2629 quote_identifiers=quote_identifiers, 2630 ) 2631 self._execute(sql, track_rows_processed, **kwargs) 2632 2633 def _attach_correlation_id(self, sql: str) -> str: 2634 if self.ATTACH_CORRELATION_ID and self.correlation_id: 2635 return f"/* {self.correlation_id} */ {sql}" 2636 return sql 2637 2638 def _log_sql( 2639 self, 2640 sql: str, 2641 expression: t.Optional[exp.Expr] = None, 2642 quote_identifiers: bool = True, 2643 ) -> None: 2644 if not logger.isEnabledFor(self._execute_log_level): 2645 return 2646 2647 sql_to_log = sql 2648 if expression is not None and not isinstance(expression, exp.Query): 2649 values = expression.find(exp.Values) 2650 if values: 2651 values.set("expressions", [exp.to_identifier("<REDACTED VALUES>")]) 2652 sql_to_log = self._to_sql(expression, quote=quote_identifiers) 2653 2654 logger.log(self._execute_log_level, "Executing SQL: %s", sql_to_log) 2655 2656 def _record_execution_stats( 2657 self, sql: str, rowcount: t.Optional[int] = None, bytes_processed: t.Optional[int] = None 2658 ) -> None: 2659 if self._query_execution_tracker: 2660 self._query_execution_tracker.record_execution(sql, rowcount, bytes_processed) 2661 2662 def _execute(self, sql: str, track_rows_processed: bool = False, **kwargs: t.Any) -> None: 2663 self.cursor.execute(sql, **kwargs) 2664 2665 if ( 2666 self.SUPPORTS_QUERY_EXECUTION_TRACKING 2667 and track_rows_processed 2668 and self._query_execution_tracker 2669 and self._query_execution_tracker.is_tracking() 2670 ): 2671 if ( 2672 rowcount := getattr(self.cursor, "rowcount", None) 2673 ) is not None and rowcount is not None: 2674 try: 2675 self._record_execution_stats(sql, int(rowcount)) 2676 except (TypeError, ValueError): 2677 return 2678 2679 @contextlib.contextmanager 2680 def temp_table( 2681 self, 2682 query_or_df: QueryOrDF, 2683 name: TableName = "diff", 2684 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 2685 source_columns: t.Optional[t.List[str]] = None, 2686 **kwargs: t.Any, 2687 ) -> t.Iterator[exp.Table]: 2688 """A context manager for working a temp table. 2689 2690 The table will be created with a random guid and cleaned up after the block. 2691 2692 Args: 2693 query_or_df: The query or df to create a temp table for. 2694 name: The base name of the temp table. 2695 target_columns_to_types: A mapping between the column name and its data type. 2696 2697 Yields: 2698 The table expression 2699 """ 2700 name = exp.to_table(name) 2701 # ensure that we use default catalog if none is not specified 2702 if isinstance(name, exp.Table) and not name.catalog and name.db and self.default_catalog: 2703 name.set("catalog", exp.parse_identifier(self.default_catalog)) 2704 2705 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 2706 query_or_df, 2707 target_columns_to_types=target_columns_to_types, 2708 target_table=name, 2709 source_columns=source_columns, 2710 ) 2711 2712 with self.transaction(): 2713 table = self._get_temp_table(name) 2714 if table.db: 2715 self.create_schema(schema_(table.args["db"], table.args.get("catalog"))) 2716 self._create_table_from_source_queries( 2717 table, 2718 source_queries, 2719 target_columns_to_types, 2720 exists=True, 2721 table_description=None, 2722 column_descriptions=None, 2723 track_rows_processed=False, 2724 **kwargs, 2725 ) 2726 2727 try: 2728 yield table 2729 finally: 2730 self.drop_table(table) 2731 2732 def _table_or_view_properties_to_expressions( 2733 self, table_or_view_properties: t.Optional[t.Dict[str, exp.Expr]] = None 2734 ) -> t.List[exp.Property]: 2735 """Converts model properties (either physical or virtual) to a list of property expressions.""" 2736 if not table_or_view_properties: 2737 return [] 2738 return [ 2739 exp.Property(this=key, value=value.copy()) 2740 for key, value in table_or_view_properties.items() 2741 ] 2742 2743 def _build_partitioned_by_exp( 2744 self, 2745 partitioned_by: t.List[exp.Expr], 2746 *, 2747 partition_interval_unit: t.Optional[IntervalUnit] = None, 2748 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 2749 catalog_name: t.Optional[str] = None, 2750 **kwargs: t.Any, 2751 ) -> t.Optional[t.Union[exp.PartitionedByProperty, exp.Property]]: 2752 return None 2753 2754 def _build_clustered_by_exp( 2755 self, 2756 clustered_by: t.List[exp.Expr], 2757 **kwargs: t.Any, 2758 ) -> t.Optional[exp.Cluster]: 2759 return None 2760 2761 def adjust_physical_properties_for_incremental( 2762 self, 2763 physical_properties: t.Dict[str, t.Any], 2764 *, 2765 requires_delete_capable_table: bool, 2766 unique_key: t.Optional[t.List[exp.Expr]], 2767 model_name: str, 2768 ) -> t.Dict[str, t.Any]: 2769 """Adjusts physical properties for an incremental model before the table is created. 2770 2771 Some engines require a specific physical table layout before they can run the DELETE/MERGE 2772 statements that incremental model kinds rely on (e.g. StarRocks only supports those on 2773 PRIMARY KEY tables). This hook lets each engine derive or validate the required properties 2774 while keeping the generic evaluator free of engine-specific branching. 2775 2776 Args: 2777 physical_properties: The model's physical properties. 2778 requires_delete_capable_table: Whether the model kind issues DELETE/MERGE statements 2779 (as opposed to append-only INSERTs), as determined by the generic evaluator. 2780 unique_key: The model's unique key, populated only when the kind allows promoting it to 2781 an engine-specific key (i.e. INCREMENTAL_BY_UNIQUE_KEY); otherwise None. 2782 model_name: The model name, for use in diagnostics. 2783 2784 Returns: 2785 The (possibly adjusted) physical properties. Implementations own the given mapping and 2786 may mutate it in place; the base implementation returns it unchanged. 2787 """ 2788 return physical_properties 2789 2790 def _build_table_properties_exp( 2791 self, 2792 catalog_name: t.Optional[str] = None, 2793 table_format: t.Optional[str] = None, 2794 storage_format: t.Optional[str] = None, 2795 partitioned_by: t.Optional[t.List[exp.Expr]] = None, 2796 partition_interval_unit: t.Optional[IntervalUnit] = None, 2797 clustered_by: t.Optional[t.List[exp.Expr]] = None, 2798 table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, 2799 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 2800 table_description: t.Optional[str] = None, 2801 table_kind: t.Optional[str] = None, 2802 **kwargs: t.Any, 2803 ) -> t.Optional[exp.Properties]: 2804 """Creates a SQLGlot table properties expression for ddl.""" 2805 properties: t.List[exp.Expr] = [] 2806 2807 if table_description: 2808 properties.append( 2809 exp.SchemaCommentProperty( 2810 this=exp.Literal.string(self._truncate_table_comment(table_description)) 2811 ) 2812 ) 2813 2814 if table_properties: 2815 table_type = self._pop_creatable_type_from_properties(table_properties) 2816 properties.extend(ensure_list(table_type)) 2817 2818 if properties: 2819 return exp.Properties(expressions=properties) 2820 return None 2821 2822 def _build_view_properties_exp( 2823 self, 2824 view_properties: t.Optional[t.Dict[str, exp.Expr]] = None, 2825 table_description: t.Optional[str] = None, 2826 **kwargs: t.Any, 2827 ) -> t.Optional[exp.Properties]: 2828 """Creates a SQLGlot table properties expression for view""" 2829 properties: t.List[exp.Expr] = [] 2830 2831 if table_description: 2832 properties.append( 2833 exp.SchemaCommentProperty( 2834 this=exp.Literal.string(self._truncate_table_comment(table_description)) 2835 ) 2836 ) 2837 2838 if properties: 2839 return exp.Properties(expressions=properties) 2840 return None 2841 2842 def _truncate_comment(self, comment: str, length: t.Optional[int]) -> str: 2843 return comment[:length] if length else comment 2844 2845 def _truncate_table_comment(self, comment: str) -> str: 2846 return self._truncate_comment(comment, self.MAX_TABLE_COMMENT_LENGTH) 2847 2848 def _truncate_column_comment(self, comment: str) -> str: 2849 return self._truncate_comment(comment, self.MAX_COLUMN_COMMENT_LENGTH) 2850 2851 def _to_sql(self, expression: exp.Expr, quote: bool = True, **kwargs: t.Any) -> str: 2852 """ 2853 Converts an expression to a SQL string. Has a set of default kwargs to apply, and then default 2854 kwargs defined for the given dialect, and then kwargs provided by the user when defining the engine 2855 adapter, and then finally kwargs provided by the user when calling this method. 2856 """ 2857 sql_gen_kwargs = { 2858 "dialect": self.dialect, 2859 "pretty": self._pretty_sql, 2860 "comments": False, 2861 **self._sql_gen_kwargs, 2862 **kwargs, 2863 } 2864 2865 expression = expression.copy() 2866 2867 if quote: 2868 quote_identifiers(expression) 2869 2870 return expression.sql(**sql_gen_kwargs, copy=False) # type: ignore 2871 2872 def _clear_data_object_cache(self, table_name: t.Optional[TableName] = None) -> None: 2873 """Clears the cache entry for the given table name, or clears the entire cache if table_name is None.""" 2874 if table_name is None: 2875 logger.debug("Clearing entire data object cache") 2876 self._data_object_cache.clear() 2877 else: 2878 table = exp.to_table(table_name) 2879 cache_key = _get_data_object_cache_key(table.catalog, table.db, table.name) 2880 logger.debug("Clearing data object cache key: %s", cache_key) 2881 self._data_object_cache.pop(cache_key, None) 2882 2883 def _get_data_objects( 2884 self, schema_name: SchemaName, object_names: t.Optional[t.Set[str]] = None 2885 ) -> t.List[DataObject]: 2886 """ 2887 Returns all the data objects that exist in the given schema and optionally catalog. 2888 """ 2889 raise NotImplementedError() 2890 2891 def _get_temp_table( 2892 self, table: TableName, table_only: bool = False, quoted: bool = True 2893 ) -> exp.Table: 2894 """ 2895 Returns the name of the temp table that should be used for the given table name. 2896 """ 2897 table = t.cast(exp.Table, exp.to_table(table).copy()) 2898 table.set( 2899 "this", exp.to_identifier(f"__temp_{table.name}_{random_id(short=True)}", quoted=quoted) 2900 ) 2901 2902 if table_only: 2903 table.set("db", None) 2904 table.set("catalog", None) 2905 2906 return table 2907 2908 def _order_projections_and_filter( 2909 self, 2910 query: Query, 2911 target_columns_to_types: t.Dict[str, exp.DataType], 2912 where: t.Optional[exp.Expr] = None, 2913 coerce_types: bool = False, 2914 ) -> Query: 2915 if not isinstance(query, exp.Query) or ( 2916 not where and not coerce_types and query.named_selects == list(target_columns_to_types) 2917 ): 2918 return query 2919 2920 query = t.cast(exp.Query, query.copy()) 2921 with_ = query.args.pop("with_", None) 2922 2923 select_exprs: t.List[exp.Expr] = [ 2924 exp.column(c, quoted=True) for c in target_columns_to_types 2925 ] 2926 if coerce_types and columns_to_types_all_known(target_columns_to_types): 2927 select_exprs = [ 2928 exp.cast(select_exprs[i], col_tpe).as_(col, quoted=True) 2929 for i, (col, col_tpe) in enumerate(target_columns_to_types.items()) 2930 ] 2931 2932 query = exp.select(*select_exprs).from_(query.subquery("_subquery", copy=False), copy=False) 2933 if where: 2934 query = query.where(where, copy=False) 2935 2936 if with_: 2937 query.set("with_", with_) 2938 2939 return query 2940 2941 def _truncate_table(self, table_name: TableName) -> None: 2942 table = exp.to_table(table_name) 2943 self.execute(f"TRUNCATE TABLE {table.sql(dialect=self.dialect, identify=True)}") 2944 2945 def drop_data_object_on_type_mismatch( 2946 self, data_object: t.Optional[DataObject], expected_type: DataObjectType 2947 ) -> bool: 2948 """Drops a data object if it exists and is not of the expected type. 2949 2950 Args: 2951 data_object: The data object to check. 2952 expected_type: The expected type of the data object. 2953 2954 Returns: 2955 True if the data object was dropped, False otherwise. 2956 """ 2957 if data_object is None or data_object.type == expected_type: 2958 return False 2959 2960 logger.warning( 2961 "Target data object '%s' is a %s and not a %s, dropping it", 2962 data_object.to_table().sql(dialect=self.dialect), 2963 data_object.type.value, 2964 expected_type.value, 2965 ) 2966 self.drop_data_object(data_object) 2967 return True 2968 2969 def _replace_by_key( 2970 self, 2971 target_table: TableName, 2972 source_table: QueryOrDF, 2973 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], 2974 key: t.Sequence[exp.Expr], 2975 is_unique_key: bool, 2976 source_columns: t.Optional[t.List[str]] = None, 2977 ) -> None: 2978 if target_columns_to_types is None: 2979 target_columns_to_types = self.columns(target_table) 2980 2981 temp_table = self._get_temp_table(target_table) 2982 key_exp = ( 2983 exp.func("CONCAT_WS", "'__SQLMESH_DELIM__'", *key, dialect=self.dialect) 2984 if len(key) > 1 2985 else key[0] 2986 ) 2987 column_names = list(target_columns_to_types or []) 2988 2989 with self.transaction(): 2990 self.ctas( 2991 temp_table, 2992 source_table, 2993 target_columns_to_types=target_columns_to_types, 2994 exists=False, 2995 source_columns=source_columns, 2996 ) 2997 2998 try: 2999 delete_query = exp.select(key_exp).from_(temp_table) 3000 insert_query = self._select_columns(target_columns_to_types).from_(temp_table) 3001 if not is_unique_key: 3002 delete_query = delete_query.distinct() 3003 else: 3004 insert_query = insert_query.distinct(*key) 3005 3006 insert_statement = exp.insert( 3007 insert_query, 3008 target_table, 3009 columns=column_names, 3010 ) 3011 delete_filter = key_exp.isin(query=delete_query) 3012 3013 if not self.INSERT_OVERWRITE_STRATEGY.is_replace_where: 3014 self.delete_from(target_table, delete_filter) 3015 else: 3016 insert_statement.set("where", delete_filter) 3017 insert_statement.set("this", exp.to_table(target_table)) 3018 3019 self.execute(insert_statement, track_rows_processed=True) 3020 finally: 3021 self.drop_table(temp_table) 3022 3023 def _build_create_comment_table_exp( 3024 self, table: exp.Table, table_comment: str, table_kind: str 3025 ) -> exp.Comment | str: 3026 return exp.Comment( 3027 this=table, 3028 kind=table_kind, 3029 expression=exp.Literal.string(self._truncate_table_comment(table_comment)), 3030 ) 3031 3032 def _create_table_comment( 3033 self, table_name: TableName, table_comment: str, table_kind: str = "TABLE" 3034 ) -> None: 3035 table = exp.to_table(table_name) 3036 3037 try: 3038 self.execute(self._build_create_comment_table_exp(table, table_comment, table_kind)) 3039 except Exception: 3040 logger.warning( 3041 f"Table comment for '{table.alias_or_name}' not registered - this may be due to limited permissions", 3042 exc_info=True, 3043 ) 3044 3045 def _build_create_comment_column_exp( 3046 self, table: exp.Table, column_name: str, column_comment: str, table_kind: str = "TABLE" 3047 ) -> exp.Comment | str: 3048 return exp.Comment( 3049 this=exp.column(column_name, *reversed(table.parts)), # type: ignore 3050 kind="COLUMN", 3051 expression=exp.Literal.string(self._truncate_column_comment(column_comment)), 3052 ) 3053 3054 def _create_column_comments( 3055 self, 3056 table_name: TableName, 3057 column_comments: t.Dict[str, str], 3058 table_kind: str = "TABLE", 3059 materialized_view: bool = False, 3060 ) -> None: 3061 table = exp.to_table(table_name) 3062 3063 for col, comment in column_comments.items(): 3064 try: 3065 self.execute(self._build_create_comment_column_exp(table, col, comment, table_kind)) 3066 except Exception: 3067 logger.warning( 3068 f"Column comments for column '{col}' in table '{table.alias_or_name}' not registered - this may be due to limited permissions", 3069 exc_info=True, 3070 ) 3071 3072 def _create_table_like( 3073 self, 3074 target_table_name: TableName, 3075 source_table_name: TableName, 3076 exists: bool, 3077 **kwargs: t.Any, 3078 ) -> None: 3079 self.create_table(target_table_name, self.columns(source_table_name), exists=exists) 3080 3081 def _rename_table( 3082 self, 3083 old_table_name: TableName, 3084 new_table_name: TableName, 3085 ) -> None: 3086 self.execute(exp.rename_table(old_table_name, new_table_name)) 3087 3088 def ensure_nulls_for_unmatched_after_join( 3089 self, 3090 query: Query, 3091 ) -> Query: 3092 return query 3093 3094 def use_server_nulls_for_unmatched_after_join( 3095 self, 3096 query: Query, 3097 ) -> Query: 3098 return query 3099 3100 def ping(self) -> None: 3101 try: 3102 self._execute(exp.select("1").sql(dialect=self.dialect)) 3103 finally: 3104 self._connection_pool.close_cursor() 3105 3106 @classmethod 3107 def _select_columns( 3108 cls, columns: t.Iterable[str], source_columns: t.Optional[t.List[str]] = None 3109 ) -> exp.Select: 3110 return exp.select( 3111 *( 3112 exp.column(c, quoted=True) 3113 if c in (source_columns or columns) 3114 else exp.alias_(exp.Null(), c, quoted=True) 3115 for c in columns 3116 ) 3117 ) 3118 3119 def _check_identifier_length(self, expression: exp.Expr) -> None: 3120 if self.MAX_IDENTIFIER_LENGTH is None or not isinstance(expression, exp.DDL): 3121 return 3122 3123 for identifier in expression.find_all(exp.Identifier): 3124 name = identifier.name 3125 name_length = len(name) 3126 if name_length > self.MAX_IDENTIFIER_LENGTH: 3127 raise SQLMeshError( 3128 f"Identifier name '{name}' (length {name_length}) exceeds {self.dialect.capitalize()}'s max identifier limit of {self.MAX_IDENTIFIER_LENGTH} characters" 3129 ) 3130 3131 def get_table_last_modified_ts(self, table_names: t.List[TableName]) -> t.List[int]: 3132 raise NotImplementedError() 3133 3134 @classmethod 3135 def _diff_grants_configs( 3136 cls, new_config: GrantsConfig, old_config: GrantsConfig 3137 ) -> t.Tuple[GrantsConfig, GrantsConfig]: 3138 """Compute additions and removals between two grants configurations. 3139 3140 This method compares new (desired) and old (current) GrantsConfigs case-insensitively 3141 for both privilege keys and grantees, while preserving original casing 3142 in the output GrantsConfigs. 3143 3144 Args: 3145 new_config: Desired grants configuration (specified by the user). 3146 old_config: Current grants configuration (returned by the database). 3147 3148 Returns: 3149 A tuple of (additions, removals) GrantsConfig where: 3150 - additions contains privileges/grantees present in new_config but not in old_config 3151 - additions uses keys and grantee strings from new_config (user-specified casing) 3152 - removals contains privileges/grantees present in old_config but not in new_config 3153 - removals uses keys and grantee strings from old_config (database-returned casing) 3154 3155 Notes: 3156 - Comparison is case-insensitive using casefold(); original casing is preserved in results. 3157 - Overlapping grantees (case-insensitive) are excluded from the results. 3158 """ 3159 3160 def _diffs(config1: GrantsConfig, config2: GrantsConfig) -> GrantsConfig: 3161 diffs: GrantsConfig = {} 3162 cf_config2 = {k.casefold(): {g.casefold() for g in v} for k, v in config2.items()} 3163 for key, grantees in config1.items(): 3164 cf_key = key.casefold() 3165 3166 # Missing key (add all grantees) 3167 if cf_key not in cf_config2: 3168 diffs[key] = grantees.copy() 3169 continue 3170 3171 # Include only grantees not in config2 3172 cf_grantees2 = cf_config2[cf_key] 3173 diff_grantees = [] 3174 for grantee in grantees: 3175 if grantee.casefold() not in cf_grantees2: 3176 diff_grantees.append(grantee) 3177 if diff_grantees: 3178 diffs[key] = diff_grantees 3179 return diffs 3180 3181 return _diffs(new_config, old_config), _diffs(old_config, new_config) 3182 3183 def _get_current_grants_config(self, table: exp.Table) -> GrantsConfig: 3184 """Returns current grants for a table as a dictionary. 3185 3186 This method queries the database and returns the current grants/permissions 3187 for the given table, parsed into a dictionary format. The it handles 3188 case-insensitive comparison between these current grants and the desired 3189 grants from model configuration. 3190 3191 Args: 3192 table: The table/view to query grants for. 3193 3194 Returns: 3195 Dictionary mapping permissions to lists of grantees. Permission names 3196 should be returned as the database provides them (typically uppercase 3197 for standard SQL permissions, but engine-specific roles may vary). 3198 3199 Raises: 3200 NotImplementedError: If the engine does not support grants. 3201 """ 3202 if not self.SUPPORTS_GRANTS: 3203 raise NotImplementedError(f"Engine does not support grants: {type(self)}") 3204 raise NotImplementedError("Subclass must implement get_current_grants") 3205 3206 def _apply_grants_config_expr( 3207 self, 3208 table: exp.Table, 3209 grants_config: GrantsConfig, 3210 table_type: DataObjectType = DataObjectType.TABLE, 3211 ) -> t.List[exp.Expr]: 3212 """Returns SQLGlot Grant expressions to apply grants to a table. 3213 3214 Args: 3215 table: The table/view to grant permissions on. 3216 grants_config: Dictionary mapping permissions to lists of grantees. 3217 table_type: The type of database object (TABLE, VIEW, MATERIALIZED_VIEW). 3218 3219 Returns: 3220 List of SQLGlot expressions for grant operations. 3221 3222 Raises: 3223 NotImplementedError: If the engine does not support grants. 3224 """ 3225 if not self.SUPPORTS_GRANTS: 3226 raise NotImplementedError(f"Engine does not support grants: {type(self)}") 3227 raise NotImplementedError("Subclass must implement _apply_grants_config_expr") 3228 3229 def _revoke_grants_config_expr( 3230 self, 3231 table: exp.Table, 3232 grants_config: GrantsConfig, 3233 table_type: DataObjectType = DataObjectType.TABLE, 3234 ) -> t.List[exp.Expr]: 3235 """Returns SQLGlot expressions to revoke grants from a table. 3236 3237 Args: 3238 table: The table/view to revoke permissions from. 3239 grants_config: Dictionary mapping permissions to lists of grantees. 3240 table_type: The type of database object (TABLE, VIEW, MATERIALIZED_VIEW). 3241 3242 Returns: 3243 List of SQLGlot expressions for revoke operations. 3244 3245 Raises: 3246 NotImplementedError: If the engine does not support grants. 3247 """ 3248 if not self.SUPPORTS_GRANTS: 3249 raise NotImplementedError(f"Engine does not support grants: {type(self)}") 3250 raise NotImplementedError("Subclass must implement _revoke_grants_config_expr") 3251 3252 3253class EngineAdapterWithIndexSupport(EngineAdapter): 3254 SUPPORTS_INDEXES = True 3255 3256 3257def _decoded_str(value: t.Union[str, bytes]) -> str: 3258 if isinstance(value, bytes): 3259 return value.decode("utf-8") 3260 return value 3261 3262 3263def _get_data_object_cache_key(catalog: t.Optional[str], schema_name: str, object_name: str) -> str: 3264 """Returns a cache key for a data object based on its fully qualified name.""" 3265 catalog = f"{catalog}." if catalog else "" 3266 return f"{catalog}{schema_name}.{object_name}"
84@set_catalog() 85class EngineAdapter: 86 """Base class wrapping a Database API compliant connection. 87 88 The EngineAdapter is an easily-subclassable interface that interacts 89 with the underlying engine and data store. 90 91 Args: 92 connection_factory_or_pool: a callable which produces a new Database API-compliant 93 connection on every call. 94 dialect: The dialect with which this adapter is associated. 95 multithreaded: Indicates whether this adapter will be used by more than one thread. 96 """ 97 98 DIALECT = "" 99 DEFAULT_BATCH_SIZE = 10000 100 DATA_OBJECT_FILTER_BATCH_SIZE = 4000 101 SUPPORTS_TRANSACTIONS = True 102 SUPPORTS_INDEXES = False 103 COMMENT_CREATION_TABLE = CommentCreationTable.IN_SCHEMA_DEF_CTAS 104 COMMENT_CREATION_VIEW = CommentCreationView.IN_SCHEMA_DEF_AND_COMMANDS 105 MAX_TABLE_COMMENT_LENGTH: t.Optional[int] = None 106 MAX_COLUMN_COMMENT_LENGTH: t.Optional[int] = None 107 INSERT_OVERWRITE_STRATEGY = InsertOverwriteStrategy.DELETE_INSERT 108 SUPPORTS_MATERIALIZED_VIEWS = False 109 SUPPORTS_MATERIALIZED_VIEW_SCHEMA = False 110 SUPPORTS_VIEW_SCHEMA = True 111 SUPPORTS_CLONING = False 112 SUPPORTS_MANAGED_MODELS = False 113 SUPPORTS_CREATE_DROP_CATALOG = False 114 SUPPORTED_DROP_CASCADE_OBJECT_KINDS: t.List[str] = [] 115 SCHEMA_DIFFER_KWARGS: t.Dict[str, t.Any] = {} 116 SUPPORTS_TUPLE_IN = True 117 HAS_VIEW_BINDING = False 118 RECREATE_MATERIALIZED_VIEW_ON_EVALUATION = True 119 SUPPORTS_REPLACE_TABLE = True 120 SUPPORTS_GRANTS = False 121 DEFAULT_CATALOG_TYPE = DIALECT 122 QUOTE_IDENTIFIERS_IN_VIEWS = True 123 MAX_IDENTIFIER_LENGTH: t.Optional[int] = None 124 ATTACH_CORRELATION_ID = True 125 SUPPORTS_QUERY_EXECUTION_TRACKING = False 126 SUPPORTS_METADATA_TABLE_LAST_MODIFIED_TS = False 127 RESOLVE_TABLE_REFS_IN_PHYSICAL_PROPERTIES: t.FrozenSet[str] = frozenset() 128 """Physical property keys whose values may contain logical model references that 129 should be resolved to physical table names during property rendering. Engines that 130 need such resolution (e.g. StarRocks' excluded_trigger_tables) override this set.""" 131 132 def __init__( 133 self, 134 connection_factory_or_pool: t.Union[t.Callable[[], t.Any], ConnectionPool], 135 dialect: str = "", 136 sql_gen_kwargs: t.Optional[t.Dict[str, Dialect | bool | str]] = None, 137 multithreaded: bool = False, 138 cursor_init: t.Optional[t.Callable[[t.Any], None]] = None, 139 default_catalog: t.Optional[str] = None, 140 execute_log_level: int = logging.DEBUG, 141 register_comments: bool = True, 142 pre_ping: bool = False, 143 pretty_sql: bool = False, 144 shared_connection: bool = False, 145 correlation_id: t.Optional[CorrelationId] = None, 146 schema_differ_overrides: t.Optional[t.Dict[str, t.Any]] = None, 147 query_execution_tracker: t.Optional[QueryExecutionTracker] = None, 148 **kwargs: t.Any, 149 ): 150 self.dialect = dialect.lower() or self.DIALECT 151 self._connection_pool = ( 152 connection_factory_or_pool 153 if isinstance(connection_factory_or_pool, ConnectionPool) 154 else create_connection_pool( 155 connection_factory_or_pool, 156 multithreaded, 157 shared_connection=shared_connection, 158 cursor_init=cursor_init, 159 ) 160 ) 161 self._sql_gen_kwargs = sql_gen_kwargs or {} 162 self._default_catalog = default_catalog 163 self._execute_log_level = execute_log_level 164 self._extra_config = kwargs 165 self._register_comments = register_comments 166 self._pre_ping = pre_ping 167 self._pretty_sql = pretty_sql 168 self._multithreaded = multithreaded 169 self.correlation_id = correlation_id 170 self._schema_differ_overrides = schema_differ_overrides 171 self._query_execution_tracker = query_execution_tracker 172 self._data_object_cache: t.Dict[str, t.Optional[DataObject]] = {} 173 174 def with_settings(self, **kwargs: t.Any) -> EngineAdapter: 175 extra_kwargs = { 176 "null_connection": True, 177 "execute_log_level": kwargs.pop("execute_log_level", self._execute_log_level), 178 "correlation_id": kwargs.pop("correlation_id", self.correlation_id), 179 "query_execution_tracker": kwargs.pop( 180 "query_execution_tracker", self._query_execution_tracker 181 ), 182 **self._extra_config, 183 **kwargs, 184 } 185 186 adapter = self.__class__( 187 self._connection_pool, 188 dialect=self.dialect, 189 sql_gen_kwargs=self._sql_gen_kwargs, 190 default_catalog=self._default_catalog, 191 register_comments=self._register_comments, 192 multithreaded=self._multithreaded, 193 pretty_sql=self._pretty_sql, 194 **extra_kwargs, 195 ) 196 197 return adapter 198 199 @property 200 def cursor(self) -> t.Any: 201 return self._connection_pool.get_cursor() 202 203 @property 204 def connection(self) -> t.Any: 205 return self._connection_pool.get() 206 207 @property 208 def spark(self) -> t.Optional[PySparkSession]: 209 return None 210 211 @property 212 def snowpark(self) -> t.Optional[SnowparkSession]: 213 return None 214 215 @property 216 def bigframe(self) -> t.Optional[BigframeSession]: 217 return None 218 219 @property 220 def comments_enabled(self) -> bool: 221 return self._register_comments and self.COMMENT_CREATION_TABLE.is_supported 222 223 @property 224 def catalog_support(self) -> CatalogSupport: 225 return CatalogSupport.UNSUPPORTED 226 227 def supports_virtual_catalog(self) -> bool: 228 """Return True if this adapter can accept a virtual catalog for multi-gateway nesting alignment. 229 230 When a project mixes catalog-aware gateways (e.g. DuckDB) with catalog-unsupported gateways 231 (e.g. ClickHouse), all adapters need a uniform 3-level FQN so MappingSchema nesting stays 232 consistent. Adapters that return True here opt in to receiving an injected virtual catalog 233 via inject_virtual_catalog(), which causes the set_catalog decorator to strip the catalog 234 from DDL expressions rather than raising UnsupportedCatalogOperationError. 235 """ 236 return False 237 238 def inject_virtual_catalog(self, gateway: str) -> None: 239 """Inject a gateway name to configure the adapter's virtual catalog. 240 241 The adapter determines the final catalog name from the gateway name (e.g. ClickHouse 242 wraps it as __{gateway}__). Only call this on adapters that return True from 243 supports_virtual_catalog(). After injection, catalog_support should return 244 SINGLE_CATALOG_ONLY so the set_catalog decorator strips the virtual catalog from DDL 245 expressions instead of raising an error. 246 """ 247 raise NotImplementedError( 248 f"{self.dialect} does not support virtual catalog injection. " 249 "Override supports_virtual_catalog() to return True and implement inject_virtual_catalog()." 250 ) 251 252 @cached_property 253 def schema_differ(self) -> SchemaDiffer: 254 return SchemaDiffer( 255 **{ 256 **self.SCHEMA_DIFFER_KWARGS, 257 **(self._schema_differ_overrides or {}), 258 } 259 ) 260 261 @property 262 def _catalog_type_overrides(self) -> t.Dict[str, str]: 263 return self._extra_config.get("catalog_type_overrides") or {} 264 265 @classmethod 266 def _casted_columns( 267 cls, 268 target_columns_to_types: t.Dict[str, exp.DataType], 269 source_columns: t.Optional[t.List[str]] = None, 270 ) -> t.List[exp.Expr]: 271 source_columns_lookup = set(source_columns or target_columns_to_types) 272 return [ 273 exp.alias_( 274 exp.cast( 275 exp.column(column, quoted=True) 276 if column in source_columns_lookup 277 else exp.Null(), 278 to=kind, 279 ), 280 column, 281 copy=False, 282 quoted=True, 283 ) 284 for column, kind in target_columns_to_types.items() 285 ] 286 287 @property 288 def default_catalog(self) -> t.Optional[str]: 289 if self.catalog_support.is_unsupported: 290 return None 291 default_catalog = self._default_catalog or self.get_current_catalog() 292 if not default_catalog: 293 raise MissingDefaultCatalogError( 294 "Could not determine a default catalog despite it being supported." 295 ) 296 return default_catalog 297 298 @property 299 def engine_run_mode(self) -> EngineRunMode: 300 return EngineRunMode.SINGLE_MODE_ENGINE 301 302 def _get_source_queries( 303 self, 304 query_or_df: QueryOrDF, 305 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], 306 target_table: TableName, 307 *, 308 batch_size: t.Optional[int] = None, 309 source_columns: t.Optional[t.List[str]] = None, 310 ) -> t.List[SourceQuery]: 311 import pandas as pd 312 313 batch_size = self.DEFAULT_BATCH_SIZE if batch_size is None else batch_size 314 if isinstance(query_or_df, exp.Query): 315 query_factory = lambda: query_or_df 316 if source_columns: 317 source_columns_lookup = set(source_columns) 318 if not target_columns_to_types: 319 raise SQLMeshError("columns_to_types must be set if source_columns is set") 320 if not set(target_columns_to_types).issubset(source_columns_lookup): 321 select_columns = [ 322 exp.column(c, quoted=True) 323 if c in source_columns_lookup 324 else exp.cast(exp.Null(), target_columns_to_types[c], copy=False).as_( 325 c, copy=False, quoted=True 326 ) 327 for c in target_columns_to_types 328 ] 329 query_factory = lambda: ( 330 exp.Select() 331 .select(*select_columns) 332 .from_(query_or_df.subquery("select_source_columns")) 333 ) 334 return [SourceQuery(query_factory=query_factory)] # type: ignore 335 336 if not target_columns_to_types: 337 raise SQLMeshError( 338 "It is expected that if a DataFrame is passed in then columns_to_types is set" 339 ) 340 341 if isinstance(query_or_df, pd.DataFrame) and query_or_df.empty: 342 raise SQLMeshError( 343 "Cannot construct source query from an empty DataFrame. This error is commonly " 344 "related to Python models that produce no data. For such models, consider yielding " 345 "from an empty generator if the resulting set is empty, i.e. use `yield from ()`." 346 ) 347 348 return self._df_to_source_queries( 349 query_or_df, 350 target_columns_to_types, 351 batch_size, 352 target_table=target_table, 353 source_columns=source_columns, 354 ) 355 356 def _df_to_source_queries( 357 self, 358 df: DF, 359 target_columns_to_types: t.Dict[str, exp.DataType], 360 batch_size: int, 361 target_table: TableName, 362 source_columns: t.Optional[t.List[str]] = None, 363 ) -> t.List[SourceQuery]: 364 import pandas as pd 365 366 assert isinstance(df, pd.DataFrame) 367 num_rows = len(df.index) 368 batch_size = sys.maxsize if batch_size == 0 else batch_size 369 370 # we need to ensure that the order of the columns in columns_to_types columns matches the order of the values 371 # they can differ if a user specifies columns() on a python model in a different order than what's in the DataFrame's emitted by that model 372 df = df[list(source_columns or target_columns_to_types)] 373 values = list(df.itertuples(index=False, name=None)) 374 375 return [ 376 SourceQuery( 377 query_factory=partial( 378 self._values_to_sql, 379 values=values, # type: ignore 380 target_columns_to_types=target_columns_to_types, 381 batch_start=i, 382 batch_end=min(i + batch_size, num_rows), 383 source_columns=source_columns, 384 ), 385 ) 386 for i in range(0, num_rows, batch_size) 387 ] 388 389 def _get_source_queries_and_columns_to_types( 390 self, 391 query_or_df: QueryOrDF, 392 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], 393 target_table: TableName, 394 *, 395 batch_size: t.Optional[int] = None, 396 source_columns: t.Optional[t.List[str]] = None, 397 ) -> t.Tuple[t.List[SourceQuery], t.Optional[t.Dict[str, exp.DataType]]]: 398 target_columns_to_types, source_columns = self._columns_to_types( 399 query_or_df, target_columns_to_types, source_columns 400 ) 401 source_queries = self._get_source_queries( 402 query_or_df, 403 target_columns_to_types, 404 target_table=target_table, 405 batch_size=batch_size, 406 source_columns=source_columns, 407 ) 408 return source_queries, target_columns_to_types 409 410 @t.overload 411 def _columns_to_types( 412 self, 413 query_or_df: DF, 414 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 415 source_columns: t.Optional[t.List[str]] = None, 416 ) -> t.Tuple[t.Dict[str, exp.DataType], t.List[str]]: ... 417 418 @t.overload 419 def _columns_to_types( 420 self, 421 query_or_df: Query, 422 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 423 source_columns: t.Optional[t.List[str]] = None, 424 ) -> t.Tuple[t.Optional[t.Dict[str, exp.DataType]], t.Optional[t.List[str]]]: ... 425 426 def _columns_to_types( 427 self, 428 query_or_df: QueryOrDF, 429 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 430 source_columns: t.Optional[t.List[str]] = None, 431 ) -> t.Tuple[t.Optional[t.Dict[str, exp.DataType]], t.Optional[t.List[str]]]: 432 import pandas as pd 433 434 if not target_columns_to_types and isinstance(query_or_df, pd.DataFrame): 435 target_columns_to_types = columns_to_types_from_df(t.cast(pd.DataFrame, query_or_df)) 436 if not source_columns and target_columns_to_types: 437 source_columns = list(target_columns_to_types) 438 # source columns should only contain columns that are defined in the target. If there are extras then 439 # that means they are intended to be ignored and will be excluded 440 source_columns = ( 441 [x for x in source_columns if x in target_columns_to_types] 442 if source_columns and target_columns_to_types 443 else None 444 ) 445 return target_columns_to_types, source_columns 446 447 def recycle(self) -> None: 448 """Closes all open connections and releases all allocated resources associated with any thread 449 except the calling one.""" 450 self._connection_pool.close_all(exclude_calling_thread=True) 451 452 def close(self) -> t.Any: 453 """Closes all open connections and releases all allocated resources.""" 454 self._connection_pool.close_all() 455 456 def get_current_catalog(self) -> t.Optional[str]: 457 """Returns the catalog name of the current connection.""" 458 raise NotImplementedError() 459 460 def set_current_catalog(self, catalog: str) -> None: 461 """Sets the catalog name of the current connection.""" 462 raise NotImplementedError() 463 464 def get_catalog_type(self, catalog: t.Optional[str]) -> str: 465 """Intended to be overridden for data virtualization systems like Trino that, 466 depending on the target catalog, require slightly different properties to be set when creating / updating tables 467 """ 468 if self.catalog_support.is_unsupported: 469 raise UnsupportedCatalogOperationError( 470 f"{self.dialect} does not support catalogs and a catalog was provided: {catalog}" 471 ) 472 return ( 473 self._catalog_type_overrides.get(catalog, self.DEFAULT_CATALOG_TYPE) 474 if catalog 475 else self.DEFAULT_CATALOG_TYPE 476 ) 477 478 def get_catalog_type_from_table(self, table: TableName) -> str: 479 """Get the catalog type from a table name if it has a catalog specified, otherwise return the current catalog type""" 480 catalog = exp.to_table(table).catalog or self.get_current_catalog() 481 return self.get_catalog_type(catalog) 482 483 @property 484 def current_catalog_type(self) -> str: 485 # `get_catalog_type_from_table` should be used over this property. Reason is that the table that is the target 486 # of the operation is what matters and not the catalog type of the connection. 487 # This still remains for legacy reasons and should be refactored out. 488 return self.get_catalog_type(self.get_current_catalog()) 489 490 def replace_query( 491 self, 492 table_name: TableName, 493 query_or_df: QueryOrDF, 494 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 495 table_description: t.Optional[str] = None, 496 column_descriptions: t.Optional[t.Dict[str, str]] = None, 497 source_columns: t.Optional[t.List[str]] = None, 498 supports_replace_table_override: t.Optional[bool] = None, 499 **kwargs: t.Any, 500 ) -> None: 501 """Replaces an existing table with a query. 502 503 For partition based engines (hive, spark), insert override is used. For other systems, create or replace is used. 504 505 Args: 506 table_name: The name of the table (eg. prod.table) 507 query_or_df: The SQL query to run or a dataframe. 508 target_columns_to_types: Only used if a dataframe is provided. A mapping between the column name and its data type. 509 Expected to be ordered to match the order of values in the dataframe. 510 kwargs: Optional create table properties. 511 """ 512 target_table = exp.to_table(table_name) 513 514 target_data_object = self.get_data_object(target_table) 515 table_exists = target_data_object is not None 516 if self.drop_data_object_on_type_mismatch(target_data_object, DataObjectType.TABLE): 517 table_exists = False 518 519 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 520 query_or_df, 521 target_columns_to_types, 522 target_table=target_table, 523 source_columns=source_columns, 524 ) 525 if not target_columns_to_types and table_exists: 526 target_columns_to_types = self.columns(target_table) 527 query = source_queries[0].query_factory() 528 self_referencing = any( 529 quote_identifiers(table) == quote_identifiers(target_table) 530 for table in query.find_all(exp.Table) 531 ) 532 # If a query references itself then it must have a table created regardless of approach used. 533 if self_referencing: 534 if not target_columns_to_types: 535 raise SQLMeshError( 536 f"Cannot create a self-referencing table {target_table.sql(dialect=self.dialect)} without knowing the column types. " 537 "Try casting the columns to an expected type or defining the columns in the model metadata. " 538 ) 539 self._create_table_from_columns( 540 target_table, 541 target_columns_to_types, 542 exists=True, 543 table_description=table_description, 544 column_descriptions=column_descriptions, 545 **kwargs, 546 ) 547 # All engines support `CREATE TABLE AS` so we use that if the table doesn't already exist and we 548 # use `CREATE OR REPLACE TABLE AS` if the engine supports it 549 supports_replace_table = ( 550 self.SUPPORTS_REPLACE_TABLE 551 if supports_replace_table_override is None 552 else supports_replace_table_override 553 ) 554 if supports_replace_table or not table_exists: 555 return self._create_table_from_source_queries( 556 target_table, 557 source_queries, 558 target_columns_to_types, 559 replace=supports_replace_table, 560 table_description=table_description, 561 column_descriptions=column_descriptions, 562 **kwargs, 563 ) 564 if self_referencing: 565 assert target_columns_to_types is not None 566 with self.temp_table( 567 self._select_columns(target_columns_to_types).from_(target_table), 568 name=target_table, 569 target_columns_to_types=target_columns_to_types, 570 **kwargs, 571 ) as temp_table: 572 for source_query in source_queries: 573 source_query.add_transform( 574 lambda node: ( # type: ignore 575 temp_table # type: ignore 576 if isinstance(node, exp.Table) 577 and quote_identifiers(node) == quote_identifiers(target_table) 578 else node 579 ) 580 ) 581 return self._insert_overwrite_by_condition( 582 target_table, 583 source_queries, 584 target_columns_to_types, 585 **kwargs, 586 ) 587 return self._insert_overwrite_by_condition( 588 target_table, 589 source_queries, 590 target_columns_to_types, 591 **kwargs, 592 ) 593 594 def create_index( 595 self, 596 table_name: TableName, 597 index_name: str, 598 columns: t.Tuple[str, ...], 599 exists: bool = True, 600 ) -> None: 601 """Creates a new index for the given table if supported 602 603 Args: 604 table_name: The name of the target table. 605 index_name: The name of the index. 606 columns: The list of columns that constitute the index. 607 exists: Indicates whether to include the IF NOT EXISTS check. 608 """ 609 if not self.SUPPORTS_INDEXES: 610 return 611 612 expression = exp.Create( 613 this=exp.Index( 614 this=exp.to_identifier(index_name), 615 table=exp.to_table(table_name), 616 params=exp.IndexParameters(columns=[exp.to_column(c) for c in columns]), 617 ), 618 kind="INDEX", 619 exists=exists, 620 ) 621 self.execute(expression) 622 623 def _pop_creatable_type_from_properties( 624 self, 625 properties: t.Dict[str, exp.Expr], 626 ) -> t.Optional[exp.Property]: 627 """Pop out the creatable_type from the properties dictionary (if exists (return it/remove it) else return none). 628 It also checks that none of the expressions are MATERIALIZE as that conflicts with the `materialize` parameter. 629 """ 630 for key in list(properties.keys()): 631 upper_key = key.upper() 632 if upper_key == KEY_FOR_CREATABLE_TYPE: 633 value = properties.pop(key).name 634 parsed_properties = exp.maybe_parse( 635 value, into=exp.Properties, dialect=self.dialect 636 ) 637 property, *others = parsed_properties.expressions 638 if others: 639 # Multiple properties are unsupported today, can look into it in the future if needed 640 raise SQLMeshError( 641 f"Invalid creatable_type value with multiple properties: {value}" 642 ) 643 if isinstance(property, exp.MaterializedProperty): 644 raise SQLMeshError( 645 f"Cannot use {value} as a creatable_type as it conflicts with the `materialize` parameter." 646 ) 647 return property 648 return None 649 650 def create_table( 651 self, 652 table_name: TableName, 653 target_columns_to_types: t.Dict[str, exp.DataType], 654 primary_key: t.Optional[t.Tuple[str, ...]] = None, 655 exists: bool = True, 656 table_description: t.Optional[str] = None, 657 column_descriptions: t.Optional[t.Dict[str, str]] = None, 658 **kwargs: t.Any, 659 ) -> None: 660 """Create a table using a DDL statement 661 662 Args: 663 table_name: The name of the table to create. Can be fully qualified or just table name. 664 target_columns_to_types: A mapping between the column name and its data type. 665 primary_key: Determines the table primary key. 666 exists: Indicates whether to include the IF NOT EXISTS check. 667 table_description: Optional table description from MODEL DDL. 668 column_descriptions: Optional column descriptions from model query. 669 kwargs: Optional create table properties. 670 """ 671 self._create_table_from_columns( 672 table_name, 673 target_columns_to_types, 674 primary_key, 675 exists, 676 table_description, 677 column_descriptions, 678 **kwargs, 679 ) 680 681 def create_managed_table( 682 self, 683 table_name: TableName, 684 query: Query, 685 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 686 partitioned_by: t.Optional[t.List[exp.Expr]] = None, 687 clustered_by: t.Optional[t.List[exp.Expr]] = None, 688 table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, 689 table_description: t.Optional[str] = None, 690 column_descriptions: t.Optional[t.Dict[str, str]] = None, 691 source_columns: t.Optional[t.List[str]] = None, 692 **kwargs: t.Any, 693 ) -> None: 694 """Create a managed table using a query. 695 696 "Managed" means that once the table is created, the data is kept up to date by the underlying database engine and not SQLMesh. 697 698 Args: 699 table_name: The name of the table to create. Can be fully qualified or just table name. 700 query: The SQL query for the engine to base the managed table on 701 target_columns_to_types: A mapping between the column name and its data type. 702 partitioned_by: The partition columns or engine specific expressions, only applicable in certain engines. (eg. (ds, hour)) 703 clustered_by: The cluster columns or engine specific expressions, only applicable in certain engines. (eg. (ds, hour)) 704 table_properties: Optional mapping of engine-specific properties to be set on the managed table 705 table_description: Optional table description from MODEL DDL. 706 column_descriptions: Optional column descriptions from model query. 707 kwargs: Optional create table properties. 708 """ 709 raise NotImplementedError(f"Engine does not support managed tables: {type(self)}") 710 711 def ctas( 712 self, 713 table_name: TableName, 714 query_or_df: QueryOrDF, 715 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 716 exists: bool = True, 717 table_description: t.Optional[str] = None, 718 column_descriptions: t.Optional[t.Dict[str, str]] = None, 719 source_columns: t.Optional[t.List[str]] = None, 720 **kwargs: t.Any, 721 ) -> None: 722 """Create a table using a CTAS statement 723 724 Args: 725 table_name: The name of the table to create. Can be fully qualified or just table name. 726 query_or_df: The SQL query to run or a dataframe for the CTAS. 727 target_columns_to_types: A mapping between the column name and its data type. Required if using a DataFrame. 728 exists: Indicates whether to include the IF NOT EXISTS check. 729 table_description: Optional table description from MODEL DDL. 730 column_descriptions: Optional column descriptions from model query. 731 kwargs: Optional create table properties. 732 """ 733 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 734 query_or_df, 735 target_columns_to_types, 736 target_table=table_name, 737 source_columns=source_columns, 738 ) 739 return self._create_table_from_source_queries( 740 table_name, 741 source_queries, 742 target_columns_to_types, 743 exists, 744 table_description=table_description, 745 column_descriptions=column_descriptions, 746 **kwargs, 747 ) 748 749 def create_state_table( 750 self, 751 table_name: str, 752 target_columns_to_types: t.Dict[str, exp.DataType], 753 primary_key: t.Optional[t.Tuple[str, ...]] = None, 754 ) -> None: 755 """Create a table to store SQLMesh internal state. 756 757 Args: 758 table_name: The name of the table to create. Can be fully qualified or just table name. 759 target_columns_to_types: A mapping between the column name and its data type. 760 primary_key: Determines the table primary key. 761 """ 762 self.create_table( 763 table_name, 764 target_columns_to_types, 765 primary_key=primary_key, 766 ) 767 768 def _create_table_from_columns( 769 self, 770 table_name: TableName, 771 target_columns_to_types: t.Dict[str, exp.DataType], 772 primary_key: t.Optional[t.Tuple[str, ...]] = None, 773 exists: bool = True, 774 table_description: t.Optional[str] = None, 775 column_descriptions: t.Optional[t.Dict[str, str]] = None, 776 **kwargs: t.Any, 777 ) -> None: 778 """ 779 Create a table using a DDL statement. 780 781 Args: 782 table_name: The name of the table to create. Can be fully qualified or just table name. 783 target_columns_to_types: Mapping between the column name and its data type. 784 primary_key: Determines the table primary key. 785 exists: Indicates whether to include the IF NOT EXISTS check. 786 table_description: Optional table description from MODEL DDL. 787 column_descriptions: Optional column descriptions from model query. 788 kwargs: Optional create table properties. 789 """ 790 table = exp.to_table(table_name) 791 792 if not columns_to_types_all_known(target_columns_to_types): 793 # It is ok if the columns types are not known if the table already exists and IF NOT EXISTS is set 794 if exists and self.table_exists(table_name): 795 return 796 raise SQLMeshError( 797 "Cannot create a table without knowing the column types. " 798 "Try casting the columns to an expected type or defining the columns in the model metadata. " 799 f"Columns to types: {target_columns_to_types}" 800 ) 801 802 primary_key_expression = ( 803 [exp.PrimaryKey(expressions=[exp.to_column(k) for k in primary_key])] 804 if primary_key and self.SUPPORTS_INDEXES 805 else [] 806 ) 807 808 schema = self._build_schema_exp( 809 table, 810 target_columns_to_types, 811 column_descriptions, 812 primary_key_expression, 813 ) 814 815 self._create_table( 816 schema, 817 None, 818 exists=exists, 819 target_columns_to_types=target_columns_to_types, 820 table_description=table_description, 821 **kwargs, 822 ) 823 824 # Register comments with commands if the engine doesn't support comments in the schema or CREATE 825 if ( 826 table_description 827 and self.COMMENT_CREATION_TABLE.is_comment_command_only 828 and self.comments_enabled 829 ): 830 self._create_table_comment(table_name, table_description) 831 if ( 832 column_descriptions 833 and self.COMMENT_CREATION_TABLE.is_comment_command_only 834 and self.comments_enabled 835 ): 836 self._create_column_comments(table_name, column_descriptions) 837 838 def _build_schema_exp( 839 self, 840 table: exp.Table, 841 target_columns_to_types: t.Dict[str, exp.DataType], 842 column_descriptions: t.Optional[t.Dict[str, str]] = None, 843 expressions: t.Optional[t.List[exp.PrimaryKey]] = None, 844 is_view: bool = False, 845 materialized: bool = False, 846 ) -> exp.Schema: 847 """ 848 Build a schema expression for a table, columns, column comments, and additional schema properties. 849 """ 850 expressions = expressions or [] 851 852 return exp.Schema( 853 this=table, 854 expressions=self._build_column_defs( 855 target_columns_to_types=target_columns_to_types, 856 column_descriptions=column_descriptions, 857 is_view=is_view, 858 materialized=materialized, 859 ) 860 + expressions, 861 ) 862 863 def _build_column_defs( 864 self, 865 target_columns_to_types: t.Dict[str, exp.DataType], 866 column_descriptions: t.Optional[t.Dict[str, str]] = None, 867 is_view: bool = False, 868 materialized: bool = False, 869 ) -> t.List[exp.ColumnDef]: 870 engine_supports_schema_comments = ( 871 self.COMMENT_CREATION_VIEW.supports_schema_def 872 if is_view 873 else self.COMMENT_CREATION_TABLE.supports_schema_def 874 ) 875 return [ 876 self._build_column_def( 877 column, 878 column_descriptions=column_descriptions, 879 engine_supports_schema_comments=engine_supports_schema_comments, 880 col_type=None if is_view else kind, # don't include column data type for views 881 ) 882 for column, kind in target_columns_to_types.items() 883 ] 884 885 def _build_column_def( 886 self, 887 col_name: str, 888 column_descriptions: t.Optional[t.Dict[str, str]] = None, 889 engine_supports_schema_comments: bool = False, 890 col_type: t.Optional[exp.DATA_TYPE] = None, 891 nested_names: t.List[str] = [], 892 ) -> exp.ColumnDef: 893 return exp.ColumnDef( 894 this=exp.to_identifier(col_name), 895 kind=col_type, 896 constraints=( 897 self._build_col_comment_exp(col_name, column_descriptions) 898 if engine_supports_schema_comments and self.comments_enabled and column_descriptions 899 else None 900 ), 901 ) 902 903 def _build_col_comment_exp( 904 self, col_name: str, column_descriptions: t.Dict[str, str] 905 ) -> t.List[exp.ColumnConstraint]: 906 comment = column_descriptions.get(col_name, None) 907 if comment: 908 return [ 909 exp.ColumnConstraint( 910 kind=exp.CommentColumnConstraint( 911 this=exp.Literal.string(self._truncate_column_comment(comment)) 912 ) 913 ) 914 ] 915 return [] 916 917 def _create_table_from_source_queries( 918 self, 919 table_name: TableName, 920 source_queries: t.List[SourceQuery], 921 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 922 exists: bool = True, 923 replace: bool = False, 924 table_description: t.Optional[str] = None, 925 column_descriptions: t.Optional[t.Dict[str, str]] = None, 926 table_kind: t.Optional[str] = None, 927 track_rows_processed: bool = True, 928 **kwargs: t.Any, 929 ) -> None: 930 table = exp.to_table(table_name) 931 932 # CTAS calls do not usually include a schema expression. However, most engines 933 # permit them in CTAS expressions, and they allow us to register all column comments 934 # in a single call rather than in a separate comment command call for each column. 935 # 936 # This block conditionally builds a schema expression with column comments if the engine 937 # supports it and we have columns_to_types. column_to_types is required because the 938 # schema expression must include at least column name, data type, and the comment - 939 # for example, `(colname INTEGER COMMENT 'comment')`. 940 # 941 # column_to_types will be available when loading from a DataFrame (by converting from 942 # pandas to SQL types), when a model is "annotated" by explicitly specifying column 943 # types, and for evaluation methods like `LogicalReplaceQueryMixin.replace_query()` 944 # calls and SCD Type 2 model calls. 945 schema = None 946 target_columns_to_types_known = target_columns_to_types and columns_to_types_all_known( 947 target_columns_to_types 948 ) 949 if ( 950 column_descriptions 951 and target_columns_to_types_known 952 and self.COMMENT_CREATION_TABLE.is_in_schema_def_ctas 953 and self.comments_enabled 954 ): 955 schema = self._build_schema_exp(table, target_columns_to_types, column_descriptions) # type: ignore 956 957 with self.transaction(condition=len(source_queries) > 1): 958 for i, source_query in enumerate(source_queries): 959 with source_query as query: 960 if target_columns_to_types and target_columns_to_types_known: 961 query = self._order_projections_and_filter( 962 query, target_columns_to_types, coerce_types=True 963 ) 964 if i == 0: 965 self._create_table( 966 schema if schema else table, 967 query, 968 target_columns_to_types=target_columns_to_types, 969 exists=exists, 970 replace=replace, 971 table_description=table_description, 972 table_kind=table_kind, 973 track_rows_processed=track_rows_processed, 974 **kwargs, 975 ) 976 else: 977 self._insert_append_query( 978 table_name, 979 query, 980 target_columns_to_types or self.columns(table), 981 track_rows_processed=track_rows_processed, 982 ) 983 984 # Register comments with commands if the engine supports comments and we weren't able to 985 # register them with the CTAS call's schema expression. 986 if ( 987 table_description 988 and self.COMMENT_CREATION_TABLE.is_comment_command_only 989 and self.comments_enabled 990 ): 991 self._create_table_comment(table_name, table_description) 992 if column_descriptions and schema is None and self.comments_enabled: 993 self._create_column_comments(table_name, column_descriptions) 994 995 def _create_table( 996 self, 997 table_name_or_schema: t.Union[exp.Schema, TableName], 998 expression: t.Optional[exp.Expr], 999 exists: bool = True, 1000 replace: bool = False, 1001 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1002 table_description: t.Optional[str] = None, 1003 column_descriptions: t.Optional[t.Dict[str, str]] = None, 1004 table_kind: t.Optional[str] = None, 1005 track_rows_processed: bool = True, 1006 **kwargs: t.Any, 1007 ) -> None: 1008 self.execute( 1009 self._build_create_table_exp( 1010 table_name_or_schema, 1011 expression=expression, 1012 exists=exists, 1013 replace=replace, 1014 target_columns_to_types=target_columns_to_types, 1015 table_description=( 1016 table_description 1017 if self.COMMENT_CREATION_TABLE.supports_schema_def and self.comments_enabled 1018 else None 1019 ), 1020 table_kind=table_kind, 1021 **kwargs, 1022 ), 1023 track_rows_processed=track_rows_processed, 1024 ) 1025 # Extract table name to clear cache 1026 table_name = ( 1027 table_name_or_schema.this 1028 if isinstance(table_name_or_schema, exp.Schema) 1029 else table_name_or_schema 1030 ) 1031 self._clear_data_object_cache(table_name) 1032 1033 def _build_create_table_exp( 1034 self, 1035 table_name_or_schema: t.Union[exp.Schema, TableName], 1036 expression: t.Optional[exp.Expr], 1037 exists: bool = True, 1038 replace: bool = False, 1039 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1040 table_description: t.Optional[str] = None, 1041 table_kind: t.Optional[str] = None, 1042 **kwargs: t.Any, 1043 ) -> exp.Create: 1044 exists = False if replace else exists 1045 catalog_name = None 1046 if not isinstance(table_name_or_schema, exp.Schema): 1047 table_name_or_schema = exp.to_table(table_name_or_schema) 1048 catalog_name = table_name_or_schema.catalog 1049 else: 1050 if isinstance(table_name_or_schema.this, exp.Table): 1051 catalog_name = table_name_or_schema.this.catalog 1052 1053 properties = ( 1054 self._build_table_properties_exp( 1055 **kwargs, 1056 catalog_name=catalog_name, 1057 target_columns_to_types=target_columns_to_types, 1058 table_description=table_description, 1059 table_kind=table_kind, 1060 ) 1061 if kwargs or table_description 1062 else None 1063 ) 1064 return exp.Create( 1065 this=table_name_or_schema, 1066 kind=table_kind or "TABLE", 1067 replace=replace, 1068 exists=exists, 1069 expression=expression, 1070 properties=properties, 1071 ) 1072 1073 def create_table_like( 1074 self, 1075 target_table_name: TableName, 1076 source_table_name: TableName, 1077 exists: bool = True, 1078 **kwargs: t.Any, 1079 ) -> None: 1080 """Create a table to store SQLMesh internal state based on the definition of another table, including any 1081 column attributes and indexes defined in the original table. 1082 1083 Args: 1084 target_table_name: The name of the table to create. Can be fully qualified or just table name. 1085 source_table_name: The name of the table to base the new table on. 1086 """ 1087 self._create_table_like(target_table_name, source_table_name, exists=exists, **kwargs) 1088 self._clear_data_object_cache(target_table_name) 1089 1090 def clone_table( 1091 self, 1092 target_table_name: TableName, 1093 source_table_name: TableName, 1094 replace: bool = False, 1095 exists: bool = True, 1096 clone_kwargs: t.Optional[t.Dict[str, t.Any]] = None, 1097 **kwargs: t.Any, 1098 ) -> None: 1099 """Creates a table with the target name by cloning the source table. 1100 1101 Args: 1102 target_table_name: The name of the table that should be created. 1103 source_table_name: The name of the source table that should be cloned. 1104 replace: Whether or not to replace an existing table. 1105 exists: Indicates whether to include the IF NOT EXISTS check. 1106 """ 1107 if not self.SUPPORTS_CLONING: 1108 raise NotImplementedError(f"Engine does not support cloning: {type(self)}") 1109 1110 kwargs.pop("rendered_physical_properties", None) 1111 self.execute( 1112 exp.Create( 1113 this=exp.to_table(target_table_name), 1114 kind="TABLE", 1115 replace=replace, 1116 exists=exists, 1117 clone=exp.Clone( 1118 this=exp.to_table(source_table_name), 1119 **(clone_kwargs or {}), 1120 ), 1121 **kwargs, 1122 ) 1123 ) 1124 self._clear_data_object_cache(target_table_name) 1125 1126 def drop_data_object(self, data_object: DataObject, ignore_if_not_exists: bool = True) -> None: 1127 """Drops a data object of arbitrary type. 1128 1129 Args: 1130 data_object: The data object to drop. 1131 ignore_if_not_exists: If True, no error will be raised if the data object does not exist. 1132 """ 1133 if data_object.type.is_view: 1134 self.drop_view(data_object.to_table(), ignore_if_not_exists=ignore_if_not_exists) 1135 elif data_object.type.is_materialized_view: 1136 self.drop_view( 1137 data_object.to_table(), ignore_if_not_exists=ignore_if_not_exists, materialized=True 1138 ) 1139 elif data_object.type.is_table: 1140 self.drop_table(data_object.to_table(), exists=ignore_if_not_exists) 1141 elif data_object.type.is_managed_table: 1142 self.drop_managed_table(data_object.to_table(), exists=ignore_if_not_exists) 1143 else: 1144 raise SQLMeshError( 1145 f"Can't drop data object '{data_object.to_table().sql(dialect=self.dialect)}' of type '{data_object.type.value}'" 1146 ) 1147 1148 def drop_table(self, table_name: TableName, exists: bool = True, **kwargs: t.Any) -> None: 1149 """Drops a table. 1150 1151 Args: 1152 table_name: The name of the table to drop. 1153 exists: If exists, defaults to True. 1154 """ 1155 self._drop_object(name=table_name, exists=exists, **kwargs) 1156 1157 def drop_managed_table(self, table_name: TableName, exists: bool = True) -> None: 1158 """Drops a managed table. 1159 1160 Args: 1161 table_name: The name of the table to drop. 1162 exists: If exists, defaults to True. 1163 """ 1164 raise NotImplementedError(f"Engine does not support managed tables: {type(self)}") 1165 1166 def _drop_object( 1167 self, 1168 name: TableName | SchemaName, 1169 exists: bool = True, 1170 kind: str = "TABLE", 1171 cascade: bool = False, 1172 **drop_args: t.Any, 1173 ) -> None: 1174 """Drops an object. 1175 1176 An object could be a DATABASE, SCHEMA, VIEW, TABLE, DYNAMIC TABLE, TEMPORARY TABLE etc depending on the :kind. 1177 1178 Args: 1179 name: The name of the table to drop. 1180 exists: If exists, defaults to True. 1181 kind: What kind of object to drop. Defaults to TABLE 1182 cascade: Whether or not to DROP ... CASCADE. 1183 Note that this is ignored for :kind's that are not present in self.SUPPORTED_DROP_CASCADE_OBJECT_KINDS 1184 **drop_args: Any extra arguments to set on the Drop expression 1185 """ 1186 if cascade and kind.upper() in self.SUPPORTED_DROP_CASCADE_OBJECT_KINDS: 1187 drop_args["cascade"] = cascade 1188 1189 self.execute(exp.Drop(this=exp.to_table(name), kind=kind, exists=exists, **drop_args)) 1190 self._clear_data_object_cache(name) 1191 1192 def get_alter_operations( 1193 self, 1194 current_table_name: TableName, 1195 target_table_name: TableName, 1196 *, 1197 ignore_destructive: bool = False, 1198 ignore_additive: bool = False, 1199 ) -> t.List[TableAlterOperation]: 1200 """ 1201 Determines the alter statements needed to change the current table into the structure of the target table. 1202 """ 1203 return t.cast( 1204 t.List[TableAlterOperation], 1205 self.schema_differ.compare_columns( 1206 current_table_name, 1207 self.columns(current_table_name), 1208 self.columns(target_table_name), 1209 ignore_destructive=ignore_destructive, 1210 ignore_additive=ignore_additive, 1211 ), 1212 ) 1213 1214 def alter_table( 1215 self, 1216 alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]], 1217 ) -> None: 1218 """ 1219 Performs the alter statements to change the current table into the structure of the target table. 1220 """ 1221 with self.transaction(): 1222 for alter_expression in [ 1223 x.expression if isinstance(x, TableAlterOperation) else x for x in alter_expressions 1224 ]: 1225 self.execute(alter_expression) 1226 1227 def create_view( 1228 self, 1229 view_name: TableName, 1230 query_or_df: QueryOrDF, 1231 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1232 replace: bool = True, 1233 materialized: bool = False, 1234 materialized_properties: t.Optional[t.Dict[str, t.Any]] = None, 1235 table_description: t.Optional[str] = None, 1236 column_descriptions: t.Optional[t.Dict[str, str]] = None, 1237 view_properties: t.Optional[t.Dict[str, exp.Expr]] = None, 1238 source_columns: t.Optional[t.List[str]] = None, 1239 **create_kwargs: t.Any, 1240 ) -> None: 1241 """Create a view with a query or dataframe. 1242 1243 If a dataframe is passed in, it will be converted into a literal values statement. 1244 This should only be done if the dataframe is very small! 1245 1246 Args: 1247 view_name: The view name. 1248 query_or_df: A query or dataframe. 1249 target_columns_to_types: Columns to use in the view statement. 1250 replace: Whether or not to replace an existing view defaults to True. 1251 materialized: Whether to create a a materialized view. Only used for engines that support this feature. 1252 materialized_properties: Optional materialized view properties to add to the view. 1253 table_description: Optional table description from MODEL DDL. 1254 column_descriptions: Optional column descriptions from model query. 1255 view_properties: Optional view properties to add to the view. 1256 create_kwargs: Additional kwargs to pass into the Create expression 1257 """ 1258 import pandas as pd 1259 1260 if materialized_properties and not materialized: 1261 raise SQLMeshError("Materialized properties are only supported for materialized views") 1262 1263 query_or_df = self._native_df_to_pandas_df(query_or_df) 1264 1265 if isinstance(query_or_df, pd.DataFrame): 1266 values: t.List[t.Tuple[t.Any, ...]] = list( 1267 query_or_df.itertuples(index=False, name=None) 1268 ) 1269 target_columns_to_types, source_columns = self._columns_to_types( 1270 query_or_df, target_columns_to_types, source_columns 1271 ) 1272 if not target_columns_to_types: 1273 raise SQLMeshError("columns_to_types must be provided for dataframes") 1274 source_columns_to_types = get_source_columns_to_types( 1275 target_columns_to_types, source_columns 1276 ) 1277 query_or_df = self._values_to_sql( 1278 values, 1279 source_columns_to_types, 1280 batch_start=0, 1281 batch_end=len(values), 1282 ) 1283 1284 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 1285 query_or_df, 1286 target_columns_to_types, 1287 batch_size=0, 1288 target_table=view_name, 1289 source_columns=source_columns, 1290 ) 1291 if len(source_queries) != 1: 1292 raise SQLMeshError("Only one source query is supported for creating views") 1293 1294 schema: t.Union[exp.Table, exp.Schema] = exp.to_table(view_name) 1295 if target_columns_to_types: 1296 schema = self._build_schema_exp( 1297 exp.to_table(view_name), 1298 target_columns_to_types, 1299 column_descriptions, 1300 is_view=True, 1301 materialized=materialized, 1302 ) 1303 1304 properties = create_kwargs.pop("properties", None) 1305 if not properties: 1306 properties = exp.Properties(expressions=[]) 1307 1308 if view_properties: 1309 table_type = self._pop_creatable_type_from_properties(view_properties) 1310 if table_type: 1311 properties.append("expressions", table_type) 1312 1313 if materialized and self.SUPPORTS_MATERIALIZED_VIEWS: 1314 properties.append("expressions", exp.MaterializedProperty()) 1315 1316 if not self.SUPPORTS_MATERIALIZED_VIEW_SCHEMA and isinstance(schema, exp.Schema): 1317 schema = schema.this 1318 1319 if not self.SUPPORTS_VIEW_SCHEMA and isinstance(schema, exp.Schema): 1320 schema = schema.this 1321 1322 if materialized_properties: 1323 partitioned_by = materialized_properties.pop("partitioned_by", None) 1324 clustered_by = materialized_properties.pop("clustered_by", None) 1325 if ( 1326 partitioned_by 1327 and ( 1328 partitioned_by_prop := self._build_partitioned_by_exp( 1329 partitioned_by, **materialized_properties 1330 ) 1331 ) 1332 is not None 1333 ): 1334 materialized_properties["catalog_name"] = exp.to_table(view_name).catalog 1335 properties.append("expressions", partitioned_by_prop) 1336 if ( 1337 clustered_by 1338 and ( 1339 clustered_by_prop := self._build_clustered_by_exp( 1340 clustered_by, **materialized_properties 1341 ) 1342 ) 1343 is not None 1344 ): 1345 properties.append("expressions", clustered_by_prop) 1346 1347 create_view_properties = self._build_view_properties_exp( 1348 view_properties, 1349 ( 1350 table_description 1351 if self.COMMENT_CREATION_VIEW.supports_schema_def and self.comments_enabled 1352 else None 1353 ), 1354 physical_cluster=create_kwargs.pop("physical_cluster", None), 1355 ) 1356 if create_view_properties: 1357 for view_property in create_view_properties.expressions: 1358 # Small hack to make sure SECURE goes at the beginning before materialized as required by Snowflake 1359 if isinstance(view_property, exp.SecureProperty): 1360 properties.set("expressions", view_property, index=0, overwrite=False) 1361 else: 1362 properties.append("expressions", view_property) 1363 1364 if properties.expressions: 1365 create_kwargs["properties"] = properties 1366 1367 if replace: 1368 self.drop_data_object_on_type_mismatch( 1369 self.get_data_object(view_name), 1370 DataObjectType.VIEW if not materialized else DataObjectType.MATERIALIZED_VIEW, 1371 ) 1372 1373 with source_queries[0] as query: 1374 self.execute( 1375 exp.Create( 1376 this=schema, 1377 kind="VIEW", 1378 replace=replace, 1379 expression=query, 1380 **create_kwargs, 1381 ), 1382 quote_identifiers=self.QUOTE_IDENTIFIERS_IN_VIEWS, 1383 ) 1384 1385 self._clear_data_object_cache(view_name) 1386 1387 # Register table comment with commands if the engine doesn't support doing it in CREATE 1388 if ( 1389 table_description 1390 and self.COMMENT_CREATION_VIEW.is_comment_command_only 1391 and self.comments_enabled 1392 ): 1393 self._create_table_comment(view_name, table_description, "VIEW") 1394 # Register column comments with commands if the engine doesn't support doing it in 1395 # CREATE or we couldn't do it in the CREATE schema definition because we don't have 1396 # columns_to_types 1397 if ( 1398 column_descriptions 1399 and ( 1400 self.COMMENT_CREATION_VIEW.is_comment_command_only 1401 or ( 1402 self.COMMENT_CREATION_VIEW.is_in_schema_def_and_commands 1403 and not target_columns_to_types 1404 ) 1405 ) 1406 and self.comments_enabled 1407 ): 1408 self._create_column_comments(view_name, column_descriptions, "VIEW", materialized) 1409 1410 @set_catalog() 1411 def create_schema( 1412 self, 1413 schema_name: SchemaName, 1414 ignore_if_exists: bool = True, 1415 warn_on_error: bool = True, 1416 properties: t.Optional[t.List[exp.Expr]] = None, 1417 ) -> None: 1418 properties = properties or [] 1419 return self._create_schema( 1420 schema_name=schema_name, 1421 ignore_if_exists=ignore_if_exists, 1422 warn_on_error=warn_on_error, 1423 properties=properties, 1424 kind="SCHEMA", 1425 ) 1426 1427 def _create_schema( 1428 self, 1429 schema_name: SchemaName, 1430 ignore_if_exists: bool, 1431 warn_on_error: bool, 1432 properties: t.List[exp.Expr], 1433 kind: str, 1434 ) -> None: 1435 """Create a schema from a name or qualified table name.""" 1436 try: 1437 self.execute( 1438 exp.Create( 1439 this=to_schema(schema_name), 1440 kind=kind, 1441 exists=ignore_if_exists, 1442 properties=exp.Properties( # this renders as '' (empty string) if expressions is empty 1443 expressions=properties 1444 ), 1445 ) 1446 ) 1447 except Exception as e: 1448 if not warn_on_error: 1449 raise 1450 logger.warning("Failed to create %s '%s': %s", kind.lower(), schema_name, e) 1451 1452 def drop_schema( 1453 self, 1454 schema_name: SchemaName, 1455 ignore_if_not_exists: bool = True, 1456 cascade: bool = False, 1457 **drop_args: t.Dict[str, exp.Expr], 1458 ) -> None: 1459 return self._drop_object( 1460 name=schema_name, 1461 exists=ignore_if_not_exists, 1462 kind="SCHEMA", 1463 cascade=cascade, 1464 **drop_args, 1465 ) 1466 1467 def drop_view( 1468 self, 1469 view_name: TableName, 1470 ignore_if_not_exists: bool = True, 1471 materialized: bool = False, 1472 **kwargs: t.Any, 1473 ) -> None: 1474 """Drop a view.""" 1475 self._drop_object( 1476 name=view_name, 1477 exists=ignore_if_not_exists, 1478 kind="VIEW", 1479 materialized=materialized and self.SUPPORTS_MATERIALIZED_VIEWS, 1480 **kwargs, 1481 ) 1482 1483 def create_catalog(self, catalog_name: str | exp.Identifier) -> None: 1484 return self._create_catalog(exp.parse_identifier(catalog_name, dialect=self.dialect)) 1485 1486 def _create_catalog(self, catalog_name: exp.Identifier) -> None: 1487 raise SQLMeshError( 1488 f"Unable to create catalog '{catalog_name.sql(dialect=self.dialect)}' as automatic catalog management is not implemented in the {self.dialect} engine." 1489 ) 1490 1491 def drop_catalog(self, catalog_name: str | exp.Identifier) -> None: 1492 return self._drop_catalog(exp.parse_identifier(catalog_name, dialect=self.dialect)) 1493 1494 def _drop_catalog(self, catalog_name: exp.Identifier) -> None: 1495 raise SQLMeshError( 1496 f"Unable to drop catalog '{catalog_name.sql(dialect=self.dialect)}' as automatic catalog management is not implemented in the {self.dialect} engine." 1497 ) 1498 1499 def columns( 1500 self, table_name: TableName, include_pseudo_columns: bool = False 1501 ) -> t.Dict[str, exp.DataType]: 1502 """Fetches column names and types for the target table.""" 1503 self.execute(exp.Describe(this=exp.to_table(table_name), kind="TABLE")) 1504 describe_output = self.cursor.fetchall() 1505 return { 1506 # Note: MySQL returns the column type as bytes. 1507 column_name: exp.DataType.build(_decoded_str(column_type), dialect=self.dialect) 1508 for column_name, column_type, *_ in itertools.takewhile( 1509 lambda t: not t[0].startswith("#"), 1510 describe_output, 1511 ) 1512 if column_name and column_name.strip() and column_type and column_type.strip() 1513 } 1514 1515 def table_exists(self, table_name: TableName) -> bool: 1516 table = exp.to_table(table_name) 1517 data_object_cache_key = _get_data_object_cache_key(table.catalog, table.db, table.name) 1518 if data_object_cache_key in self._data_object_cache: 1519 logger.debug("Table existence cache hit: %s", data_object_cache_key) 1520 return self._data_object_cache[data_object_cache_key] is not None 1521 1522 try: 1523 self.execute(exp.Describe(this=table, kind="TABLE")) 1524 return True 1525 except Exception: 1526 return False 1527 1528 def delete_from(self, table_name: TableName, where: t.Union[str, exp.Expr]) -> None: 1529 self.execute(exp.delete(table_name, where)) 1530 1531 def insert_append( 1532 self, 1533 table_name: TableName, 1534 query_or_df: QueryOrDF, 1535 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1536 track_rows_processed: bool = True, 1537 source_columns: t.Optional[t.List[str]] = None, 1538 ) -> None: 1539 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 1540 query_or_df, 1541 target_columns_to_types, 1542 target_table=table_name, 1543 source_columns=source_columns, 1544 ) 1545 self._insert_append_source_queries( 1546 table_name, source_queries, target_columns_to_types, track_rows_processed 1547 ) 1548 1549 def _insert_append_source_queries( 1550 self, 1551 table_name: TableName, 1552 source_queries: t.List[SourceQuery], 1553 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1554 track_rows_processed: bool = True, 1555 ) -> None: 1556 with self.transaction(condition=len(source_queries) > 0): 1557 target_columns_to_types = target_columns_to_types or self.columns(table_name) 1558 for source_query in source_queries: 1559 with source_query as query: 1560 self._insert_append_query( 1561 table_name, 1562 query, 1563 target_columns_to_types, 1564 track_rows_processed=track_rows_processed, 1565 ) 1566 1567 def _insert_append_query( 1568 self, 1569 table_name: TableName, 1570 query: Query, 1571 target_columns_to_types: t.Dict[str, exp.DataType], 1572 order_projections: bool = True, 1573 track_rows_processed: bool = True, 1574 ) -> None: 1575 if order_projections: 1576 query = self._order_projections_and_filter(query, target_columns_to_types) 1577 self.execute( 1578 exp.insert(query, table_name, columns=list(target_columns_to_types)), 1579 track_rows_processed=track_rows_processed, 1580 ) 1581 1582 def insert_overwrite_by_partition( 1583 self, 1584 table_name: TableName, 1585 query_or_df: QueryOrDF, 1586 partitioned_by: t.List[exp.Expr], 1587 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1588 source_columns: t.Optional[t.List[str]] = None, 1589 ) -> None: 1590 if self.INSERT_OVERWRITE_STRATEGY.is_insert_overwrite: 1591 target_table = exp.to_table(table_name) 1592 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 1593 query_or_df, 1594 target_columns_to_types, 1595 target_table=target_table, 1596 source_columns=source_columns, 1597 ) 1598 self._insert_overwrite_by_condition( 1599 table_name, source_queries, target_columns_to_types=target_columns_to_types 1600 ) 1601 else: 1602 self._replace_by_key( 1603 table_name, 1604 query_or_df, 1605 target_columns_to_types, 1606 partitioned_by, 1607 is_unique_key=False, 1608 source_columns=source_columns, 1609 ) 1610 1611 def insert_overwrite_by_time_partition( 1612 self, 1613 table_name: TableName, 1614 query_or_df: QueryOrDF, 1615 start: TimeLike, 1616 end: TimeLike, 1617 time_formatter: t.Callable[[TimeLike, t.Optional[t.Dict[str, exp.DataType]]], exp.Expr], 1618 time_column: TimeColumn | exp.Expr | str, 1619 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1620 source_columns: t.Optional[t.List[str]] = None, 1621 **kwargs: t.Any, 1622 ) -> None: 1623 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 1624 query_or_df, 1625 target_columns_to_types, 1626 target_table=table_name, 1627 source_columns=source_columns, 1628 ) 1629 if not target_columns_to_types or not columns_to_types_all_known(target_columns_to_types): 1630 target_columns_to_types = self.columns(table_name) 1631 low, high = [ 1632 time_formatter(dt, target_columns_to_types) 1633 for dt in make_inclusive(start, end, self.dialect) 1634 ] 1635 if isinstance(time_column, TimeColumn): 1636 time_column = time_column.column 1637 where = exp.Between( 1638 this=exp.to_column(time_column) if isinstance(time_column, str) else time_column, 1639 low=low, 1640 high=high, 1641 ) 1642 return self._insert_overwrite_by_time_partition( 1643 table_name, source_queries, target_columns_to_types, where, **kwargs 1644 ) 1645 1646 def _insert_overwrite_by_time_partition( 1647 self, 1648 table_name: TableName, 1649 source_queries: t.List[SourceQuery], 1650 target_columns_to_types: t.Dict[str, exp.DataType], 1651 where: exp.Condition, 1652 **kwargs: t.Any, 1653 ) -> None: 1654 return self._insert_overwrite_by_condition( 1655 table_name, source_queries, target_columns_to_types, where, **kwargs 1656 ) 1657 1658 def _values_to_sql( 1659 self, 1660 values: t.List[t.Tuple[t.Any, ...]], 1661 target_columns_to_types: t.Dict[str, exp.DataType], 1662 batch_start: int, 1663 batch_end: int, 1664 alias: str = "t", 1665 source_columns: t.Optional[t.List[str]] = None, 1666 ) -> Query: 1667 return select_from_values_for_batch_range( 1668 values=values, 1669 target_columns_to_types=target_columns_to_types, 1670 batch_start=batch_start, 1671 batch_end=batch_end, 1672 alias=alias, 1673 source_columns=source_columns, 1674 ) 1675 1676 def _insert_overwrite_by_condition( 1677 self, 1678 table_name: TableName, 1679 source_queries: t.List[SourceQuery], 1680 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1681 where: t.Optional[exp.Condition] = None, 1682 insert_overwrite_strategy_override: t.Optional[InsertOverwriteStrategy] = None, 1683 **kwargs: t.Any, 1684 ) -> None: 1685 table = exp.to_table(table_name) 1686 insert_overwrite_strategy = ( 1687 insert_overwrite_strategy_override or self.INSERT_OVERWRITE_STRATEGY 1688 ) 1689 with self.transaction( 1690 condition=len(source_queries) > 0 or insert_overwrite_strategy.is_delete_insert 1691 ): 1692 target_columns_to_types = target_columns_to_types or self.columns(table_name) 1693 for i, source_query in enumerate(source_queries): 1694 with source_query as query: 1695 query = self._order_projections_and_filter( 1696 query, target_columns_to_types, where=where 1697 ) 1698 if i > 0 or insert_overwrite_strategy.is_delete_insert: 1699 if i == 0: 1700 self.delete_from(table_name, where=where or exp.true()) 1701 self._insert_append_query( 1702 table_name, 1703 query, 1704 target_columns_to_types=target_columns_to_types, 1705 order_projections=False, 1706 ) 1707 elif insert_overwrite_strategy.is_merge: 1708 columns = [exp.column(col) for col in target_columns_to_types] 1709 when_not_matched_by_source = exp.When( 1710 matched=False, 1711 source=True, 1712 condition=where, 1713 then=exp.Delete(), 1714 ) 1715 when_not_matched_by_target = exp.When( 1716 matched=False, 1717 source=False, 1718 then=exp.Insert( 1719 this=exp.Tuple(expressions=columns), 1720 expression=exp.Tuple(expressions=columns), 1721 ), 1722 ) 1723 self._merge( 1724 target_table=table_name, 1725 query=query, 1726 on=exp.false(), 1727 whens=exp.Whens( 1728 expressions=[when_not_matched_by_source, when_not_matched_by_target] 1729 ), 1730 ) 1731 else: 1732 insert_exp = exp.insert( 1733 query, 1734 table, 1735 columns=( 1736 list(target_columns_to_types) 1737 if not insert_overwrite_strategy.is_replace_where 1738 else None 1739 ), 1740 overwrite=insert_overwrite_strategy.is_insert_overwrite, 1741 ) 1742 if insert_overwrite_strategy.is_replace_where: 1743 insert_exp.set("where", where or exp.true()) 1744 self.execute(insert_exp, track_rows_processed=True) 1745 1746 def update_table( 1747 self, 1748 table_name: TableName, 1749 properties: t.Dict[str, t.Any], 1750 where: t.Optional[str | exp.Condition] = None, 1751 ) -> None: 1752 self.execute(exp.update(table_name, properties, where=where)) 1753 1754 def _merge( 1755 self, 1756 target_table: TableName, 1757 query: Query, 1758 on: exp.Expr, 1759 whens: exp.Whens, 1760 ) -> None: 1761 this = exp.alias_(exp.to_table(target_table), alias=MERGE_TARGET_ALIAS, table=True) 1762 using = exp.alias_( 1763 exp.Subquery(this=query), alias=MERGE_SOURCE_ALIAS, copy=False, table=True 1764 ) 1765 self.execute( 1766 exp.Merge(this=this, using=using, on=on, whens=whens), track_rows_processed=True 1767 ) 1768 1769 def scd_type_2_by_time( 1770 self, 1771 target_table: TableName, 1772 source_table: QueryOrDF, 1773 unique_key: t.Sequence[exp.Expr], 1774 valid_from_col: exp.Column, 1775 valid_to_col: exp.Column, 1776 execution_time: t.Union[TimeLike, exp.Column], 1777 updated_at_col: exp.Column, 1778 invalidate_hard_deletes: bool = True, 1779 updated_at_as_valid_from: bool = False, 1780 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1781 table_description: t.Optional[str] = None, 1782 column_descriptions: t.Optional[t.Dict[str, str]] = None, 1783 truncate: bool = False, 1784 source_columns: t.Optional[t.List[str]] = None, 1785 **kwargs: t.Any, 1786 ) -> None: 1787 self._scd_type_2( 1788 target_table=target_table, 1789 source_table=source_table, 1790 unique_key=unique_key, 1791 valid_from_col=valid_from_col, 1792 valid_to_col=valid_to_col, 1793 execution_time=execution_time, 1794 updated_at_col=updated_at_col, 1795 invalidate_hard_deletes=invalidate_hard_deletes, 1796 updated_at_as_valid_from=updated_at_as_valid_from, 1797 target_columns_to_types=target_columns_to_types, 1798 table_description=table_description, 1799 column_descriptions=column_descriptions, 1800 truncate=truncate, 1801 source_columns=source_columns, 1802 **kwargs, 1803 ) 1804 1805 def scd_type_2_by_column( 1806 self, 1807 target_table: TableName, 1808 source_table: QueryOrDF, 1809 unique_key: t.Sequence[exp.Expr], 1810 valid_from_col: exp.Column, 1811 valid_to_col: exp.Column, 1812 execution_time: t.Union[TimeLike, exp.Column], 1813 check_columns: t.Union[exp.Star, t.Sequence[exp.Expr]], 1814 invalidate_hard_deletes: bool = True, 1815 execution_time_as_valid_from: bool = False, 1816 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1817 table_description: t.Optional[str] = None, 1818 column_descriptions: t.Optional[t.Dict[str, str]] = None, 1819 truncate: bool = False, 1820 source_columns: t.Optional[t.List[str]] = None, 1821 **kwargs: t.Any, 1822 ) -> None: 1823 self._scd_type_2( 1824 target_table=target_table, 1825 source_table=source_table, 1826 unique_key=unique_key, 1827 valid_from_col=valid_from_col, 1828 valid_to_col=valid_to_col, 1829 execution_time=execution_time, 1830 check_columns=check_columns, 1831 target_columns_to_types=target_columns_to_types, 1832 invalidate_hard_deletes=invalidate_hard_deletes, 1833 execution_time_as_valid_from=execution_time_as_valid_from, 1834 table_description=table_description, 1835 column_descriptions=column_descriptions, 1836 truncate=truncate, 1837 source_columns=source_columns, 1838 **kwargs, 1839 ) 1840 1841 def _scd_type_2( 1842 self, 1843 target_table: TableName, 1844 source_table: QueryOrDF, 1845 unique_key: t.Sequence[exp.Expr], 1846 valid_from_col: exp.Column, 1847 valid_to_col: exp.Column, 1848 execution_time: t.Union[TimeLike, exp.Column], 1849 invalidate_hard_deletes: bool = True, 1850 updated_at_col: t.Optional[exp.Column] = None, 1851 check_columns: t.Optional[t.Union[exp.Star, t.Sequence[exp.Expr]]] = None, 1852 updated_at_as_valid_from: bool = False, 1853 execution_time_as_valid_from: bool = False, 1854 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1855 table_description: t.Optional[str] = None, 1856 column_descriptions: t.Optional[t.Dict[str, str]] = None, 1857 truncate: bool = False, 1858 source_columns: t.Optional[t.List[str]] = None, 1859 **kwargs: t.Any, 1860 ) -> None: 1861 def remove_managed_columns( 1862 cols_to_types: t.Dict[str, exp.DataType], 1863 ) -> t.Dict[str, exp.DataType]: 1864 return { 1865 k: v for k, v in cols_to_types.items() if k not in {valid_from_name, valid_to_name} 1866 } 1867 1868 valid_from_name = valid_from_col.name 1869 valid_to_name = valid_to_col.name 1870 target_columns_to_types = target_columns_to_types or self.columns(target_table) 1871 if ( 1872 valid_from_name not in target_columns_to_types 1873 or valid_to_name not in target_columns_to_types 1874 or not columns_to_types_all_known(target_columns_to_types) 1875 ): 1876 target_columns_to_types = self.columns(target_table) 1877 unmanaged_columns_to_types = ( 1878 remove_managed_columns(target_columns_to_types) if target_columns_to_types else None 1879 ) 1880 source_queries, unmanaged_columns_to_types = self._get_source_queries_and_columns_to_types( 1881 source_table, 1882 unmanaged_columns_to_types, 1883 target_table=target_table, 1884 batch_size=0, 1885 source_columns=source_columns, 1886 ) 1887 updated_at_name = updated_at_col.name if updated_at_col else None 1888 if not target_columns_to_types: 1889 raise SQLMeshError(f"Could not get columns_to_types. Does {target_table} exist?") 1890 unmanaged_columns_to_types = unmanaged_columns_to_types or remove_managed_columns( 1891 target_columns_to_types 1892 ) 1893 if not unique_key: 1894 raise SQLMeshError("unique_key must be provided for SCD Type 2") 1895 if check_columns and updated_at_col: 1896 raise SQLMeshError( 1897 "Cannot use both `check_columns` and `updated_at_name` for SCD Type 2" 1898 ) 1899 if check_columns and updated_at_as_valid_from: 1900 raise SQLMeshError( 1901 "Cannot use both `check_columns` and `updated_at_as_valid_from` for SCD Type 2" 1902 ) 1903 if execution_time_as_valid_from and not check_columns: 1904 raise SQLMeshError( 1905 "Cannot use `execution_time_as_valid_from` without `check_columns` for SCD Type 2" 1906 ) 1907 if updated_at_name and updated_at_name not in target_columns_to_types: 1908 raise SQLMeshError( 1909 f"Column {updated_at_name} not found in {target_table}. Table must contain an `updated_at` timestamp for SCD Type 2" 1910 ) 1911 time_data_type = target_columns_to_types[valid_from_name] 1912 select_source_columns: t.List[t.Union[str, exp.Alias]] = [ 1913 col for col in unmanaged_columns_to_types if col != updated_at_name 1914 ] 1915 table_columns = [exp.column(c, quoted=True) for c in target_columns_to_types] 1916 if updated_at_name: 1917 select_source_columns.append( 1918 exp.cast(updated_at_col, time_data_type).as_(updated_at_col.this) # type: ignore 1919 ) 1920 1921 # If a star is provided, we include all unmanaged columns in the check. 1922 # This unnecessarily includes unique key columns but since they are used in the join, and therefore we know 1923 # they are equal or not, the extra check is not a problem and we gain simplified logic here. 1924 # If we want to change this, then we just need to check the expressions in unique_key and pull out the 1925 # column names and then remove them from the unmanaged_columns 1926 if check_columns: 1927 # Handle both Star directly and [Star()] (which can happen during serialization/deserialization) 1928 if isinstance(seq_get(ensure_list(check_columns), 0), exp.Star): 1929 check_columns = [exp.column(col) for col in unmanaged_columns_to_types] 1930 execution_ts = ( 1931 exp.cast(execution_time, time_data_type, dialect=self.dialect) 1932 if isinstance(execution_time, exp.Column) 1933 else to_time_column(execution_time, time_data_type, self.dialect, nullable=True) 1934 ) 1935 if updated_at_as_valid_from: 1936 if not updated_at_col: 1937 raise SQLMeshError( 1938 "Cannot use `updated_at_as_valid_from` without `updated_at_name` for SCD Type 2" 1939 ) 1940 update_valid_from_start: t.Union[str, exp.Expr] = updated_at_col 1941 # If using check_columns and the user doesn't always want execution_time for valid from 1942 # then we only use epoch 0 if we are truncating the table and loading rows for the first time. 1943 # All future new rows should have execution time. 1944 elif check_columns and (execution_time_as_valid_from or not truncate): 1945 update_valid_from_start = execution_ts 1946 else: 1947 update_valid_from_start = to_time_column( 1948 "1970-01-01 00:00:00+00:00", time_data_type, self.dialect, nullable=True 1949 ) 1950 insert_valid_from_start = execution_ts if check_columns else updated_at_col # type: ignore 1951 # joined._exists IS NULL is saying "if the row is deleted" 1952 delete_check = ( 1953 exp.column("_exists", "joined").is_(exp.Null()) if invalidate_hard_deletes else None 1954 ) 1955 prefixed_valid_to_col = valid_to_col.copy() 1956 prefixed_valid_to_col.this.set("this", f"t_{prefixed_valid_to_col.name}") 1957 prefixed_valid_from_col = valid_from_col.copy() 1958 prefixed_valid_from_col.this.set("this", f"t_{valid_from_col.name}") 1959 if check_columns: 1960 row_check_conditions = [] 1961 for col in check_columns: 1962 col_qualified = col.copy() 1963 col_qualified.set("table", exp.to_identifier("joined")) 1964 1965 t_col = col_qualified.copy() 1966 for column in t_col.find_all(exp.Column): 1967 column.this.set("this", f"t_{column.name}") 1968 1969 row_check_conditions.extend( 1970 [ 1971 col_qualified.neq(t_col), 1972 exp.and_(t_col.is_(exp.Null()), col_qualified.is_(exp.Null()).not_()), 1973 exp.and_(t_col.is_(exp.Null()).not_(), col_qualified.is_(exp.Null())), 1974 ] 1975 ) 1976 row_value_check = exp.or_(*row_check_conditions) 1977 unique_key_conditions = [] 1978 for key in unique_key: 1979 key_qualified = key.copy() 1980 key_qualified.set("table", exp.to_identifier("joined")) 1981 t_key = key_qualified.copy() 1982 for col in t_key.find_all(exp.Column): 1983 col.this.set("this", f"t_{col.name}") 1984 unique_key_conditions.extend( 1985 [t_key.is_(exp.Null()).not_(), key_qualified.is_(exp.Null()).not_()] 1986 ) 1987 unique_key_check = exp.and_(*unique_key_conditions) 1988 # unique_key_check is saying "if the row is updated" 1989 # row_value_check is saying "if the row has changed" 1990 updated_row_filter = exp.and_(unique_key_check, row_value_check) 1991 valid_to_case_stmt = ( 1992 exp.Case() 1993 .when( 1994 exp.and_( 1995 exp.or_( 1996 delete_check, 1997 updated_row_filter, 1998 ) 1999 ), 2000 execution_ts, 2001 ) 2002 .else_(prefixed_valid_to_col) 2003 .as_(valid_to_col.this) 2004 ) 2005 valid_from_case_stmt = exp.func( 2006 "COALESCE", 2007 prefixed_valid_from_col, 2008 update_valid_from_start, 2009 ).as_(valid_from_col.this) 2010 else: 2011 assert updated_at_col is not None 2012 updated_at_col_qualified = updated_at_col.copy() 2013 updated_at_col_qualified.set("table", exp.to_identifier("joined")) 2014 prefixed_updated_at_col = updated_at_col_qualified.copy() 2015 prefixed_updated_at_col.this.set("this", f"t_{updated_at_col_qualified.name}") 2016 updated_row_filter = updated_at_col_qualified > prefixed_updated_at_col 2017 2018 valid_to_case_stmt_builder = exp.Case().when( 2019 updated_row_filter, updated_at_col_qualified 2020 ) 2021 if delete_check: 2022 valid_to_case_stmt_builder = valid_to_case_stmt_builder.when( 2023 delete_check, execution_ts 2024 ) 2025 valid_to_case_stmt = valid_to_case_stmt_builder.else_(prefixed_valid_to_col).as_( 2026 valid_to_col.this 2027 ) 2028 2029 valid_from_case_stmt = ( 2030 exp.Case() 2031 .when( 2032 exp.and_( 2033 prefixed_valid_from_col.is_(exp.Null()), 2034 exp.column("_exists", "latest_deleted").is_(exp.Null()).not_(), 2035 ), 2036 exp.Case() 2037 .when( 2038 exp.column(valid_to_col.this, "latest_deleted") > updated_at_col, 2039 exp.column(valid_to_col.this, "latest_deleted"), 2040 ) 2041 .else_(updated_at_col), 2042 ) 2043 .when(prefixed_valid_from_col.is_(exp.Null()), update_valid_from_start) 2044 .else_(prefixed_valid_from_col) 2045 ).as_(valid_from_col.this) 2046 2047 existing_rows_query = exp.select(*table_columns, exp.true().as_("_exists")).from_( 2048 target_table 2049 ) 2050 if truncate: 2051 existing_rows_query = existing_rows_query.limit(0) 2052 2053 with source_queries[0] as source_query: 2054 prefixed_columns_to_types = [] 2055 for column in target_columns_to_types: 2056 prefixed_col = exp.column(column).copy() 2057 prefixed_col.this.set("this", f"t_{prefixed_col.name}") 2058 prefixed_columns_to_types.append(prefixed_col) 2059 prefixed_unmanaged_columns = [] 2060 for column in unmanaged_columns_to_types: 2061 prefixed_col = exp.column(column).copy() 2062 prefixed_col.this.set("this", f"t_{prefixed_col.name}") 2063 prefixed_unmanaged_columns.append(prefixed_col) 2064 query = ( 2065 exp.Select() # type: ignore 2066 .select(*table_columns) 2067 .from_("static") 2068 .union( 2069 exp.select(*table_columns).from_("updated_rows"), 2070 distinct=False, 2071 ) 2072 .union( 2073 exp.select(*table_columns).from_("inserted_rows"), 2074 distinct=False, 2075 ) 2076 .with_( 2077 "source", 2078 exp.select(exp.true().as_("_exists"), *select_source_columns) 2079 .distinct(*unique_key) 2080 .from_( 2081 self.use_server_nulls_for_unmatched_after_join(source_query).subquery( # type: ignore 2082 "raw_source" 2083 ) 2084 ), 2085 ) 2086 # Historical Records that Do Not Change 2087 .with_( 2088 "static", 2089 existing_rows_query.where(valid_to_col.is_(exp.Null()).not_()), 2090 ) 2091 # Latest Records that can be updated 2092 .with_( 2093 "latest", 2094 existing_rows_query.where(valid_to_col.is_(exp.Null())), 2095 ) 2096 # Deleted records which can be used to determine `valid_from` for undeleted source records 2097 .with_( 2098 "deleted", 2099 exp.select(*[exp.column(col, "static") for col in target_columns_to_types]) 2100 .from_("static") 2101 .join( 2102 "latest", 2103 on=exp.and_( 2104 *[ 2105 add_table(key, "static").eq(add_table(key, "latest")) 2106 for key in unique_key 2107 ] 2108 ), 2109 join_type="left", 2110 ) 2111 .where(exp.column(valid_to_col.this, "latest").is_(exp.Null())), 2112 ) 2113 # Get the latest `valid_to` deleted record for each unique key 2114 .with_( 2115 "latest_deleted", 2116 exp.select( 2117 exp.true().as_("_exists"), 2118 *(part.as_(f"_key{i}") for i, part in enumerate(unique_key)), 2119 exp.Max(this=valid_to_col).as_(valid_to_col.this), 2120 ) 2121 .from_("deleted") 2122 .group_by(*unique_key), 2123 ) 2124 # Do a full join between latest records and source table in order to combine them together 2125 # MySQL doesn't support full join so going to do a left then right join and remove dups with union 2126 # We do a left/right and filter right on only matching to remove the need to do union distinct 2127 # which allows scd type 2 to be compatible with unhashable data types 2128 .with_( 2129 "joined", 2130 exp.select( 2131 exp.column("_exists", table="source").as_("_exists"), 2132 *( 2133 exp.column(col, table="latest").as_(prefixed_columns_to_types[i].this) 2134 for i, col in enumerate(target_columns_to_types) 2135 ), 2136 *( 2137 exp.column(col, table="source").as_(col) 2138 for col in unmanaged_columns_to_types 2139 ), 2140 ) 2141 .from_("latest") 2142 .join( 2143 "source", 2144 on=exp.and_( 2145 *[ 2146 add_table(key, "latest").eq(add_table(key, "source")) 2147 for key in unique_key 2148 ] 2149 ), 2150 join_type="left", 2151 ) 2152 .union( 2153 exp.select( 2154 exp.column("_exists", table="source").as_("_exists"), 2155 *( 2156 exp.column(col, table="latest").as_( 2157 prefixed_columns_to_types[i].this 2158 ) 2159 for i, col in enumerate(target_columns_to_types) 2160 ), 2161 *( 2162 exp.column(col, table="source").as_(col) 2163 for col in unmanaged_columns_to_types 2164 ), 2165 ) 2166 .from_("latest") 2167 .join( 2168 "source", 2169 on=exp.and_( 2170 *[ 2171 add_table(key, "latest").eq(add_table(key, "source")) 2172 for key in unique_key 2173 ] 2174 ), 2175 join_type="right", 2176 ) 2177 .where(exp.column("_exists", table="latest").is_(exp.Null())), 2178 distinct=False, 2179 ), 2180 ) 2181 # Get deleted, new, no longer current, or unchanged records 2182 .with_( 2183 "updated_rows", 2184 exp.select( 2185 *( 2186 exp.func( 2187 "COALESCE", 2188 exp.column(prefixed_unmanaged_columns[i].this, table="joined"), 2189 exp.column(col, table="joined"), 2190 ).as_(col) 2191 for i, col in enumerate(unmanaged_columns_to_types) 2192 ), 2193 valid_from_case_stmt, 2194 valid_to_case_stmt, 2195 ) 2196 .from_("joined") 2197 .join( 2198 "latest_deleted", 2199 on=exp.and_( 2200 *[ 2201 add_table(part, "joined").eq( 2202 exp.column(f"_key{i}", "latest_deleted") 2203 ) 2204 for i, part in enumerate(unique_key) 2205 ] 2206 ), 2207 join_type="left", 2208 ), 2209 ) 2210 # Get records that have been "updated" which means inserting a new record with previous `valid_from` 2211 .with_( 2212 "inserted_rows", 2213 exp.select( 2214 *unmanaged_columns_to_types, 2215 insert_valid_from_start.as_(valid_from_col.this), # type: ignore 2216 to_time_column(exp.null(), time_data_type, self.dialect, nullable=True).as_( 2217 valid_to_col.this 2218 ), 2219 ) 2220 .from_("joined") 2221 .where(updated_row_filter), 2222 ) 2223 ) 2224 2225 self.replace_query( 2226 target_table, 2227 self.ensure_nulls_for_unmatched_after_join(query), 2228 target_columns_to_types=target_columns_to_types, 2229 table_description=table_description, 2230 column_descriptions=column_descriptions, 2231 **kwargs, 2232 ) 2233 2234 def merge( 2235 self, 2236 target_table: TableName, 2237 source_table: QueryOrDF, 2238 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], 2239 unique_key: t.Sequence[exp.Expr], 2240 when_matched: t.Optional[exp.Whens] = None, 2241 merge_filter: t.Optional[exp.Expr] = None, 2242 source_columns: t.Optional[t.List[str]] = None, 2243 **kwargs: t.Any, 2244 ) -> None: 2245 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 2246 source_table, 2247 target_columns_to_types, 2248 target_table=target_table, 2249 source_columns=source_columns, 2250 ) 2251 target_columns_to_types = target_columns_to_types or self.columns(target_table) 2252 on = exp.and_( 2253 *( 2254 add_table(part, MERGE_TARGET_ALIAS).eq(add_table(part, MERGE_SOURCE_ALIAS)) 2255 for part in unique_key 2256 ) 2257 ) 2258 if merge_filter: 2259 on = exp.and_(merge_filter, on) 2260 2261 if not when_matched: 2262 match_expressions = [ 2263 exp.When( 2264 matched=True, 2265 source=False, 2266 then=exp.Update( 2267 expressions=[ 2268 exp.column(col, MERGE_TARGET_ALIAS).eq( 2269 exp.column(col, MERGE_SOURCE_ALIAS) 2270 ) 2271 for col in target_columns_to_types 2272 ], 2273 ), 2274 ) 2275 ] 2276 else: 2277 match_expressions = when_matched.copy().expressions 2278 2279 match_expressions.append( 2280 exp.When( 2281 matched=False, 2282 source=False, 2283 then=exp.Insert( 2284 this=exp.Tuple( 2285 expressions=[exp.column(col) for col in target_columns_to_types] 2286 ), 2287 expression=exp.Tuple( 2288 expressions=[ 2289 exp.column(col, MERGE_SOURCE_ALIAS) for col in target_columns_to_types 2290 ] 2291 ), 2292 ), 2293 ) 2294 ) 2295 for source_query in source_queries: 2296 with source_query as query: 2297 self._merge( 2298 target_table=target_table, 2299 query=query, 2300 on=on, 2301 whens=exp.Whens(expressions=match_expressions), 2302 ) 2303 2304 def rename_table( 2305 self, 2306 old_table_name: TableName, 2307 new_table_name: TableName, 2308 ) -> None: 2309 new_table = exp.to_table(new_table_name) 2310 if new_table.catalog: 2311 old_table = exp.to_table(old_table_name) 2312 catalog = old_table.catalog or self.get_current_catalog() 2313 if catalog != new_table.catalog: 2314 raise UnsupportedCatalogOperationError( 2315 "Tried to rename table across catalogs which is not supported" 2316 ) 2317 self._rename_table(old_table_name, new_table_name) 2318 self._clear_data_object_cache(old_table_name) 2319 self._clear_data_object_cache(new_table_name) 2320 2321 def get_data_object( 2322 self, target_name: TableName, safe_to_cache: bool = False 2323 ) -> t.Optional[DataObject]: 2324 target_table = exp.to_table(target_name) 2325 existing_data_objects = self.get_data_objects( 2326 schema_(target_table.db, target_table.catalog), 2327 {target_table.name}, 2328 safe_to_cache=safe_to_cache, 2329 ) 2330 if existing_data_objects: 2331 return existing_data_objects[0] 2332 return None 2333 2334 def get_data_objects( 2335 self, 2336 schema_name: SchemaName, 2337 object_names: t.Optional[t.Set[str]] = None, 2338 safe_to_cache: bool = False, 2339 ) -> t.List[DataObject]: 2340 """Lists all data objects in the target schema. 2341 2342 Args: 2343 schema_name: The name of the schema to list data objects from. 2344 object_names: If provided, only return data objects with these names. 2345 safe_to_cache: Whether it is safe to cache the results of this call. 2346 2347 Returns: 2348 A list of data objects in the target schema. 2349 """ 2350 if object_names is not None: 2351 if not object_names: 2352 return [] 2353 2354 # Check cache for each object name 2355 target_schema = to_schema(schema_name) 2356 cached_objects = [] 2357 missing_names = set() 2358 2359 for name in object_names: 2360 cache_key = _get_data_object_cache_key( 2361 target_schema.catalog, target_schema.db, name 2362 ) 2363 if cache_key in self._data_object_cache: 2364 logger.debug("Data object cache hit: %s", cache_key) 2365 data_object = self._data_object_cache[cache_key] 2366 # If the object is none, then the table was previously looked for but not found 2367 if data_object: 2368 cached_objects.append(data_object) 2369 else: 2370 logger.debug("Data object cache miss: %s", cache_key) 2371 missing_names.add(name) 2372 2373 # Fetch missing objects from database 2374 if missing_names: 2375 object_names_list = list(missing_names) 2376 batches = [ 2377 object_names_list[i : i + self.DATA_OBJECT_FILTER_BATCH_SIZE] 2378 for i in range(0, len(object_names_list), self.DATA_OBJECT_FILTER_BATCH_SIZE) 2379 ] 2380 2381 fetched_objects = [] 2382 fetched_object_names = set() 2383 for batch in batches: 2384 objects = self._get_data_objects(schema_name, set(batch)) 2385 for obj in objects: 2386 if safe_to_cache: 2387 cache_key = _get_data_object_cache_key( 2388 obj.catalog, obj.schema_name, obj.name 2389 ) 2390 self._data_object_cache[cache_key] = obj 2391 fetched_objects.append(obj) 2392 fetched_object_names.add(obj.name) 2393 2394 if safe_to_cache: 2395 for missing_name in missing_names - fetched_object_names: 2396 cache_key = _get_data_object_cache_key( 2397 target_schema.catalog, target_schema.db, missing_name 2398 ) 2399 self._data_object_cache[cache_key] = None 2400 2401 return cached_objects + fetched_objects 2402 2403 return cached_objects 2404 2405 fetched_objects = self._get_data_objects(schema_name) 2406 if safe_to_cache: 2407 for obj in fetched_objects: 2408 cache_key = _get_data_object_cache_key(obj.catalog, obj.schema_name, obj.name) 2409 self._data_object_cache[cache_key] = obj 2410 return fetched_objects 2411 2412 def fetchone( 2413 self, 2414 query: t.Union[exp.Expr, str], 2415 ignore_unsupported_errors: bool = False, 2416 quote_identifiers: bool = False, 2417 ) -> t.Optional[t.Tuple]: 2418 with self.transaction(): 2419 self.execute( 2420 query, 2421 ignore_unsupported_errors=ignore_unsupported_errors, 2422 quote_identifiers=quote_identifiers, 2423 ) 2424 return self.cursor.fetchone() 2425 2426 def fetchall( 2427 self, 2428 query: t.Union[exp.Expr, str], 2429 ignore_unsupported_errors: bool = False, 2430 quote_identifiers: bool = False, 2431 ) -> t.List[t.Tuple]: 2432 with self.transaction(): 2433 self.execute( 2434 query, 2435 ignore_unsupported_errors=ignore_unsupported_errors, 2436 quote_identifiers=quote_identifiers, 2437 ) 2438 return self.cursor.fetchall() 2439 2440 def _fetch_native_df( 2441 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 2442 ) -> DF: 2443 """Fetches a DataFrame that can be either Pandas or PySpark from the cursor""" 2444 with self.transaction(): 2445 self.execute(query, quote_identifiers=quote_identifiers) 2446 return self.cursor.fetchdf() 2447 2448 def _native_df_to_pandas_df( 2449 self, 2450 query_or_df: QueryOrDF, 2451 ) -> t.Union[Query, pd.DataFrame]: 2452 """ 2453 Take a "native" DataFrame (eg Pyspark, Bigframe, Snowpark etc) and convert it to Pandas 2454 """ 2455 import pandas as pd 2456 2457 if isinstance(query_or_df, (exp.Query, pd.DataFrame)): 2458 return query_or_df 2459 2460 # EngineAdapter subclasses that have native DataFrame types should override this 2461 raise NotImplementedError(f"Unable to convert {type(query_or_df)} to Pandas") 2462 2463 def fetchdf( 2464 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 2465 ) -> pd.DataFrame: 2466 """Fetches a Pandas DataFrame from the cursor""" 2467 import pandas as pd 2468 2469 df = self._fetch_native_df(query, quote_identifiers=quote_identifiers) 2470 if not isinstance(df, pd.DataFrame): 2471 raise NotImplementedError( 2472 "The cursor's `fetch_native_df` method is not returning a pandas DataFrame. Need to update `fetchdf` so a Pandas DataFrame is returned" 2473 ) 2474 return df 2475 2476 def fetch_pyspark_df( 2477 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 2478 ) -> PySparkDataFrame: 2479 """Fetches a PySpark DataFrame from the cursor""" 2480 raise NotImplementedError(f"Engine does not support PySpark DataFrames: {type(self)}") 2481 2482 @property 2483 def wap_enabled(self) -> bool: 2484 """Returns whether WAP is enabled for this engine.""" 2485 return self._extra_config.get("wap_enabled", False) 2486 2487 def wap_supported(self, table_name: TableName) -> bool: 2488 """Returns whether WAP for the target table is supported.""" 2489 return False 2490 2491 def wap_table_name(self, table_name: TableName, wap_id: str) -> str: 2492 """Returns the updated table name for the given WAP ID. 2493 2494 Args: 2495 table_name: The name of the target table. 2496 wap_id: The WAP ID to prepare. 2497 2498 Returns: 2499 The updated table name that should be used for writing. 2500 """ 2501 raise NotImplementedError(f"Engine does not support WAP: {type(self)}") 2502 2503 def wap_prepare(self, table_name: TableName, wap_id: str) -> str: 2504 """Prepares the target table for WAP and returns the updated table name. 2505 2506 Args: 2507 table_name: The name of the target table. 2508 wap_id: The WAP ID to prepare. 2509 2510 Returns: 2511 The updated table name that should be used for writing. 2512 """ 2513 raise NotImplementedError(f"Engine does not support WAP: {type(self)}") 2514 2515 def wap_publish(self, table_name: TableName, wap_id: str) -> None: 2516 """Publishes changes with the given WAP ID to the target table. 2517 2518 Args: 2519 table_name: The name of the target table. 2520 wap_id: The WAP ID to publish. 2521 """ 2522 raise NotImplementedError(f"Engine does not support WAP: {type(self)}") 2523 2524 def sync_grants_config( 2525 self, 2526 table: exp.Table, 2527 grants_config: GrantsConfig, 2528 table_type: DataObjectType = DataObjectType.TABLE, 2529 ) -> None: 2530 """Applies the grants_config to a table authoritatively. 2531 It first compares the specified grants against the current grants, and then 2532 applies the diffs to the table by revoking and granting privileges as needed. 2533 2534 Args: 2535 table: The table/view to apply grants to. 2536 grants_config: Dictionary mapping privileges to lists of grantees. 2537 table_type: The type of database object (TABLE, VIEW, MATERIALIZED_VIEW). 2538 """ 2539 if not self.SUPPORTS_GRANTS: 2540 raise NotImplementedError(f"Engine does not support grants: {type(self)}") 2541 2542 current_grants = self._get_current_grants_config(table) 2543 new_grants, revoked_grants = self._diff_grants_configs(grants_config, current_grants) 2544 revoke_exprs = self._revoke_grants_config_expr(table, revoked_grants, table_type) 2545 grant_exprs = self._apply_grants_config_expr(table, new_grants, table_type) 2546 dcl_exprs = revoke_exprs + grant_exprs 2547 2548 if dcl_exprs: 2549 self.execute(dcl_exprs) 2550 2551 @contextlib.contextmanager 2552 def transaction( 2553 self, 2554 condition: t.Optional[bool] = None, 2555 ) -> t.Iterator[None]: 2556 """A transaction context manager.""" 2557 if ( 2558 self._connection_pool.is_transaction_active 2559 or not self.SUPPORTS_TRANSACTIONS 2560 or (condition is not None and not condition) 2561 ): 2562 yield 2563 return 2564 2565 if self._pre_ping: 2566 try: 2567 logger.debug("Pinging the database to check the connection") 2568 self.ping() 2569 except Exception: 2570 logger.info("Connection to the database was lost. Reconnecting...") 2571 self._connection_pool.close() 2572 2573 self._connection_pool.begin() 2574 try: 2575 yield 2576 except Exception as e: 2577 self._connection_pool.rollback() 2578 raise e 2579 else: 2580 self._connection_pool.commit() 2581 2582 @contextlib.contextmanager 2583 def session(self, properties: SessionProperties) -> t.Iterator[None]: 2584 """A session context manager.""" 2585 if self._is_session_active(): 2586 yield 2587 return 2588 2589 self._begin_session(properties) 2590 try: 2591 yield 2592 finally: 2593 self._end_session() 2594 2595 def _begin_session(self, properties: SessionProperties) -> t.Any: 2596 """Begin a new session.""" 2597 2598 def _end_session(self) -> None: 2599 """End the existing session.""" 2600 2601 def _is_session_active(self) -> bool: 2602 """Indicates whether or not a session is active.""" 2603 return False 2604 2605 def execute( 2606 self, 2607 expressions: t.Union[str, exp.Expr, t.Sequence[exp.Expr]], 2608 ignore_unsupported_errors: bool = False, 2609 quote_identifiers: bool = True, 2610 track_rows_processed: bool = False, 2611 **kwargs: t.Any, 2612 ) -> None: 2613 """Execute a sql query.""" 2614 to_sql_kwargs = ( 2615 {"unsupported_level": ErrorLevel.IGNORE} if ignore_unsupported_errors else {} 2616 ) 2617 with self.transaction(): 2618 for e in ensure_list(expressions): 2619 if isinstance(e, exp.Expr): 2620 self._check_identifier_length(e) 2621 sql = self._to_sql(e, quote=quote_identifiers, **to_sql_kwargs) 2622 else: 2623 sql = t.cast(str, e) 2624 2625 sql = self._attach_correlation_id(sql) 2626 2627 self._log_sql( 2628 sql, 2629 expression=e if isinstance(e, exp.Expr) else None, 2630 quote_identifiers=quote_identifiers, 2631 ) 2632 self._execute(sql, track_rows_processed, **kwargs) 2633 2634 def _attach_correlation_id(self, sql: str) -> str: 2635 if self.ATTACH_CORRELATION_ID and self.correlation_id: 2636 return f"/* {self.correlation_id} */ {sql}" 2637 return sql 2638 2639 def _log_sql( 2640 self, 2641 sql: str, 2642 expression: t.Optional[exp.Expr] = None, 2643 quote_identifiers: bool = True, 2644 ) -> None: 2645 if not logger.isEnabledFor(self._execute_log_level): 2646 return 2647 2648 sql_to_log = sql 2649 if expression is not None and not isinstance(expression, exp.Query): 2650 values = expression.find(exp.Values) 2651 if values: 2652 values.set("expressions", [exp.to_identifier("<REDACTED VALUES>")]) 2653 sql_to_log = self._to_sql(expression, quote=quote_identifiers) 2654 2655 logger.log(self._execute_log_level, "Executing SQL: %s", sql_to_log) 2656 2657 def _record_execution_stats( 2658 self, sql: str, rowcount: t.Optional[int] = None, bytes_processed: t.Optional[int] = None 2659 ) -> None: 2660 if self._query_execution_tracker: 2661 self._query_execution_tracker.record_execution(sql, rowcount, bytes_processed) 2662 2663 def _execute(self, sql: str, track_rows_processed: bool = False, **kwargs: t.Any) -> None: 2664 self.cursor.execute(sql, **kwargs) 2665 2666 if ( 2667 self.SUPPORTS_QUERY_EXECUTION_TRACKING 2668 and track_rows_processed 2669 and self._query_execution_tracker 2670 and self._query_execution_tracker.is_tracking() 2671 ): 2672 if ( 2673 rowcount := getattr(self.cursor, "rowcount", None) 2674 ) is not None and rowcount is not None: 2675 try: 2676 self._record_execution_stats(sql, int(rowcount)) 2677 except (TypeError, ValueError): 2678 return 2679 2680 @contextlib.contextmanager 2681 def temp_table( 2682 self, 2683 query_or_df: QueryOrDF, 2684 name: TableName = "diff", 2685 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 2686 source_columns: t.Optional[t.List[str]] = None, 2687 **kwargs: t.Any, 2688 ) -> t.Iterator[exp.Table]: 2689 """A context manager for working a temp table. 2690 2691 The table will be created with a random guid and cleaned up after the block. 2692 2693 Args: 2694 query_or_df: The query or df to create a temp table for. 2695 name: The base name of the temp table. 2696 target_columns_to_types: A mapping between the column name and its data type. 2697 2698 Yields: 2699 The table expression 2700 """ 2701 name = exp.to_table(name) 2702 # ensure that we use default catalog if none is not specified 2703 if isinstance(name, exp.Table) and not name.catalog and name.db and self.default_catalog: 2704 name.set("catalog", exp.parse_identifier(self.default_catalog)) 2705 2706 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 2707 query_or_df, 2708 target_columns_to_types=target_columns_to_types, 2709 target_table=name, 2710 source_columns=source_columns, 2711 ) 2712 2713 with self.transaction(): 2714 table = self._get_temp_table(name) 2715 if table.db: 2716 self.create_schema(schema_(table.args["db"], table.args.get("catalog"))) 2717 self._create_table_from_source_queries( 2718 table, 2719 source_queries, 2720 target_columns_to_types, 2721 exists=True, 2722 table_description=None, 2723 column_descriptions=None, 2724 track_rows_processed=False, 2725 **kwargs, 2726 ) 2727 2728 try: 2729 yield table 2730 finally: 2731 self.drop_table(table) 2732 2733 def _table_or_view_properties_to_expressions( 2734 self, table_or_view_properties: t.Optional[t.Dict[str, exp.Expr]] = None 2735 ) -> t.List[exp.Property]: 2736 """Converts model properties (either physical or virtual) to a list of property expressions.""" 2737 if not table_or_view_properties: 2738 return [] 2739 return [ 2740 exp.Property(this=key, value=value.copy()) 2741 for key, value in table_or_view_properties.items() 2742 ] 2743 2744 def _build_partitioned_by_exp( 2745 self, 2746 partitioned_by: t.List[exp.Expr], 2747 *, 2748 partition_interval_unit: t.Optional[IntervalUnit] = None, 2749 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 2750 catalog_name: t.Optional[str] = None, 2751 **kwargs: t.Any, 2752 ) -> t.Optional[t.Union[exp.PartitionedByProperty, exp.Property]]: 2753 return None 2754 2755 def _build_clustered_by_exp( 2756 self, 2757 clustered_by: t.List[exp.Expr], 2758 **kwargs: t.Any, 2759 ) -> t.Optional[exp.Cluster]: 2760 return None 2761 2762 def adjust_physical_properties_for_incremental( 2763 self, 2764 physical_properties: t.Dict[str, t.Any], 2765 *, 2766 requires_delete_capable_table: bool, 2767 unique_key: t.Optional[t.List[exp.Expr]], 2768 model_name: str, 2769 ) -> t.Dict[str, t.Any]: 2770 """Adjusts physical properties for an incremental model before the table is created. 2771 2772 Some engines require a specific physical table layout before they can run the DELETE/MERGE 2773 statements that incremental model kinds rely on (e.g. StarRocks only supports those on 2774 PRIMARY KEY tables). This hook lets each engine derive or validate the required properties 2775 while keeping the generic evaluator free of engine-specific branching. 2776 2777 Args: 2778 physical_properties: The model's physical properties. 2779 requires_delete_capable_table: Whether the model kind issues DELETE/MERGE statements 2780 (as opposed to append-only INSERTs), as determined by the generic evaluator. 2781 unique_key: The model's unique key, populated only when the kind allows promoting it to 2782 an engine-specific key (i.e. INCREMENTAL_BY_UNIQUE_KEY); otherwise None. 2783 model_name: The model name, for use in diagnostics. 2784 2785 Returns: 2786 The (possibly adjusted) physical properties. Implementations own the given mapping and 2787 may mutate it in place; the base implementation returns it unchanged. 2788 """ 2789 return physical_properties 2790 2791 def _build_table_properties_exp( 2792 self, 2793 catalog_name: t.Optional[str] = None, 2794 table_format: t.Optional[str] = None, 2795 storage_format: t.Optional[str] = None, 2796 partitioned_by: t.Optional[t.List[exp.Expr]] = None, 2797 partition_interval_unit: t.Optional[IntervalUnit] = None, 2798 clustered_by: t.Optional[t.List[exp.Expr]] = None, 2799 table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, 2800 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 2801 table_description: t.Optional[str] = None, 2802 table_kind: t.Optional[str] = None, 2803 **kwargs: t.Any, 2804 ) -> t.Optional[exp.Properties]: 2805 """Creates a SQLGlot table properties expression for ddl.""" 2806 properties: t.List[exp.Expr] = [] 2807 2808 if table_description: 2809 properties.append( 2810 exp.SchemaCommentProperty( 2811 this=exp.Literal.string(self._truncate_table_comment(table_description)) 2812 ) 2813 ) 2814 2815 if table_properties: 2816 table_type = self._pop_creatable_type_from_properties(table_properties) 2817 properties.extend(ensure_list(table_type)) 2818 2819 if properties: 2820 return exp.Properties(expressions=properties) 2821 return None 2822 2823 def _build_view_properties_exp( 2824 self, 2825 view_properties: t.Optional[t.Dict[str, exp.Expr]] = None, 2826 table_description: t.Optional[str] = None, 2827 **kwargs: t.Any, 2828 ) -> t.Optional[exp.Properties]: 2829 """Creates a SQLGlot table properties expression for view""" 2830 properties: t.List[exp.Expr] = [] 2831 2832 if table_description: 2833 properties.append( 2834 exp.SchemaCommentProperty( 2835 this=exp.Literal.string(self._truncate_table_comment(table_description)) 2836 ) 2837 ) 2838 2839 if properties: 2840 return exp.Properties(expressions=properties) 2841 return None 2842 2843 def _truncate_comment(self, comment: str, length: t.Optional[int]) -> str: 2844 return comment[:length] if length else comment 2845 2846 def _truncate_table_comment(self, comment: str) -> str: 2847 return self._truncate_comment(comment, self.MAX_TABLE_COMMENT_LENGTH) 2848 2849 def _truncate_column_comment(self, comment: str) -> str: 2850 return self._truncate_comment(comment, self.MAX_COLUMN_COMMENT_LENGTH) 2851 2852 def _to_sql(self, expression: exp.Expr, quote: bool = True, **kwargs: t.Any) -> str: 2853 """ 2854 Converts an expression to a SQL string. Has a set of default kwargs to apply, and then default 2855 kwargs defined for the given dialect, and then kwargs provided by the user when defining the engine 2856 adapter, and then finally kwargs provided by the user when calling this method. 2857 """ 2858 sql_gen_kwargs = { 2859 "dialect": self.dialect, 2860 "pretty": self._pretty_sql, 2861 "comments": False, 2862 **self._sql_gen_kwargs, 2863 **kwargs, 2864 } 2865 2866 expression = expression.copy() 2867 2868 if quote: 2869 quote_identifiers(expression) 2870 2871 return expression.sql(**sql_gen_kwargs, copy=False) # type: ignore 2872 2873 def _clear_data_object_cache(self, table_name: t.Optional[TableName] = None) -> None: 2874 """Clears the cache entry for the given table name, or clears the entire cache if table_name is None.""" 2875 if table_name is None: 2876 logger.debug("Clearing entire data object cache") 2877 self._data_object_cache.clear() 2878 else: 2879 table = exp.to_table(table_name) 2880 cache_key = _get_data_object_cache_key(table.catalog, table.db, table.name) 2881 logger.debug("Clearing data object cache key: %s", cache_key) 2882 self._data_object_cache.pop(cache_key, None) 2883 2884 def _get_data_objects( 2885 self, schema_name: SchemaName, object_names: t.Optional[t.Set[str]] = None 2886 ) -> t.List[DataObject]: 2887 """ 2888 Returns all the data objects that exist in the given schema and optionally catalog. 2889 """ 2890 raise NotImplementedError() 2891 2892 def _get_temp_table( 2893 self, table: TableName, table_only: bool = False, quoted: bool = True 2894 ) -> exp.Table: 2895 """ 2896 Returns the name of the temp table that should be used for the given table name. 2897 """ 2898 table = t.cast(exp.Table, exp.to_table(table).copy()) 2899 table.set( 2900 "this", exp.to_identifier(f"__temp_{table.name}_{random_id(short=True)}", quoted=quoted) 2901 ) 2902 2903 if table_only: 2904 table.set("db", None) 2905 table.set("catalog", None) 2906 2907 return table 2908 2909 def _order_projections_and_filter( 2910 self, 2911 query: Query, 2912 target_columns_to_types: t.Dict[str, exp.DataType], 2913 where: t.Optional[exp.Expr] = None, 2914 coerce_types: bool = False, 2915 ) -> Query: 2916 if not isinstance(query, exp.Query) or ( 2917 not where and not coerce_types and query.named_selects == list(target_columns_to_types) 2918 ): 2919 return query 2920 2921 query = t.cast(exp.Query, query.copy()) 2922 with_ = query.args.pop("with_", None) 2923 2924 select_exprs: t.List[exp.Expr] = [ 2925 exp.column(c, quoted=True) for c in target_columns_to_types 2926 ] 2927 if coerce_types and columns_to_types_all_known(target_columns_to_types): 2928 select_exprs = [ 2929 exp.cast(select_exprs[i], col_tpe).as_(col, quoted=True) 2930 for i, (col, col_tpe) in enumerate(target_columns_to_types.items()) 2931 ] 2932 2933 query = exp.select(*select_exprs).from_(query.subquery("_subquery", copy=False), copy=False) 2934 if where: 2935 query = query.where(where, copy=False) 2936 2937 if with_: 2938 query.set("with_", with_) 2939 2940 return query 2941 2942 def _truncate_table(self, table_name: TableName) -> None: 2943 table = exp.to_table(table_name) 2944 self.execute(f"TRUNCATE TABLE {table.sql(dialect=self.dialect, identify=True)}") 2945 2946 def drop_data_object_on_type_mismatch( 2947 self, data_object: t.Optional[DataObject], expected_type: DataObjectType 2948 ) -> bool: 2949 """Drops a data object if it exists and is not of the expected type. 2950 2951 Args: 2952 data_object: The data object to check. 2953 expected_type: The expected type of the data object. 2954 2955 Returns: 2956 True if the data object was dropped, False otherwise. 2957 """ 2958 if data_object is None or data_object.type == expected_type: 2959 return False 2960 2961 logger.warning( 2962 "Target data object '%s' is a %s and not a %s, dropping it", 2963 data_object.to_table().sql(dialect=self.dialect), 2964 data_object.type.value, 2965 expected_type.value, 2966 ) 2967 self.drop_data_object(data_object) 2968 return True 2969 2970 def _replace_by_key( 2971 self, 2972 target_table: TableName, 2973 source_table: QueryOrDF, 2974 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], 2975 key: t.Sequence[exp.Expr], 2976 is_unique_key: bool, 2977 source_columns: t.Optional[t.List[str]] = None, 2978 ) -> None: 2979 if target_columns_to_types is None: 2980 target_columns_to_types = self.columns(target_table) 2981 2982 temp_table = self._get_temp_table(target_table) 2983 key_exp = ( 2984 exp.func("CONCAT_WS", "'__SQLMESH_DELIM__'", *key, dialect=self.dialect) 2985 if len(key) > 1 2986 else key[0] 2987 ) 2988 column_names = list(target_columns_to_types or []) 2989 2990 with self.transaction(): 2991 self.ctas( 2992 temp_table, 2993 source_table, 2994 target_columns_to_types=target_columns_to_types, 2995 exists=False, 2996 source_columns=source_columns, 2997 ) 2998 2999 try: 3000 delete_query = exp.select(key_exp).from_(temp_table) 3001 insert_query = self._select_columns(target_columns_to_types).from_(temp_table) 3002 if not is_unique_key: 3003 delete_query = delete_query.distinct() 3004 else: 3005 insert_query = insert_query.distinct(*key) 3006 3007 insert_statement = exp.insert( 3008 insert_query, 3009 target_table, 3010 columns=column_names, 3011 ) 3012 delete_filter = key_exp.isin(query=delete_query) 3013 3014 if not self.INSERT_OVERWRITE_STRATEGY.is_replace_where: 3015 self.delete_from(target_table, delete_filter) 3016 else: 3017 insert_statement.set("where", delete_filter) 3018 insert_statement.set("this", exp.to_table(target_table)) 3019 3020 self.execute(insert_statement, track_rows_processed=True) 3021 finally: 3022 self.drop_table(temp_table) 3023 3024 def _build_create_comment_table_exp( 3025 self, table: exp.Table, table_comment: str, table_kind: str 3026 ) -> exp.Comment | str: 3027 return exp.Comment( 3028 this=table, 3029 kind=table_kind, 3030 expression=exp.Literal.string(self._truncate_table_comment(table_comment)), 3031 ) 3032 3033 def _create_table_comment( 3034 self, table_name: TableName, table_comment: str, table_kind: str = "TABLE" 3035 ) -> None: 3036 table = exp.to_table(table_name) 3037 3038 try: 3039 self.execute(self._build_create_comment_table_exp(table, table_comment, table_kind)) 3040 except Exception: 3041 logger.warning( 3042 f"Table comment for '{table.alias_or_name}' not registered - this may be due to limited permissions", 3043 exc_info=True, 3044 ) 3045 3046 def _build_create_comment_column_exp( 3047 self, table: exp.Table, column_name: str, column_comment: str, table_kind: str = "TABLE" 3048 ) -> exp.Comment | str: 3049 return exp.Comment( 3050 this=exp.column(column_name, *reversed(table.parts)), # type: ignore 3051 kind="COLUMN", 3052 expression=exp.Literal.string(self._truncate_column_comment(column_comment)), 3053 ) 3054 3055 def _create_column_comments( 3056 self, 3057 table_name: TableName, 3058 column_comments: t.Dict[str, str], 3059 table_kind: str = "TABLE", 3060 materialized_view: bool = False, 3061 ) -> None: 3062 table = exp.to_table(table_name) 3063 3064 for col, comment in column_comments.items(): 3065 try: 3066 self.execute(self._build_create_comment_column_exp(table, col, comment, table_kind)) 3067 except Exception: 3068 logger.warning( 3069 f"Column comments for column '{col}' in table '{table.alias_or_name}' not registered - this may be due to limited permissions", 3070 exc_info=True, 3071 ) 3072 3073 def _create_table_like( 3074 self, 3075 target_table_name: TableName, 3076 source_table_name: TableName, 3077 exists: bool, 3078 **kwargs: t.Any, 3079 ) -> None: 3080 self.create_table(target_table_name, self.columns(source_table_name), exists=exists) 3081 3082 def _rename_table( 3083 self, 3084 old_table_name: TableName, 3085 new_table_name: TableName, 3086 ) -> None: 3087 self.execute(exp.rename_table(old_table_name, new_table_name)) 3088 3089 def ensure_nulls_for_unmatched_after_join( 3090 self, 3091 query: Query, 3092 ) -> Query: 3093 return query 3094 3095 def use_server_nulls_for_unmatched_after_join( 3096 self, 3097 query: Query, 3098 ) -> Query: 3099 return query 3100 3101 def ping(self) -> None: 3102 try: 3103 self._execute(exp.select("1").sql(dialect=self.dialect)) 3104 finally: 3105 self._connection_pool.close_cursor() 3106 3107 @classmethod 3108 def _select_columns( 3109 cls, columns: t.Iterable[str], source_columns: t.Optional[t.List[str]] = None 3110 ) -> exp.Select: 3111 return exp.select( 3112 *( 3113 exp.column(c, quoted=True) 3114 if c in (source_columns or columns) 3115 else exp.alias_(exp.Null(), c, quoted=True) 3116 for c in columns 3117 ) 3118 ) 3119 3120 def _check_identifier_length(self, expression: exp.Expr) -> None: 3121 if self.MAX_IDENTIFIER_LENGTH is None or not isinstance(expression, exp.DDL): 3122 return 3123 3124 for identifier in expression.find_all(exp.Identifier): 3125 name = identifier.name 3126 name_length = len(name) 3127 if name_length > self.MAX_IDENTIFIER_LENGTH: 3128 raise SQLMeshError( 3129 f"Identifier name '{name}' (length {name_length}) exceeds {self.dialect.capitalize()}'s max identifier limit of {self.MAX_IDENTIFIER_LENGTH} characters" 3130 ) 3131 3132 def get_table_last_modified_ts(self, table_names: t.List[TableName]) -> t.List[int]: 3133 raise NotImplementedError() 3134 3135 @classmethod 3136 def _diff_grants_configs( 3137 cls, new_config: GrantsConfig, old_config: GrantsConfig 3138 ) -> t.Tuple[GrantsConfig, GrantsConfig]: 3139 """Compute additions and removals between two grants configurations. 3140 3141 This method compares new (desired) and old (current) GrantsConfigs case-insensitively 3142 for both privilege keys and grantees, while preserving original casing 3143 in the output GrantsConfigs. 3144 3145 Args: 3146 new_config: Desired grants configuration (specified by the user). 3147 old_config: Current grants configuration (returned by the database). 3148 3149 Returns: 3150 A tuple of (additions, removals) GrantsConfig where: 3151 - additions contains privileges/grantees present in new_config but not in old_config 3152 - additions uses keys and grantee strings from new_config (user-specified casing) 3153 - removals contains privileges/grantees present in old_config but not in new_config 3154 - removals uses keys and grantee strings from old_config (database-returned casing) 3155 3156 Notes: 3157 - Comparison is case-insensitive using casefold(); original casing is preserved in results. 3158 - Overlapping grantees (case-insensitive) are excluded from the results. 3159 """ 3160 3161 def _diffs(config1: GrantsConfig, config2: GrantsConfig) -> GrantsConfig: 3162 diffs: GrantsConfig = {} 3163 cf_config2 = {k.casefold(): {g.casefold() for g in v} for k, v in config2.items()} 3164 for key, grantees in config1.items(): 3165 cf_key = key.casefold() 3166 3167 # Missing key (add all grantees) 3168 if cf_key not in cf_config2: 3169 diffs[key] = grantees.copy() 3170 continue 3171 3172 # Include only grantees not in config2 3173 cf_grantees2 = cf_config2[cf_key] 3174 diff_grantees = [] 3175 for grantee in grantees: 3176 if grantee.casefold() not in cf_grantees2: 3177 diff_grantees.append(grantee) 3178 if diff_grantees: 3179 diffs[key] = diff_grantees 3180 return diffs 3181 3182 return _diffs(new_config, old_config), _diffs(old_config, new_config) 3183 3184 def _get_current_grants_config(self, table: exp.Table) -> GrantsConfig: 3185 """Returns current grants for a table as a dictionary. 3186 3187 This method queries the database and returns the current grants/permissions 3188 for the given table, parsed into a dictionary format. The it handles 3189 case-insensitive comparison between these current grants and the desired 3190 grants from model configuration. 3191 3192 Args: 3193 table: The table/view to query grants for. 3194 3195 Returns: 3196 Dictionary mapping permissions to lists of grantees. Permission names 3197 should be returned as the database provides them (typically uppercase 3198 for standard SQL permissions, but engine-specific roles may vary). 3199 3200 Raises: 3201 NotImplementedError: If the engine does not support grants. 3202 """ 3203 if not self.SUPPORTS_GRANTS: 3204 raise NotImplementedError(f"Engine does not support grants: {type(self)}") 3205 raise NotImplementedError("Subclass must implement get_current_grants") 3206 3207 def _apply_grants_config_expr( 3208 self, 3209 table: exp.Table, 3210 grants_config: GrantsConfig, 3211 table_type: DataObjectType = DataObjectType.TABLE, 3212 ) -> t.List[exp.Expr]: 3213 """Returns SQLGlot Grant expressions to apply grants to a table. 3214 3215 Args: 3216 table: The table/view to grant permissions on. 3217 grants_config: Dictionary mapping permissions to lists of grantees. 3218 table_type: The type of database object (TABLE, VIEW, MATERIALIZED_VIEW). 3219 3220 Returns: 3221 List of SQLGlot expressions for grant operations. 3222 3223 Raises: 3224 NotImplementedError: If the engine does not support grants. 3225 """ 3226 if not self.SUPPORTS_GRANTS: 3227 raise NotImplementedError(f"Engine does not support grants: {type(self)}") 3228 raise NotImplementedError("Subclass must implement _apply_grants_config_expr") 3229 3230 def _revoke_grants_config_expr( 3231 self, 3232 table: exp.Table, 3233 grants_config: GrantsConfig, 3234 table_type: DataObjectType = DataObjectType.TABLE, 3235 ) -> t.List[exp.Expr]: 3236 """Returns SQLGlot expressions to revoke grants from a table. 3237 3238 Args: 3239 table: The table/view to revoke permissions from. 3240 grants_config: Dictionary mapping permissions to lists of grantees. 3241 table_type: The type of database object (TABLE, VIEW, MATERIALIZED_VIEW). 3242 3243 Returns: 3244 List of SQLGlot expressions for revoke operations. 3245 3246 Raises: 3247 NotImplementedError: If the engine does not support grants. 3248 """ 3249 if not self.SUPPORTS_GRANTS: 3250 raise NotImplementedError(f"Engine does not support grants: {type(self)}") 3251 raise NotImplementedError("Subclass must implement _revoke_grants_config_expr")
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.
132 def __init__( 133 self, 134 connection_factory_or_pool: t.Union[t.Callable[[], t.Any], ConnectionPool], 135 dialect: str = "", 136 sql_gen_kwargs: t.Optional[t.Dict[str, Dialect | bool | str]] = None, 137 multithreaded: bool = False, 138 cursor_init: t.Optional[t.Callable[[t.Any], None]] = None, 139 default_catalog: t.Optional[str] = None, 140 execute_log_level: int = logging.DEBUG, 141 register_comments: bool = True, 142 pre_ping: bool = False, 143 pretty_sql: bool = False, 144 shared_connection: bool = False, 145 correlation_id: t.Optional[CorrelationId] = None, 146 schema_differ_overrides: t.Optional[t.Dict[str, t.Any]] = None, 147 query_execution_tracker: t.Optional[QueryExecutionTracker] = None, 148 **kwargs: t.Any, 149 ): 150 self.dialect = dialect.lower() or self.DIALECT 151 self._connection_pool = ( 152 connection_factory_or_pool 153 if isinstance(connection_factory_or_pool, ConnectionPool) 154 else create_connection_pool( 155 connection_factory_or_pool, 156 multithreaded, 157 shared_connection=shared_connection, 158 cursor_init=cursor_init, 159 ) 160 ) 161 self._sql_gen_kwargs = sql_gen_kwargs or {} 162 self._default_catalog = default_catalog 163 self._execute_log_level = execute_log_level 164 self._extra_config = kwargs 165 self._register_comments = register_comments 166 self._pre_ping = pre_ping 167 self._pretty_sql = pretty_sql 168 self._multithreaded = multithreaded 169 self.correlation_id = correlation_id 170 self._schema_differ_overrides = schema_differ_overrides 171 self._query_execution_tracker = query_execution_tracker 172 self._data_object_cache: t.Dict[str, t.Optional[DataObject]] = {}
Physical property keys whose values may contain logical model references that should be resolved to physical table names during property rendering. Engines that need such resolution (e.g. StarRocks' excluded_trigger_tables) override this set.
174 def with_settings(self, **kwargs: t.Any) -> EngineAdapter: 175 extra_kwargs = { 176 "null_connection": True, 177 "execute_log_level": kwargs.pop("execute_log_level", self._execute_log_level), 178 "correlation_id": kwargs.pop("correlation_id", self.correlation_id), 179 "query_execution_tracker": kwargs.pop( 180 "query_execution_tracker", self._query_execution_tracker 181 ), 182 **self._extra_config, 183 **kwargs, 184 } 185 186 adapter = self.__class__( 187 self._connection_pool, 188 dialect=self.dialect, 189 sql_gen_kwargs=self._sql_gen_kwargs, 190 default_catalog=self._default_catalog, 191 register_comments=self._register_comments, 192 multithreaded=self._multithreaded, 193 pretty_sql=self._pretty_sql, 194 **extra_kwargs, 195 ) 196 197 return adapter
227 def supports_virtual_catalog(self) -> bool: 228 """Return True if this adapter can accept a virtual catalog for multi-gateway nesting alignment. 229 230 When a project mixes catalog-aware gateways (e.g. DuckDB) with catalog-unsupported gateways 231 (e.g. ClickHouse), all adapters need a uniform 3-level FQN so MappingSchema nesting stays 232 consistent. Adapters that return True here opt in to receiving an injected virtual catalog 233 via inject_virtual_catalog(), which causes the set_catalog decorator to strip the catalog 234 from DDL expressions rather than raising UnsupportedCatalogOperationError. 235 """ 236 return False
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.
238 def inject_virtual_catalog(self, gateway: str) -> None: 239 """Inject a gateway name to configure the adapter's virtual catalog. 240 241 The adapter determines the final catalog name from the gateway name (e.g. ClickHouse 242 wraps it as __{gateway}__). Only call this on adapters that return True from 243 supports_virtual_catalog(). After injection, catalog_support should return 244 SINGLE_CATALOG_ONLY so the set_catalog decorator strips the virtual catalog from DDL 245 expressions instead of raising an error. 246 """ 247 raise NotImplementedError( 248 f"{self.dialect} does not support virtual catalog injection. " 249 "Override supports_virtual_catalog() to return True and implement inject_virtual_catalog()." 250 )
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.
287 @property 288 def default_catalog(self) -> t.Optional[str]: 289 if self.catalog_support.is_unsupported: 290 return None 291 default_catalog = self._default_catalog or self.get_current_catalog() 292 if not default_catalog: 293 raise MissingDefaultCatalogError( 294 "Could not determine a default catalog despite it being supported." 295 ) 296 return default_catalog
447 def recycle(self) -> None: 448 """Closes all open connections and releases all allocated resources associated with any thread 449 except the calling one.""" 450 self._connection_pool.close_all(exclude_calling_thread=True)
Closes all open connections and releases all allocated resources associated with any thread except the calling one.
452 def close(self) -> t.Any: 453 """Closes all open connections and releases all allocated resources.""" 454 self._connection_pool.close_all()
Closes all open connections and releases all allocated resources.
456 def get_current_catalog(self) -> t.Optional[str]: 457 """Returns the catalog name of the current connection.""" 458 raise NotImplementedError()
Returns the catalog name of the current connection.
460 def set_current_catalog(self, catalog: str) -> None: 461 """Sets the catalog name of the current connection.""" 462 raise NotImplementedError()
Sets the catalog name of the current connection.
464 def get_catalog_type(self, catalog: t.Optional[str]) -> str: 465 """Intended to be overridden for data virtualization systems like Trino that, 466 depending on the target catalog, require slightly different properties to be set when creating / updating tables 467 """ 468 if self.catalog_support.is_unsupported: 469 raise UnsupportedCatalogOperationError( 470 f"{self.dialect} does not support catalogs and a catalog was provided: {catalog}" 471 ) 472 return ( 473 self._catalog_type_overrides.get(catalog, self.DEFAULT_CATALOG_TYPE) 474 if catalog 475 else self.DEFAULT_CATALOG_TYPE 476 )
Intended to be overridden for data virtualization systems like Trino that, depending on the target catalog, require slightly different properties to be set when creating / updating tables
478 def get_catalog_type_from_table(self, table: TableName) -> str: 479 """Get the catalog type from a table name if it has a catalog specified, otherwise return the current catalog type""" 480 catalog = exp.to_table(table).catalog or self.get_current_catalog() 481 return self.get_catalog_type(catalog)
Get the catalog type from a table name if it has a catalog specified, otherwise return the current catalog type
483 @property 484 def current_catalog_type(self) -> str: 485 # `get_catalog_type_from_table` should be used over this property. Reason is that the table that is the target 486 # of the operation is what matters and not the catalog type of the connection. 487 # This still remains for legacy reasons and should be refactored out. 488 return self.get_catalog_type(self.get_current_catalog())
490 def replace_query( 491 self, 492 table_name: TableName, 493 query_or_df: QueryOrDF, 494 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 495 table_description: t.Optional[str] = None, 496 column_descriptions: t.Optional[t.Dict[str, str]] = None, 497 source_columns: t.Optional[t.List[str]] = None, 498 supports_replace_table_override: t.Optional[bool] = None, 499 **kwargs: t.Any, 500 ) -> None: 501 """Replaces an existing table with a query. 502 503 For partition based engines (hive, spark), insert override is used. For other systems, create or replace is used. 504 505 Args: 506 table_name: The name of the table (eg. prod.table) 507 query_or_df: The SQL query to run or a dataframe. 508 target_columns_to_types: Only used if a dataframe is provided. A mapping between the column name and its data type. 509 Expected to be ordered to match the order of values in the dataframe. 510 kwargs: Optional create table properties. 511 """ 512 target_table = exp.to_table(table_name) 513 514 target_data_object = self.get_data_object(target_table) 515 table_exists = target_data_object is not None 516 if self.drop_data_object_on_type_mismatch(target_data_object, DataObjectType.TABLE): 517 table_exists = False 518 519 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 520 query_or_df, 521 target_columns_to_types, 522 target_table=target_table, 523 source_columns=source_columns, 524 ) 525 if not target_columns_to_types and table_exists: 526 target_columns_to_types = self.columns(target_table) 527 query = source_queries[0].query_factory() 528 self_referencing = any( 529 quote_identifiers(table) == quote_identifiers(target_table) 530 for table in query.find_all(exp.Table) 531 ) 532 # If a query references itself then it must have a table created regardless of approach used. 533 if self_referencing: 534 if not target_columns_to_types: 535 raise SQLMeshError( 536 f"Cannot create a self-referencing table {target_table.sql(dialect=self.dialect)} without knowing the column types. " 537 "Try casting the columns to an expected type or defining the columns in the model metadata. " 538 ) 539 self._create_table_from_columns( 540 target_table, 541 target_columns_to_types, 542 exists=True, 543 table_description=table_description, 544 column_descriptions=column_descriptions, 545 **kwargs, 546 ) 547 # All engines support `CREATE TABLE AS` so we use that if the table doesn't already exist and we 548 # use `CREATE OR REPLACE TABLE AS` if the engine supports it 549 supports_replace_table = ( 550 self.SUPPORTS_REPLACE_TABLE 551 if supports_replace_table_override is None 552 else supports_replace_table_override 553 ) 554 if supports_replace_table or not table_exists: 555 return self._create_table_from_source_queries( 556 target_table, 557 source_queries, 558 target_columns_to_types, 559 replace=supports_replace_table, 560 table_description=table_description, 561 column_descriptions=column_descriptions, 562 **kwargs, 563 ) 564 if self_referencing: 565 assert target_columns_to_types is not None 566 with self.temp_table( 567 self._select_columns(target_columns_to_types).from_(target_table), 568 name=target_table, 569 target_columns_to_types=target_columns_to_types, 570 **kwargs, 571 ) as temp_table: 572 for source_query in source_queries: 573 source_query.add_transform( 574 lambda node: ( # type: ignore 575 temp_table # type: ignore 576 if isinstance(node, exp.Table) 577 and quote_identifiers(node) == quote_identifiers(target_table) 578 else node 579 ) 580 ) 581 return self._insert_overwrite_by_condition( 582 target_table, 583 source_queries, 584 target_columns_to_types, 585 **kwargs, 586 ) 587 return self._insert_overwrite_by_condition( 588 target_table, 589 source_queries, 590 target_columns_to_types, 591 **kwargs, 592 )
Replaces an existing table with a query.
For partition based engines (hive, spark), insert override is used. For other systems, create or replace is used.
Arguments:
- table_name: The name of the table (eg. prod.table)
- query_or_df: The SQL query to run or a dataframe.
- target_columns_to_types: Only used if a dataframe is provided. A mapping between the column name and its data type. Expected to be ordered to match the order of values in the dataframe.
- kwargs: Optional create table properties.
594 def create_index( 595 self, 596 table_name: TableName, 597 index_name: str, 598 columns: t.Tuple[str, ...], 599 exists: bool = True, 600 ) -> None: 601 """Creates a new index for the given table if supported 602 603 Args: 604 table_name: The name of the target table. 605 index_name: The name of the index. 606 columns: The list of columns that constitute the index. 607 exists: Indicates whether to include the IF NOT EXISTS check. 608 """ 609 if not self.SUPPORTS_INDEXES: 610 return 611 612 expression = exp.Create( 613 this=exp.Index( 614 this=exp.to_identifier(index_name), 615 table=exp.to_table(table_name), 616 params=exp.IndexParameters(columns=[exp.to_column(c) for c in columns]), 617 ), 618 kind="INDEX", 619 exists=exists, 620 ) 621 self.execute(expression)
Creates a new index for the given table if supported
Arguments:
- table_name: The name of the target table.
- index_name: The name of the index.
- columns: The list of columns that constitute the index.
- exists: Indicates whether to include the IF NOT EXISTS check.
650 def create_table( 651 self, 652 table_name: TableName, 653 target_columns_to_types: t.Dict[str, exp.DataType], 654 primary_key: t.Optional[t.Tuple[str, ...]] = None, 655 exists: bool = True, 656 table_description: t.Optional[str] = None, 657 column_descriptions: t.Optional[t.Dict[str, str]] = None, 658 **kwargs: t.Any, 659 ) -> None: 660 """Create a table using a DDL statement 661 662 Args: 663 table_name: The name of the table to create. Can be fully qualified or just table name. 664 target_columns_to_types: A mapping between the column name and its data type. 665 primary_key: Determines the table primary key. 666 exists: Indicates whether to include the IF NOT EXISTS check. 667 table_description: Optional table description from MODEL DDL. 668 column_descriptions: Optional column descriptions from model query. 669 kwargs: Optional create table properties. 670 """ 671 self._create_table_from_columns( 672 table_name, 673 target_columns_to_types, 674 primary_key, 675 exists, 676 table_description, 677 column_descriptions, 678 **kwargs, 679 )
Create a table using a DDL statement
Arguments:
- table_name: The name of the table to create. Can be fully qualified or just table name.
- target_columns_to_types: A mapping between the column name and its data type.
- primary_key: Determines the table primary key.
- exists: Indicates whether to include the IF NOT EXISTS check.
- table_description: Optional table description from MODEL DDL.
- column_descriptions: Optional column descriptions from model query.
- kwargs: Optional create table properties.
681 def create_managed_table( 682 self, 683 table_name: TableName, 684 query: Query, 685 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 686 partitioned_by: t.Optional[t.List[exp.Expr]] = None, 687 clustered_by: t.Optional[t.List[exp.Expr]] = None, 688 table_properties: t.Optional[t.Dict[str, exp.Expr]] = None, 689 table_description: t.Optional[str] = None, 690 column_descriptions: t.Optional[t.Dict[str, str]] = None, 691 source_columns: t.Optional[t.List[str]] = None, 692 **kwargs: t.Any, 693 ) -> None: 694 """Create a managed table using a query. 695 696 "Managed" means that once the table is created, the data is kept up to date by the underlying database engine and not SQLMesh. 697 698 Args: 699 table_name: The name of the table to create. Can be fully qualified or just table name. 700 query: The SQL query for the engine to base the managed table on 701 target_columns_to_types: A mapping between the column name and its data type. 702 partitioned_by: The partition columns or engine specific expressions, only applicable in certain engines. (eg. (ds, hour)) 703 clustered_by: The cluster columns or engine specific expressions, only applicable in certain engines. (eg. (ds, hour)) 704 table_properties: Optional mapping of engine-specific properties to be set on the managed table 705 table_description: Optional table description from MODEL DDL. 706 column_descriptions: Optional column descriptions from model query. 707 kwargs: Optional create table properties. 708 """ 709 raise NotImplementedError(f"Engine does not support managed tables: {type(self)}")
Create a managed table using a query.
"Managed" means that once the table is created, the data is kept up to date by the underlying database engine and not SQLMesh.
Arguments:
- table_name: The name of the table to create. Can be fully qualified or just table name.
- query: The SQL query for the engine to base the managed table on
- target_columns_to_types: A mapping between the column name and its data type.
- partitioned_by: The partition columns or engine specific expressions, only applicable in certain engines. (eg. (ds, hour))
- clustered_by: The cluster columns or engine specific expressions, only applicable in certain engines. (eg. (ds, hour))
- table_properties: Optional mapping of engine-specific properties to be set on the managed table
- table_description: Optional table description from MODEL DDL.
- column_descriptions: Optional column descriptions from model query.
- kwargs: Optional create table properties.
711 def ctas( 712 self, 713 table_name: TableName, 714 query_or_df: QueryOrDF, 715 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 716 exists: bool = True, 717 table_description: t.Optional[str] = None, 718 column_descriptions: t.Optional[t.Dict[str, str]] = None, 719 source_columns: t.Optional[t.List[str]] = None, 720 **kwargs: t.Any, 721 ) -> None: 722 """Create a table using a CTAS statement 723 724 Args: 725 table_name: The name of the table to create. Can be fully qualified or just table name. 726 query_or_df: The SQL query to run or a dataframe for the CTAS. 727 target_columns_to_types: A mapping between the column name and its data type. Required if using a DataFrame. 728 exists: Indicates whether to include the IF NOT EXISTS check. 729 table_description: Optional table description from MODEL DDL. 730 column_descriptions: Optional column descriptions from model query. 731 kwargs: Optional create table properties. 732 """ 733 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 734 query_or_df, 735 target_columns_to_types, 736 target_table=table_name, 737 source_columns=source_columns, 738 ) 739 return self._create_table_from_source_queries( 740 table_name, 741 source_queries, 742 target_columns_to_types, 743 exists, 744 table_description=table_description, 745 column_descriptions=column_descriptions, 746 **kwargs, 747 )
Create a table using a CTAS statement
Arguments:
- table_name: The name of the table to create. Can be fully qualified or just table name.
- query_or_df: The SQL query to run or a dataframe for the CTAS.
- target_columns_to_types: A mapping between the column name and its data type. Required if using a DataFrame.
- exists: Indicates whether to include the IF NOT EXISTS check.
- table_description: Optional table description from MODEL DDL.
- column_descriptions: Optional column descriptions from model query.
- kwargs: Optional create table properties.
749 def create_state_table( 750 self, 751 table_name: str, 752 target_columns_to_types: t.Dict[str, exp.DataType], 753 primary_key: t.Optional[t.Tuple[str, ...]] = None, 754 ) -> None: 755 """Create a table to store SQLMesh internal state. 756 757 Args: 758 table_name: The name of the table to create. Can be fully qualified or just table name. 759 target_columns_to_types: A mapping between the column name and its data type. 760 primary_key: Determines the table primary key. 761 """ 762 self.create_table( 763 table_name, 764 target_columns_to_types, 765 primary_key=primary_key, 766 )
Create a table to store SQLMesh internal state.
Arguments:
- table_name: The name of the table to create. Can be fully qualified or just table name.
- target_columns_to_types: A mapping between the column name and its data type.
- primary_key: Determines the table primary key.
1073 def create_table_like( 1074 self, 1075 target_table_name: TableName, 1076 source_table_name: TableName, 1077 exists: bool = True, 1078 **kwargs: t.Any, 1079 ) -> None: 1080 """Create a table to store SQLMesh internal state based on the definition of another table, including any 1081 column attributes and indexes defined in the original table. 1082 1083 Args: 1084 target_table_name: The name of the table to create. Can be fully qualified or just table name. 1085 source_table_name: The name of the table to base the new table on. 1086 """ 1087 self._create_table_like(target_table_name, source_table_name, exists=exists, **kwargs) 1088 self._clear_data_object_cache(target_table_name)
Create a table to store SQLMesh internal state based on the definition of another table, including any column attributes and indexes defined in the original table.
Arguments:
- target_table_name: The name of the table to create. Can be fully qualified or just table name.
- source_table_name: The name of the table to base the new table on.
1090 def clone_table( 1091 self, 1092 target_table_name: TableName, 1093 source_table_name: TableName, 1094 replace: bool = False, 1095 exists: bool = True, 1096 clone_kwargs: t.Optional[t.Dict[str, t.Any]] = None, 1097 **kwargs: t.Any, 1098 ) -> None: 1099 """Creates a table with the target name by cloning the source table. 1100 1101 Args: 1102 target_table_name: The name of the table that should be created. 1103 source_table_name: The name of the source table that should be cloned. 1104 replace: Whether or not to replace an existing table. 1105 exists: Indicates whether to include the IF NOT EXISTS check. 1106 """ 1107 if not self.SUPPORTS_CLONING: 1108 raise NotImplementedError(f"Engine does not support cloning: {type(self)}") 1109 1110 kwargs.pop("rendered_physical_properties", None) 1111 self.execute( 1112 exp.Create( 1113 this=exp.to_table(target_table_name), 1114 kind="TABLE", 1115 replace=replace, 1116 exists=exists, 1117 clone=exp.Clone( 1118 this=exp.to_table(source_table_name), 1119 **(clone_kwargs or {}), 1120 ), 1121 **kwargs, 1122 ) 1123 ) 1124 self._clear_data_object_cache(target_table_name)
Creates a table with the target name by cloning the source table.
Arguments:
- target_table_name: The name of the table that should be created.
- source_table_name: The name of the source table that should be cloned.
- replace: Whether or not to replace an existing table.
- exists: Indicates whether to include the IF NOT EXISTS check.
1126 def drop_data_object(self, data_object: DataObject, ignore_if_not_exists: bool = True) -> None: 1127 """Drops a data object of arbitrary type. 1128 1129 Args: 1130 data_object: The data object to drop. 1131 ignore_if_not_exists: If True, no error will be raised if the data object does not exist. 1132 """ 1133 if data_object.type.is_view: 1134 self.drop_view(data_object.to_table(), ignore_if_not_exists=ignore_if_not_exists) 1135 elif data_object.type.is_materialized_view: 1136 self.drop_view( 1137 data_object.to_table(), ignore_if_not_exists=ignore_if_not_exists, materialized=True 1138 ) 1139 elif data_object.type.is_table: 1140 self.drop_table(data_object.to_table(), exists=ignore_if_not_exists) 1141 elif data_object.type.is_managed_table: 1142 self.drop_managed_table(data_object.to_table(), exists=ignore_if_not_exists) 1143 else: 1144 raise SQLMeshError( 1145 f"Can't drop data object '{data_object.to_table().sql(dialect=self.dialect)}' of type '{data_object.type.value}'" 1146 )
Drops a data object of arbitrary type.
Arguments:
- data_object: The data object to drop.
- ignore_if_not_exists: If True, no error will be raised if the data object does not exist.
1148 def drop_table(self, table_name: TableName, exists: bool = True, **kwargs: t.Any) -> None: 1149 """Drops a table. 1150 1151 Args: 1152 table_name: The name of the table to drop. 1153 exists: If exists, defaults to True. 1154 """ 1155 self._drop_object(name=table_name, exists=exists, **kwargs)
Drops a table.
Arguments:
- table_name: The name of the table to drop.
- exists: If exists, defaults to True.
1157 def drop_managed_table(self, table_name: TableName, exists: bool = True) -> None: 1158 """Drops a managed table. 1159 1160 Args: 1161 table_name: The name of the table to drop. 1162 exists: If exists, defaults to True. 1163 """ 1164 raise NotImplementedError(f"Engine does not support managed tables: {type(self)}")
Drops a managed table.
Arguments:
- table_name: The name of the table to drop.
- exists: If exists, defaults to True.
1192 def get_alter_operations( 1193 self, 1194 current_table_name: TableName, 1195 target_table_name: TableName, 1196 *, 1197 ignore_destructive: bool = False, 1198 ignore_additive: bool = False, 1199 ) -> t.List[TableAlterOperation]: 1200 """ 1201 Determines the alter statements needed to change the current table into the structure of the target table. 1202 """ 1203 return t.cast( 1204 t.List[TableAlterOperation], 1205 self.schema_differ.compare_columns( 1206 current_table_name, 1207 self.columns(current_table_name), 1208 self.columns(target_table_name), 1209 ignore_destructive=ignore_destructive, 1210 ignore_additive=ignore_additive, 1211 ), 1212 )
Determines the alter statements needed to change the current table into the structure of the target table.
1214 def alter_table( 1215 self, 1216 alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]], 1217 ) -> None: 1218 """ 1219 Performs the alter statements to change the current table into the structure of the target table. 1220 """ 1221 with self.transaction(): 1222 for alter_expression in [ 1223 x.expression if isinstance(x, TableAlterOperation) else x for x in alter_expressions 1224 ]: 1225 self.execute(alter_expression)
Performs the alter statements to change the current table into the structure of the target table.
1227 def create_view( 1228 self, 1229 view_name: TableName, 1230 query_or_df: QueryOrDF, 1231 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1232 replace: bool = True, 1233 materialized: bool = False, 1234 materialized_properties: t.Optional[t.Dict[str, t.Any]] = None, 1235 table_description: t.Optional[str] = None, 1236 column_descriptions: t.Optional[t.Dict[str, str]] = None, 1237 view_properties: t.Optional[t.Dict[str, exp.Expr]] = None, 1238 source_columns: t.Optional[t.List[str]] = None, 1239 **create_kwargs: t.Any, 1240 ) -> None: 1241 """Create a view with a query or dataframe. 1242 1243 If a dataframe is passed in, it will be converted into a literal values statement. 1244 This should only be done if the dataframe is very small! 1245 1246 Args: 1247 view_name: The view name. 1248 query_or_df: A query or dataframe. 1249 target_columns_to_types: Columns to use in the view statement. 1250 replace: Whether or not to replace an existing view defaults to True. 1251 materialized: Whether to create a a materialized view. Only used for engines that support this feature. 1252 materialized_properties: Optional materialized view properties to add to the view. 1253 table_description: Optional table description from MODEL DDL. 1254 column_descriptions: Optional column descriptions from model query. 1255 view_properties: Optional view properties to add to the view. 1256 create_kwargs: Additional kwargs to pass into the Create expression 1257 """ 1258 import pandas as pd 1259 1260 if materialized_properties and not materialized: 1261 raise SQLMeshError("Materialized properties are only supported for materialized views") 1262 1263 query_or_df = self._native_df_to_pandas_df(query_or_df) 1264 1265 if isinstance(query_or_df, pd.DataFrame): 1266 values: t.List[t.Tuple[t.Any, ...]] = list( 1267 query_or_df.itertuples(index=False, name=None) 1268 ) 1269 target_columns_to_types, source_columns = self._columns_to_types( 1270 query_or_df, target_columns_to_types, source_columns 1271 ) 1272 if not target_columns_to_types: 1273 raise SQLMeshError("columns_to_types must be provided for dataframes") 1274 source_columns_to_types = get_source_columns_to_types( 1275 target_columns_to_types, source_columns 1276 ) 1277 query_or_df = self._values_to_sql( 1278 values, 1279 source_columns_to_types, 1280 batch_start=0, 1281 batch_end=len(values), 1282 ) 1283 1284 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 1285 query_or_df, 1286 target_columns_to_types, 1287 batch_size=0, 1288 target_table=view_name, 1289 source_columns=source_columns, 1290 ) 1291 if len(source_queries) != 1: 1292 raise SQLMeshError("Only one source query is supported for creating views") 1293 1294 schema: t.Union[exp.Table, exp.Schema] = exp.to_table(view_name) 1295 if target_columns_to_types: 1296 schema = self._build_schema_exp( 1297 exp.to_table(view_name), 1298 target_columns_to_types, 1299 column_descriptions, 1300 is_view=True, 1301 materialized=materialized, 1302 ) 1303 1304 properties = create_kwargs.pop("properties", None) 1305 if not properties: 1306 properties = exp.Properties(expressions=[]) 1307 1308 if view_properties: 1309 table_type = self._pop_creatable_type_from_properties(view_properties) 1310 if table_type: 1311 properties.append("expressions", table_type) 1312 1313 if materialized and self.SUPPORTS_MATERIALIZED_VIEWS: 1314 properties.append("expressions", exp.MaterializedProperty()) 1315 1316 if not self.SUPPORTS_MATERIALIZED_VIEW_SCHEMA and isinstance(schema, exp.Schema): 1317 schema = schema.this 1318 1319 if not self.SUPPORTS_VIEW_SCHEMA and isinstance(schema, exp.Schema): 1320 schema = schema.this 1321 1322 if materialized_properties: 1323 partitioned_by = materialized_properties.pop("partitioned_by", None) 1324 clustered_by = materialized_properties.pop("clustered_by", None) 1325 if ( 1326 partitioned_by 1327 and ( 1328 partitioned_by_prop := self._build_partitioned_by_exp( 1329 partitioned_by, **materialized_properties 1330 ) 1331 ) 1332 is not None 1333 ): 1334 materialized_properties["catalog_name"] = exp.to_table(view_name).catalog 1335 properties.append("expressions", partitioned_by_prop) 1336 if ( 1337 clustered_by 1338 and ( 1339 clustered_by_prop := self._build_clustered_by_exp( 1340 clustered_by, **materialized_properties 1341 ) 1342 ) 1343 is not None 1344 ): 1345 properties.append("expressions", clustered_by_prop) 1346 1347 create_view_properties = self._build_view_properties_exp( 1348 view_properties, 1349 ( 1350 table_description 1351 if self.COMMENT_CREATION_VIEW.supports_schema_def and self.comments_enabled 1352 else None 1353 ), 1354 physical_cluster=create_kwargs.pop("physical_cluster", None), 1355 ) 1356 if create_view_properties: 1357 for view_property in create_view_properties.expressions: 1358 # Small hack to make sure SECURE goes at the beginning before materialized as required by Snowflake 1359 if isinstance(view_property, exp.SecureProperty): 1360 properties.set("expressions", view_property, index=0, overwrite=False) 1361 else: 1362 properties.append("expressions", view_property) 1363 1364 if properties.expressions: 1365 create_kwargs["properties"] = properties 1366 1367 if replace: 1368 self.drop_data_object_on_type_mismatch( 1369 self.get_data_object(view_name), 1370 DataObjectType.VIEW if not materialized else DataObjectType.MATERIALIZED_VIEW, 1371 ) 1372 1373 with source_queries[0] as query: 1374 self.execute( 1375 exp.Create( 1376 this=schema, 1377 kind="VIEW", 1378 replace=replace, 1379 expression=query, 1380 **create_kwargs, 1381 ), 1382 quote_identifiers=self.QUOTE_IDENTIFIERS_IN_VIEWS, 1383 ) 1384 1385 self._clear_data_object_cache(view_name) 1386 1387 # Register table comment with commands if the engine doesn't support doing it in CREATE 1388 if ( 1389 table_description 1390 and self.COMMENT_CREATION_VIEW.is_comment_command_only 1391 and self.comments_enabled 1392 ): 1393 self._create_table_comment(view_name, table_description, "VIEW") 1394 # Register column comments with commands if the engine doesn't support doing it in 1395 # CREATE or we couldn't do it in the CREATE schema definition because we don't have 1396 # columns_to_types 1397 if ( 1398 column_descriptions 1399 and ( 1400 self.COMMENT_CREATION_VIEW.is_comment_command_only 1401 or ( 1402 self.COMMENT_CREATION_VIEW.is_in_schema_def_and_commands 1403 and not target_columns_to_types 1404 ) 1405 ) 1406 and self.comments_enabled 1407 ): 1408 self._create_column_comments(view_name, column_descriptions, "VIEW", materialized)
Create a view with a query or dataframe.
If a dataframe is passed in, it will be converted into a literal values statement. This should only be done if the dataframe is very small!
Arguments:
- view_name: The view name.
- query_or_df: A query or dataframe.
- target_columns_to_types: Columns to use in the view statement.
- replace: Whether or not to replace an existing view defaults to True.
- materialized: Whether to create a a materialized view. Only used for engines that support this feature.
- materialized_properties: Optional materialized view properties to add to the view.
- table_description: Optional table description from MODEL DDL.
- column_descriptions: Optional column descriptions from model query.
- view_properties: Optional view properties to add to the view.
- create_kwargs: Additional kwargs to pass into the Create expression
1410 @set_catalog() 1411 def create_schema( 1412 self, 1413 schema_name: SchemaName, 1414 ignore_if_exists: bool = True, 1415 warn_on_error: bool = True, 1416 properties: t.Optional[t.List[exp.Expr]] = None, 1417 ) -> None: 1418 properties = properties or [] 1419 return self._create_schema( 1420 schema_name=schema_name, 1421 ignore_if_exists=ignore_if_exists, 1422 warn_on_error=warn_on_error, 1423 properties=properties, 1424 kind="SCHEMA", 1425 )
1452 def drop_schema( 1453 self, 1454 schema_name: SchemaName, 1455 ignore_if_not_exists: bool = True, 1456 cascade: bool = False, 1457 **drop_args: t.Dict[str, exp.Expr], 1458 ) -> None: 1459 return self._drop_object( 1460 name=schema_name, 1461 exists=ignore_if_not_exists, 1462 kind="SCHEMA", 1463 cascade=cascade, 1464 **drop_args, 1465 )
1467 def drop_view( 1468 self, 1469 view_name: TableName, 1470 ignore_if_not_exists: bool = True, 1471 materialized: bool = False, 1472 **kwargs: t.Any, 1473 ) -> None: 1474 """Drop a view.""" 1475 self._drop_object( 1476 name=view_name, 1477 exists=ignore_if_not_exists, 1478 kind="VIEW", 1479 materialized=materialized and self.SUPPORTS_MATERIALIZED_VIEWS, 1480 **kwargs, 1481 )
Drop a view.
1499 def columns( 1500 self, table_name: TableName, include_pseudo_columns: bool = False 1501 ) -> t.Dict[str, exp.DataType]: 1502 """Fetches column names and types for the target table.""" 1503 self.execute(exp.Describe(this=exp.to_table(table_name), kind="TABLE")) 1504 describe_output = self.cursor.fetchall() 1505 return { 1506 # Note: MySQL returns the column type as bytes. 1507 column_name: exp.DataType.build(_decoded_str(column_type), dialect=self.dialect) 1508 for column_name, column_type, *_ in itertools.takewhile( 1509 lambda t: not t[0].startswith("#"), 1510 describe_output, 1511 ) 1512 if column_name and column_name.strip() and column_type and column_type.strip() 1513 }
Fetches column names and types for the target table.
1515 def table_exists(self, table_name: TableName) -> bool: 1516 table = exp.to_table(table_name) 1517 data_object_cache_key = _get_data_object_cache_key(table.catalog, table.db, table.name) 1518 if data_object_cache_key in self._data_object_cache: 1519 logger.debug("Table existence cache hit: %s", data_object_cache_key) 1520 return self._data_object_cache[data_object_cache_key] is not None 1521 1522 try: 1523 self.execute(exp.Describe(this=table, kind="TABLE")) 1524 return True 1525 except Exception: 1526 return False
1531 def insert_append( 1532 self, 1533 table_name: TableName, 1534 query_or_df: QueryOrDF, 1535 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1536 track_rows_processed: bool = True, 1537 source_columns: t.Optional[t.List[str]] = None, 1538 ) -> None: 1539 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 1540 query_or_df, 1541 target_columns_to_types, 1542 target_table=table_name, 1543 source_columns=source_columns, 1544 ) 1545 self._insert_append_source_queries( 1546 table_name, source_queries, target_columns_to_types, track_rows_processed 1547 )
1582 def insert_overwrite_by_partition( 1583 self, 1584 table_name: TableName, 1585 query_or_df: QueryOrDF, 1586 partitioned_by: t.List[exp.Expr], 1587 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1588 source_columns: t.Optional[t.List[str]] = None, 1589 ) -> None: 1590 if self.INSERT_OVERWRITE_STRATEGY.is_insert_overwrite: 1591 target_table = exp.to_table(table_name) 1592 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 1593 query_or_df, 1594 target_columns_to_types, 1595 target_table=target_table, 1596 source_columns=source_columns, 1597 ) 1598 self._insert_overwrite_by_condition( 1599 table_name, source_queries, target_columns_to_types=target_columns_to_types 1600 ) 1601 else: 1602 self._replace_by_key( 1603 table_name, 1604 query_or_df, 1605 target_columns_to_types, 1606 partitioned_by, 1607 is_unique_key=False, 1608 source_columns=source_columns, 1609 )
1611 def insert_overwrite_by_time_partition( 1612 self, 1613 table_name: TableName, 1614 query_or_df: QueryOrDF, 1615 start: TimeLike, 1616 end: TimeLike, 1617 time_formatter: t.Callable[[TimeLike, t.Optional[t.Dict[str, exp.DataType]]], exp.Expr], 1618 time_column: TimeColumn | exp.Expr | str, 1619 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1620 source_columns: t.Optional[t.List[str]] = None, 1621 **kwargs: t.Any, 1622 ) -> None: 1623 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 1624 query_or_df, 1625 target_columns_to_types, 1626 target_table=table_name, 1627 source_columns=source_columns, 1628 ) 1629 if not target_columns_to_types or not columns_to_types_all_known(target_columns_to_types): 1630 target_columns_to_types = self.columns(table_name) 1631 low, high = [ 1632 time_formatter(dt, target_columns_to_types) 1633 for dt in make_inclusive(start, end, self.dialect) 1634 ] 1635 if isinstance(time_column, TimeColumn): 1636 time_column = time_column.column 1637 where = exp.Between( 1638 this=exp.to_column(time_column) if isinstance(time_column, str) else time_column, 1639 low=low, 1640 high=high, 1641 ) 1642 return self._insert_overwrite_by_time_partition( 1643 table_name, source_queries, target_columns_to_types, where, **kwargs 1644 )
1769 def scd_type_2_by_time( 1770 self, 1771 target_table: TableName, 1772 source_table: QueryOrDF, 1773 unique_key: t.Sequence[exp.Expr], 1774 valid_from_col: exp.Column, 1775 valid_to_col: exp.Column, 1776 execution_time: t.Union[TimeLike, exp.Column], 1777 updated_at_col: exp.Column, 1778 invalidate_hard_deletes: bool = True, 1779 updated_at_as_valid_from: bool = False, 1780 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1781 table_description: t.Optional[str] = None, 1782 column_descriptions: t.Optional[t.Dict[str, str]] = None, 1783 truncate: bool = False, 1784 source_columns: t.Optional[t.List[str]] = None, 1785 **kwargs: t.Any, 1786 ) -> None: 1787 self._scd_type_2( 1788 target_table=target_table, 1789 source_table=source_table, 1790 unique_key=unique_key, 1791 valid_from_col=valid_from_col, 1792 valid_to_col=valid_to_col, 1793 execution_time=execution_time, 1794 updated_at_col=updated_at_col, 1795 invalidate_hard_deletes=invalidate_hard_deletes, 1796 updated_at_as_valid_from=updated_at_as_valid_from, 1797 target_columns_to_types=target_columns_to_types, 1798 table_description=table_description, 1799 column_descriptions=column_descriptions, 1800 truncate=truncate, 1801 source_columns=source_columns, 1802 **kwargs, 1803 )
1805 def scd_type_2_by_column( 1806 self, 1807 target_table: TableName, 1808 source_table: QueryOrDF, 1809 unique_key: t.Sequence[exp.Expr], 1810 valid_from_col: exp.Column, 1811 valid_to_col: exp.Column, 1812 execution_time: t.Union[TimeLike, exp.Column], 1813 check_columns: t.Union[exp.Star, t.Sequence[exp.Expr]], 1814 invalidate_hard_deletes: bool = True, 1815 execution_time_as_valid_from: bool = False, 1816 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 1817 table_description: t.Optional[str] = None, 1818 column_descriptions: t.Optional[t.Dict[str, str]] = None, 1819 truncate: bool = False, 1820 source_columns: t.Optional[t.List[str]] = None, 1821 **kwargs: t.Any, 1822 ) -> None: 1823 self._scd_type_2( 1824 target_table=target_table, 1825 source_table=source_table, 1826 unique_key=unique_key, 1827 valid_from_col=valid_from_col, 1828 valid_to_col=valid_to_col, 1829 execution_time=execution_time, 1830 check_columns=check_columns, 1831 target_columns_to_types=target_columns_to_types, 1832 invalidate_hard_deletes=invalidate_hard_deletes, 1833 execution_time_as_valid_from=execution_time_as_valid_from, 1834 table_description=table_description, 1835 column_descriptions=column_descriptions, 1836 truncate=truncate, 1837 source_columns=source_columns, 1838 **kwargs, 1839 )
2234 def merge( 2235 self, 2236 target_table: TableName, 2237 source_table: QueryOrDF, 2238 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]], 2239 unique_key: t.Sequence[exp.Expr], 2240 when_matched: t.Optional[exp.Whens] = None, 2241 merge_filter: t.Optional[exp.Expr] = None, 2242 source_columns: t.Optional[t.List[str]] = None, 2243 **kwargs: t.Any, 2244 ) -> None: 2245 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 2246 source_table, 2247 target_columns_to_types, 2248 target_table=target_table, 2249 source_columns=source_columns, 2250 ) 2251 target_columns_to_types = target_columns_to_types or self.columns(target_table) 2252 on = exp.and_( 2253 *( 2254 add_table(part, MERGE_TARGET_ALIAS).eq(add_table(part, MERGE_SOURCE_ALIAS)) 2255 for part in unique_key 2256 ) 2257 ) 2258 if merge_filter: 2259 on = exp.and_(merge_filter, on) 2260 2261 if not when_matched: 2262 match_expressions = [ 2263 exp.When( 2264 matched=True, 2265 source=False, 2266 then=exp.Update( 2267 expressions=[ 2268 exp.column(col, MERGE_TARGET_ALIAS).eq( 2269 exp.column(col, MERGE_SOURCE_ALIAS) 2270 ) 2271 for col in target_columns_to_types 2272 ], 2273 ), 2274 ) 2275 ] 2276 else: 2277 match_expressions = when_matched.copy().expressions 2278 2279 match_expressions.append( 2280 exp.When( 2281 matched=False, 2282 source=False, 2283 then=exp.Insert( 2284 this=exp.Tuple( 2285 expressions=[exp.column(col) for col in target_columns_to_types] 2286 ), 2287 expression=exp.Tuple( 2288 expressions=[ 2289 exp.column(col, MERGE_SOURCE_ALIAS) for col in target_columns_to_types 2290 ] 2291 ), 2292 ), 2293 ) 2294 ) 2295 for source_query in source_queries: 2296 with source_query as query: 2297 self._merge( 2298 target_table=target_table, 2299 query=query, 2300 on=on, 2301 whens=exp.Whens(expressions=match_expressions), 2302 )
2304 def rename_table( 2305 self, 2306 old_table_name: TableName, 2307 new_table_name: TableName, 2308 ) -> None: 2309 new_table = exp.to_table(new_table_name) 2310 if new_table.catalog: 2311 old_table = exp.to_table(old_table_name) 2312 catalog = old_table.catalog or self.get_current_catalog() 2313 if catalog != new_table.catalog: 2314 raise UnsupportedCatalogOperationError( 2315 "Tried to rename table across catalogs which is not supported" 2316 ) 2317 self._rename_table(old_table_name, new_table_name) 2318 self._clear_data_object_cache(old_table_name) 2319 self._clear_data_object_cache(new_table_name)
2321 def get_data_object( 2322 self, target_name: TableName, safe_to_cache: bool = False 2323 ) -> t.Optional[DataObject]: 2324 target_table = exp.to_table(target_name) 2325 existing_data_objects = self.get_data_objects( 2326 schema_(target_table.db, target_table.catalog), 2327 {target_table.name}, 2328 safe_to_cache=safe_to_cache, 2329 ) 2330 if existing_data_objects: 2331 return existing_data_objects[0] 2332 return None
2334 def get_data_objects( 2335 self, 2336 schema_name: SchemaName, 2337 object_names: t.Optional[t.Set[str]] = None, 2338 safe_to_cache: bool = False, 2339 ) -> t.List[DataObject]: 2340 """Lists all data objects in the target schema. 2341 2342 Args: 2343 schema_name: The name of the schema to list data objects from. 2344 object_names: If provided, only return data objects with these names. 2345 safe_to_cache: Whether it is safe to cache the results of this call. 2346 2347 Returns: 2348 A list of data objects in the target schema. 2349 """ 2350 if object_names is not None: 2351 if not object_names: 2352 return [] 2353 2354 # Check cache for each object name 2355 target_schema = to_schema(schema_name) 2356 cached_objects = [] 2357 missing_names = set() 2358 2359 for name in object_names: 2360 cache_key = _get_data_object_cache_key( 2361 target_schema.catalog, target_schema.db, name 2362 ) 2363 if cache_key in self._data_object_cache: 2364 logger.debug("Data object cache hit: %s", cache_key) 2365 data_object = self._data_object_cache[cache_key] 2366 # If the object is none, then the table was previously looked for but not found 2367 if data_object: 2368 cached_objects.append(data_object) 2369 else: 2370 logger.debug("Data object cache miss: %s", cache_key) 2371 missing_names.add(name) 2372 2373 # Fetch missing objects from database 2374 if missing_names: 2375 object_names_list = list(missing_names) 2376 batches = [ 2377 object_names_list[i : i + self.DATA_OBJECT_FILTER_BATCH_SIZE] 2378 for i in range(0, len(object_names_list), self.DATA_OBJECT_FILTER_BATCH_SIZE) 2379 ] 2380 2381 fetched_objects = [] 2382 fetched_object_names = set() 2383 for batch in batches: 2384 objects = self._get_data_objects(schema_name, set(batch)) 2385 for obj in objects: 2386 if safe_to_cache: 2387 cache_key = _get_data_object_cache_key( 2388 obj.catalog, obj.schema_name, obj.name 2389 ) 2390 self._data_object_cache[cache_key] = obj 2391 fetched_objects.append(obj) 2392 fetched_object_names.add(obj.name) 2393 2394 if safe_to_cache: 2395 for missing_name in missing_names - fetched_object_names: 2396 cache_key = _get_data_object_cache_key( 2397 target_schema.catalog, target_schema.db, missing_name 2398 ) 2399 self._data_object_cache[cache_key] = None 2400 2401 return cached_objects + fetched_objects 2402 2403 return cached_objects 2404 2405 fetched_objects = self._get_data_objects(schema_name) 2406 if safe_to_cache: 2407 for obj in fetched_objects: 2408 cache_key = _get_data_object_cache_key(obj.catalog, obj.schema_name, obj.name) 2409 self._data_object_cache[cache_key] = obj 2410 return fetched_objects
Lists all data objects in the target schema.
Arguments:
- schema_name: The name of the schema to list data objects from.
- object_names: If provided, only return data objects with these names.
- safe_to_cache: Whether it is safe to cache the results of this call.
Returns:
A list of data objects in the target schema.
2412 def fetchone( 2413 self, 2414 query: t.Union[exp.Expr, str], 2415 ignore_unsupported_errors: bool = False, 2416 quote_identifiers: bool = False, 2417 ) -> t.Optional[t.Tuple]: 2418 with self.transaction(): 2419 self.execute( 2420 query, 2421 ignore_unsupported_errors=ignore_unsupported_errors, 2422 quote_identifiers=quote_identifiers, 2423 ) 2424 return self.cursor.fetchone()
2426 def fetchall( 2427 self, 2428 query: t.Union[exp.Expr, str], 2429 ignore_unsupported_errors: bool = False, 2430 quote_identifiers: bool = False, 2431 ) -> t.List[t.Tuple]: 2432 with self.transaction(): 2433 self.execute( 2434 query, 2435 ignore_unsupported_errors=ignore_unsupported_errors, 2436 quote_identifiers=quote_identifiers, 2437 ) 2438 return self.cursor.fetchall()
2463 def fetchdf( 2464 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 2465 ) -> pd.DataFrame: 2466 """Fetches a Pandas DataFrame from the cursor""" 2467 import pandas as pd 2468 2469 df = self._fetch_native_df(query, quote_identifiers=quote_identifiers) 2470 if not isinstance(df, pd.DataFrame): 2471 raise NotImplementedError( 2472 "The cursor's `fetch_native_df` method is not returning a pandas DataFrame. Need to update `fetchdf` so a Pandas DataFrame is returned" 2473 ) 2474 return df
Fetches a Pandas DataFrame from the cursor
2476 def fetch_pyspark_df( 2477 self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False 2478 ) -> PySparkDataFrame: 2479 """Fetches a PySpark DataFrame from the cursor""" 2480 raise NotImplementedError(f"Engine does not support PySpark DataFrames: {type(self)}")
Fetches a PySpark DataFrame from the cursor
2482 @property 2483 def wap_enabled(self) -> bool: 2484 """Returns whether WAP is enabled for this engine.""" 2485 return self._extra_config.get("wap_enabled", False)
Returns whether WAP is enabled for this engine.
2487 def wap_supported(self, table_name: TableName) -> bool: 2488 """Returns whether WAP for the target table is supported.""" 2489 return False
Returns whether WAP for the target table is supported.
2491 def wap_table_name(self, table_name: TableName, wap_id: str) -> str: 2492 """Returns the updated table name for the given WAP ID. 2493 2494 Args: 2495 table_name: The name of the target table. 2496 wap_id: The WAP ID to prepare. 2497 2498 Returns: 2499 The updated table name that should be used for writing. 2500 """ 2501 raise NotImplementedError(f"Engine does not support WAP: {type(self)}")
Returns the updated table name for the given WAP ID.
Arguments:
- table_name: The name of the target table.
- wap_id: The WAP ID to prepare.
Returns:
The updated table name that should be used for writing.
2503 def wap_prepare(self, table_name: TableName, wap_id: str) -> str: 2504 """Prepares the target table for WAP and returns the updated table name. 2505 2506 Args: 2507 table_name: The name of the target table. 2508 wap_id: The WAP ID to prepare. 2509 2510 Returns: 2511 The updated table name that should be used for writing. 2512 """ 2513 raise NotImplementedError(f"Engine does not support WAP: {type(self)}")
Prepares the target table for WAP and returns the updated table name.
Arguments:
- table_name: The name of the target table.
- wap_id: The WAP ID to prepare.
Returns:
The updated table name that should be used for writing.
2515 def wap_publish(self, table_name: TableName, wap_id: str) -> None: 2516 """Publishes changes with the given WAP ID to the target table. 2517 2518 Args: 2519 table_name: The name of the target table. 2520 wap_id: The WAP ID to publish. 2521 """ 2522 raise NotImplementedError(f"Engine does not support WAP: {type(self)}")
Publishes changes with the given WAP ID to the target table.
Arguments:
- table_name: The name of the target table.
- wap_id: The WAP ID to publish.
2524 def sync_grants_config( 2525 self, 2526 table: exp.Table, 2527 grants_config: GrantsConfig, 2528 table_type: DataObjectType = DataObjectType.TABLE, 2529 ) -> None: 2530 """Applies the grants_config to a table authoritatively. 2531 It first compares the specified grants against the current grants, and then 2532 applies the diffs to the table by revoking and granting privileges as needed. 2533 2534 Args: 2535 table: The table/view to apply grants to. 2536 grants_config: Dictionary mapping privileges to lists of grantees. 2537 table_type: The type of database object (TABLE, VIEW, MATERIALIZED_VIEW). 2538 """ 2539 if not self.SUPPORTS_GRANTS: 2540 raise NotImplementedError(f"Engine does not support grants: {type(self)}") 2541 2542 current_grants = self._get_current_grants_config(table) 2543 new_grants, revoked_grants = self._diff_grants_configs(grants_config, current_grants) 2544 revoke_exprs = self._revoke_grants_config_expr(table, revoked_grants, table_type) 2545 grant_exprs = self._apply_grants_config_expr(table, new_grants, table_type) 2546 dcl_exprs = revoke_exprs + grant_exprs 2547 2548 if dcl_exprs: 2549 self.execute(dcl_exprs)
Applies the grants_config to a table authoritatively. It first compares the specified grants against the current grants, and then applies the diffs to the table by revoking and granting privileges as needed.
Arguments:
- table: The table/view to apply grants to.
- grants_config: Dictionary mapping privileges to lists of grantees.
- table_type: The type of database object (TABLE, VIEW, MATERIALIZED_VIEW).
2551 @contextlib.contextmanager 2552 def transaction( 2553 self, 2554 condition: t.Optional[bool] = None, 2555 ) -> t.Iterator[None]: 2556 """A transaction context manager.""" 2557 if ( 2558 self._connection_pool.is_transaction_active 2559 or not self.SUPPORTS_TRANSACTIONS 2560 or (condition is not None and not condition) 2561 ): 2562 yield 2563 return 2564 2565 if self._pre_ping: 2566 try: 2567 logger.debug("Pinging the database to check the connection") 2568 self.ping() 2569 except Exception: 2570 logger.info("Connection to the database was lost. Reconnecting...") 2571 self._connection_pool.close() 2572 2573 self._connection_pool.begin() 2574 try: 2575 yield 2576 except Exception as e: 2577 self._connection_pool.rollback() 2578 raise e 2579 else: 2580 self._connection_pool.commit()
A transaction context manager.
2582 @contextlib.contextmanager 2583 def session(self, properties: SessionProperties) -> t.Iterator[None]: 2584 """A session context manager.""" 2585 if self._is_session_active(): 2586 yield 2587 return 2588 2589 self._begin_session(properties) 2590 try: 2591 yield 2592 finally: 2593 self._end_session()
A session context manager.
2605 def execute( 2606 self, 2607 expressions: t.Union[str, exp.Expr, t.Sequence[exp.Expr]], 2608 ignore_unsupported_errors: bool = False, 2609 quote_identifiers: bool = True, 2610 track_rows_processed: bool = False, 2611 **kwargs: t.Any, 2612 ) -> None: 2613 """Execute a sql query.""" 2614 to_sql_kwargs = ( 2615 {"unsupported_level": ErrorLevel.IGNORE} if ignore_unsupported_errors else {} 2616 ) 2617 with self.transaction(): 2618 for e in ensure_list(expressions): 2619 if isinstance(e, exp.Expr): 2620 self._check_identifier_length(e) 2621 sql = self._to_sql(e, quote=quote_identifiers, **to_sql_kwargs) 2622 else: 2623 sql = t.cast(str, e) 2624 2625 sql = self._attach_correlation_id(sql) 2626 2627 self._log_sql( 2628 sql, 2629 expression=e if isinstance(e, exp.Expr) else None, 2630 quote_identifiers=quote_identifiers, 2631 ) 2632 self._execute(sql, track_rows_processed, **kwargs)
Execute a sql query.
2680 @contextlib.contextmanager 2681 def temp_table( 2682 self, 2683 query_or_df: QueryOrDF, 2684 name: TableName = "diff", 2685 target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None, 2686 source_columns: t.Optional[t.List[str]] = None, 2687 **kwargs: t.Any, 2688 ) -> t.Iterator[exp.Table]: 2689 """A context manager for working a temp table. 2690 2691 The table will be created with a random guid and cleaned up after the block. 2692 2693 Args: 2694 query_or_df: The query or df to create a temp table for. 2695 name: The base name of the temp table. 2696 target_columns_to_types: A mapping between the column name and its data type. 2697 2698 Yields: 2699 The table expression 2700 """ 2701 name = exp.to_table(name) 2702 # ensure that we use default catalog if none is not specified 2703 if isinstance(name, exp.Table) and not name.catalog and name.db and self.default_catalog: 2704 name.set("catalog", exp.parse_identifier(self.default_catalog)) 2705 2706 source_queries, target_columns_to_types = self._get_source_queries_and_columns_to_types( 2707 query_or_df, 2708 target_columns_to_types=target_columns_to_types, 2709 target_table=name, 2710 source_columns=source_columns, 2711 ) 2712 2713 with self.transaction(): 2714 table = self._get_temp_table(name) 2715 if table.db: 2716 self.create_schema(schema_(table.args["db"], table.args.get("catalog"))) 2717 self._create_table_from_source_queries( 2718 table, 2719 source_queries, 2720 target_columns_to_types, 2721 exists=True, 2722 table_description=None, 2723 column_descriptions=None, 2724 track_rows_processed=False, 2725 **kwargs, 2726 ) 2727 2728 try: 2729 yield table 2730 finally: 2731 self.drop_table(table)
A context manager for working a temp table.
The table will be created with a random guid and cleaned up after the block.
Arguments:
- query_or_df: The query or df to create a temp table for.
- name: The base name of the temp table.
- target_columns_to_types: A mapping between the column name and its data type.
Yields:
The table expression
2762 def adjust_physical_properties_for_incremental( 2763 self, 2764 physical_properties: t.Dict[str, t.Any], 2765 *, 2766 requires_delete_capable_table: bool, 2767 unique_key: t.Optional[t.List[exp.Expr]], 2768 model_name: str, 2769 ) -> t.Dict[str, t.Any]: 2770 """Adjusts physical properties for an incremental model before the table is created. 2771 2772 Some engines require a specific physical table layout before they can run the DELETE/MERGE 2773 statements that incremental model kinds rely on (e.g. StarRocks only supports those on 2774 PRIMARY KEY tables). This hook lets each engine derive or validate the required properties 2775 while keeping the generic evaluator free of engine-specific branching. 2776 2777 Args: 2778 physical_properties: The model's physical properties. 2779 requires_delete_capable_table: Whether the model kind issues DELETE/MERGE statements 2780 (as opposed to append-only INSERTs), as determined by the generic evaluator. 2781 unique_key: The model's unique key, populated only when the kind allows promoting it to 2782 an engine-specific key (i.e. INCREMENTAL_BY_UNIQUE_KEY); otherwise None. 2783 model_name: The model name, for use in diagnostics. 2784 2785 Returns: 2786 The (possibly adjusted) physical properties. Implementations own the given mapping and 2787 may mutate it in place; the base implementation returns it unchanged. 2788 """ 2789 return physical_properties
Adjusts physical properties for an incremental model before the table is created.
Some engines require a specific physical table layout before they can run the DELETE/MERGE statements that incremental model kinds rely on (e.g. StarRocks only supports those on PRIMARY KEY tables). This hook lets each engine derive or validate the required properties while keeping the generic evaluator free of engine-specific branching.
Arguments:
- physical_properties: The model's physical properties.
- requires_delete_capable_table: Whether the model kind issues DELETE/MERGE statements (as opposed to append-only INSERTs), as determined by the generic evaluator.
- unique_key: The model's unique key, populated only when the kind allows promoting it to an engine-specific key (i.e. INCREMENTAL_BY_UNIQUE_KEY); otherwise None.
- model_name: The model name, for use in diagnostics.
Returns:
The (possibly adjusted) physical properties. Implementations own the given mapping and may mutate it in place; the base implementation returns it unchanged.
2946 def drop_data_object_on_type_mismatch( 2947 self, data_object: t.Optional[DataObject], expected_type: DataObjectType 2948 ) -> bool: 2949 """Drops a data object if it exists and is not of the expected type. 2950 2951 Args: 2952 data_object: The data object to check. 2953 expected_type: The expected type of the data object. 2954 2955 Returns: 2956 True if the data object was dropped, False otherwise. 2957 """ 2958 if data_object is None or data_object.type == expected_type: 2959 return False 2960 2961 logger.warning( 2962 "Target data object '%s' is a %s and not a %s, dropping it", 2963 data_object.to_table().sql(dialect=self.dialect), 2964 data_object.type.value, 2965 expected_type.value, 2966 ) 2967 self.drop_data_object(data_object) 2968 return True
Drops a data object if it exists and is not of the expected type.
Arguments:
- data_object: The data object to check.
- expected_type: The expected type of the data object.
Returns:
True if the data object was dropped, False otherwise.
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.
Inherited Members
- EngineAdapter
- EngineAdapter
- DIALECT
- DEFAULT_BATCH_SIZE
- DATA_OBJECT_FILTER_BATCH_SIZE
- SUPPORTS_TRANSACTIONS
- COMMENT_CREATION_TABLE
- COMMENT_CREATION_VIEW
- MAX_TABLE_COMMENT_LENGTH
- MAX_COLUMN_COMMENT_LENGTH
- INSERT_OVERWRITE_STRATEGY
- SUPPORTS_MATERIALIZED_VIEWS
- SUPPORTS_MATERIALIZED_VIEW_SCHEMA
- SUPPORTS_VIEW_SCHEMA
- SUPPORTS_CLONING
- SUPPORTS_MANAGED_MODELS
- SUPPORTS_CREATE_DROP_CATALOG
- SUPPORTED_DROP_CASCADE_OBJECT_KINDS
- SCHEMA_DIFFER_KWARGS
- SUPPORTS_TUPLE_IN
- HAS_VIEW_BINDING
- RECREATE_MATERIALIZED_VIEW_ON_EVALUATION
- SUPPORTS_REPLACE_TABLE
- 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
- catalog_support
- supports_virtual_catalog
- inject_virtual_catalog
- schema_differ
- default_catalog
- engine_run_mode
- 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
- alter_table
- create_view
- create_schema
- drop_schema
- drop_view
- create_catalog
- drop_catalog
- columns
- table_exists
- delete_from
- insert_append
- insert_overwrite_by_partition
- insert_overwrite_by_time_partition
- update_table
- scd_type_2_by_time
- scd_type_2_by_column
- merge
- rename_table
- get_data_object
- get_data_objects
- fetchone
- 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
- ensure_nulls_for_unmatched_after_join
- use_server_nulls_for_unmatched_after_join
- ping
- get_table_last_modified_ts