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