Edit on GitHub

sqlmesh.core.engine_adapter.databricks

  1from __future__ import annotations
  2
  3import logging
  4import typing as t
  5from functools import partial
  6
  7from sqlglot import exp
  8
  9from sqlmesh.core.constants import LIQUID_CLUSTERING_KEYWORDS
 10from sqlmesh.core.dialect import to_schema
 11from sqlmesh.core.engine_adapter.mixins import GrantsFromInfoSchemaMixin
 12from sqlmesh.core.engine_adapter.shared import (
 13    CatalogSupport,
 14    DataObject,
 15    DataObjectType,
 16    InsertOverwriteStrategy,
 17    SourceQuery,
 18)
 19from sqlmesh.core.engine_adapter.spark import SparkEngineAdapter
 20from sqlmesh.core.node import IntervalUnit
 21from sqlmesh.core.schema_diff import NestedSupport
 22from sqlmesh.engines.spark.db_api.spark_session import connection, SparkSessionConnection
 23from sqlmesh.utils.errors import SQLMeshError, MissingDefaultCatalogError
 24
 25if t.TYPE_CHECKING:
 26    import pandas as pd
 27
 28    from sqlmesh.core._typing import SchemaName, TableName, SessionProperties
 29    from sqlmesh.core.engine_adapter._typing import DF, PySparkSession, Query
 30
 31logger = logging.getLogger(__name__)
 32
 33
 34def _query_tags(
 35    query_tags: t.Optional[t.Union[exp.Expr, str, int, float, bool]],
 36) -> t.Optional[t.Dict[str, t.Optional[str]]]:
 37    if not query_tags:
 38        return None
 39
 40    if not isinstance(query_tags, (exp.Map, exp.VarMap)):
 41        raise SQLMeshError("Invalid value for `session_properties.query_tags`. Must be a map.")
 42
 43    keys = query_tags.args.get("keys")
 44    values = query_tags.args.get("values")
 45    if not isinstance(keys, exp.Array) or not isinstance(values, exp.Array):
 46        raise SQLMeshError(
 47            "Invalid value for `session_properties.query_tags`. Must be a map with array "
 48            "keys and array values."
 49        )
 50
 51    tags: t.Dict[str, t.Optional[str]] = {}
 52    for key, value in zip(keys.expressions, values.expressions):
 53        if not isinstance(key, exp.Literal) or not key.is_string:
 54            raise SQLMeshError(
 55                "Invalid key in `session_properties.query_tags`. Keys must be string literals."
 56            )
 57
 58        if isinstance(value, exp.Null):
 59            tags[key.this] = None
 60        elif isinstance(value, exp.Literal) and value.is_string:
 61            tags[key.this] = value.this
 62        else:
 63            raise SQLMeshError(
 64                "Invalid value in `session_properties.query_tags`. Values must be string "
 65                "literals or NULL."
 66            )
 67
 68    return tags
 69
 70
 71class DatabricksEngineAdapter(SparkEngineAdapter, GrantsFromInfoSchemaMixin):
 72    DIALECT = "databricks"
 73    INSERT_OVERWRITE_STRATEGY = InsertOverwriteStrategy.REPLACE_WHERE
 74    SUPPORTS_CLONING = True
 75    SUPPORTS_MATERIALIZED_VIEWS = True
 76    SUPPORTS_MATERIALIZED_VIEW_SCHEMA = True
 77    SUPPORTS_GRANTS = True
 78    USE_CATALOG_IN_GRANTS = True
 79    # Spark has this set to false for compatibility when mixing with Trino but that isn't a concern with Databricks
 80    QUOTE_IDENTIFIERS_IN_VIEWS = True
 81    SCHEMA_DIFFER_KWARGS = {
 82        "support_positional_add": True,
 83        "nested_support": NestedSupport.ALL,
 84        "array_element_selector": "element",
 85        "parameterized_type_defaults": {
 86            exp.DataType.build("DECIMAL", dialect=DIALECT).this: [(10, 0), (0,)],
 87        },
 88    }
 89
 90    def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
 91        super().__init__(*args, **kwargs)
 92        self._set_spark_engine_adapter_if_needed()
 93
 94    @classmethod
 95    def can_access_spark_session(cls, disable_spark_session: bool) -> bool:
 96        from sqlmesh import RuntimeEnv
 97
 98        if disable_spark_session:
 99            return False
100
101        return RuntimeEnv.get().is_databricks
102
103    @classmethod
104    def can_access_databricks_connect(cls, disable_databricks_connect: bool) -> bool:
105        if disable_databricks_connect:
106            return False
107
108        try:
109            from databricks.connect import DatabricksSession  # noqa
110
111            return True
112        except ImportError:
113            return False
114
115    @property
116    def _use_spark_session(self) -> bool:
117        if self.can_access_spark_session(bool(self._extra_config.get("disable_spark_session"))):
118            return True
119
120        if self.can_access_databricks_connect(
121            bool(self._extra_config.get("disable_databricks_connect"))
122        ):
123            if self._extra_config.get("databricks_connect_use_serverless"):
124                return True
125
126            if {
127                "databricks_connect_cluster_id",
128                "databricks_connect_server_hostname",
129                "databricks_connect_access_token",
130            }.issubset(self._extra_config):
131                return True
132
133        return False
134
135    @property
136    def is_spark_session_connection(self) -> bool:
137        return isinstance(self.connection, SparkSessionConnection)
138
139    @property
140    def _is_databricks_sql_connector_connection(self) -> bool:
141        return not self.is_spark_session_connection and not self._connection_pool.get_attribute(
142            "use_spark_engine_adapter"
143        )
144
145    def _set_spark_engine_adapter_if_needed(self) -> None:
146        self._spark_engine_adapter = None
147
148        if not self._use_spark_session or self.is_spark_session_connection:
149            return
150
151        from databricks.connect import DatabricksSession
152
153        connect_kwargs = dict(
154            host=self._extra_config["databricks_connect_server_hostname"],
155            token=self._extra_config.get("databricks_connect_access_token"),
156        )
157        if "databricks_connect_use_serverless" in self._extra_config:
158            connect_kwargs["serverless"] = True
159        else:
160            connect_kwargs["cluster_id"] = self._extra_config["databricks_connect_cluster_id"]
161
162        catalog = self._extra_config.get("catalog")
163        spark = (
164            DatabricksSession.builder.remote(**connect_kwargs).userAgent("sqlmesh").getOrCreate()
165        )
166        self._spark_engine_adapter = SparkEngineAdapter(
167            partial(connection, spark=spark, catalog=catalog),
168            default_catalog=catalog,
169            execute_log_level=self._execute_log_level,
170            multithreaded=self._multithreaded,
171            sql_gen_kwargs=self._sql_gen_kwargs,
172            register_comments=self._register_comments,
173            pre_ping=self._pre_ping,
174            pretty_sql=self._pretty_sql,
175        )
176
177    @property
178    def cursor(self) -> t.Any:
179        if (
180            self._connection_pool.get_attribute("use_spark_engine_adapter")
181            and not self.is_spark_session_connection
182        ):
183            return self._spark_engine_adapter.cursor  # type: ignore
184        return super().cursor
185
186    @property
187    def spark(self) -> PySparkSession:
188        if not self._use_spark_session:
189            raise SQLMeshError(
190                "SparkSession is not available. "
191                "Either run from a Databricks Notebook or "
192                "install `databricks-connect` and configure it to connect to your Databricks cluster."
193            )
194        if self.is_spark_session_connection:
195            return self.connection.spark
196        return self._spark_engine_adapter.spark  # type: ignore
197
198    @property
199    def catalog_support(self) -> CatalogSupport:
200        return CatalogSupport.FULL_SUPPORT
201
202    @staticmethod
203    def _grant_object_kind(table_type: DataObjectType) -> str:
204        if table_type == DataObjectType.VIEW:
205            return "VIEW"
206        if table_type == DataObjectType.MATERIALIZED_VIEW:
207            return "MATERIALIZED VIEW"
208        return "TABLE"
209
210    def _get_grant_expression(self, table: exp.Table) -> exp.Expr:
211        # We only care about explicitly granted privileges and not inherited ones
212        # if this is removed you would see grants inherited from the catalog get returned
213        expression = super()._get_grant_expression(table)
214        expression.args["where"].set(
215            "this",
216            exp.and_(
217                expression.args["where"].this,
218                exp.column("inherited_from").eq(exp.Literal.string("NONE")),
219                wrap=False,
220            ),
221        )
222        return expression
223
224    def _begin_session(self, properties: SessionProperties) -> t.Any:
225        """Begin a new session."""
226        # Align the different possible connectors to a single catalog
227        self.set_current_catalog(self.default_catalog)  # type: ignore
228        self._connection_pool.set_attribute("query_tags", _query_tags(properties.get("query_tags")))
229
230    def _end_session(self) -> None:
231        self._connection_pool.set_attribute("query_tags", None)
232        self._connection_pool.set_attribute("use_spark_engine_adapter", False)
233
234    def _execute(self, sql: str, track_rows_processed: bool = False, **kwargs: t.Any) -> None:
235        query_tags = self._connection_pool.get_attribute("query_tags")
236        if (
237            query_tags
238            and "query_tags" not in kwargs
239            and self._is_databricks_sql_connector_connection
240        ):
241            kwargs["query_tags"] = query_tags
242
243        return super()._execute(sql, track_rows_processed, **kwargs)
244
245    def _df_to_source_queries(
246        self,
247        df: DF,
248        target_columns_to_types: t.Dict[str, exp.DataType],
249        batch_size: int,
250        target_table: TableName,
251        source_columns: t.Optional[t.List[str]] = None,
252    ) -> t.List[SourceQuery]:
253        if not self._use_spark_session:
254            return super(SparkEngineAdapter, self)._df_to_source_queries(
255                df, target_columns_to_types, batch_size, target_table, source_columns=source_columns
256            )
257        pyspark_df = self._ensure_pyspark_df(
258            df, target_columns_to_types, source_columns=source_columns
259        )
260
261        def query_factory() -> Query:
262            temp_table = self._get_temp_table(target_table or "spark", table_only=True)
263            pyspark_df.createOrReplaceTempView(temp_table.sql(dialect=self.dialect))
264            self._connection_pool.set_attribute("use_spark_engine_adapter", True)
265            return exp.select(*self._select_columns(target_columns_to_types)).from_(temp_table)
266
267        return [SourceQuery(query_factory=query_factory)]
268
269    def _fetch_native_df(
270        self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False
271    ) -> DF:
272        """Fetches a DataFrame that can be either Pandas or PySpark from the cursor"""
273        if self.is_spark_session_connection:
274            return super()._fetch_native_df(query, quote_identifiers=quote_identifiers)
275        if self._spark_engine_adapter:
276            return self._spark_engine_adapter._fetch_native_df(  # type: ignore
277                query, quote_identifiers=quote_identifiers
278            )
279        self.execute(query)
280        return self.cursor.fetchall_arrow().to_pandas()
281
282    def fetchdf(
283        self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False
284    ) -> pd.DataFrame:
285        """
286        Returns a Pandas DataFrame from a query or expression.
287        """
288        import pandas as pd
289
290        df = self._fetch_native_df(query, quote_identifiers=quote_identifiers)
291        if not isinstance(df, pd.DataFrame):
292            return df.toPandas()
293        return df
294
295    def get_current_catalog(self) -> t.Optional[str]:
296        pyspark_catalog = None
297        sql_connector_catalog = None
298        if self._spark_engine_adapter:
299            from py4j.protocol import Py4JError
300            from pyspark.errors.exceptions.connect import SparkConnectGrpcException
301
302            try:
303                # Note: Spark 3.4+ Only API
304                pyspark_catalog = self._spark_engine_adapter.get_current_catalog()
305            except (Py4JError, SparkConnectGrpcException):
306                pass
307        elif self.is_spark_session_connection:
308            pyspark_catalog = self.connection.spark.catalog.currentCatalog()
309        if not self.is_spark_session_connection:
310            result = self.fetchone(exp.select(self.CURRENT_CATALOG_EXPRESSION))
311            sql_connector_catalog = result[0] if result else None
312        if self._spark_engine_adapter and pyspark_catalog != sql_connector_catalog:
313            logger.warning(
314                f"Current catalog mismatch between Databricks SQL Connector and Databricks-Connect: `{sql_connector_catalog}` != `{pyspark_catalog}`. Set `catalog` connection property to make them the same."
315            )
316        return pyspark_catalog or sql_connector_catalog
317
318    def set_current_catalog(self, catalog_name: str) -> None:
319        def _set_spark_session_current_catalog(spark: PySparkSession) -> None:
320            from py4j.protocol import Py4JError
321            from pyspark.errors.exceptions.connect import SparkConnectGrpcException
322
323            try:
324                # Note: Spark 3.4+ Only API
325                spark.catalog.setCurrentCatalog(catalog_name)
326            except (Py4JError, SparkConnectGrpcException):
327                pass
328
329        # Since Databricks splits commands across the Dataframe API and the SQL Connector
330        # (depending if databricks-connect is installed and a Dataframe is used) we need to ensure both
331        # are set to the same catalog since they maintain their default catalog separately
332        self.execute(exp.Use(this=exp.to_identifier(catalog_name), kind="CATALOG"))
333        if self.is_spark_session_connection:
334            _set_spark_session_current_catalog(self.connection.spark)
335
336        if self._spark_engine_adapter:
337            _set_spark_session_current_catalog(self._spark_engine_adapter.spark)
338
339    def _get_data_objects(
340        self, schema_name: SchemaName, object_names: t.Optional[t.Set[str]] = None
341    ) -> t.List[DataObject]:
342        """
343        Returns all the data objects that exist in the given schema and catalog.
344        """
345        schema = to_schema(schema_name)
346        catalog_name = schema.catalog or self.get_current_catalog()
347        query = (
348            exp.select(
349                exp.column("table_name").as_("name"),
350                exp.column("table_schema").as_("schema"),
351                exp.column("table_catalog").as_("catalog"),
352                exp.case(exp.column("table_type"))
353                .when(exp.Literal.string("VIEW"), exp.Literal.string("view"))
354                .when(
355                    exp.Literal.string("MATERIALIZED_VIEW"), exp.Literal.string("materialized_view")
356                )
357                .else_(exp.Literal.string("table"))
358                .as_("type"),
359            )
360            .from_(
361                # always query `system` information_schema
362                exp.table_("tables", "information_schema", "system")
363            )
364            .where(exp.column("table_catalog").eq(catalog_name))
365            .where(exp.column("table_schema").eq(schema.db))
366        )
367
368        if object_names:
369            query = query.where(exp.column("table_name").isin(*object_names))
370
371        df = self.fetchdf(query)
372        return [
373            DataObject(
374                catalog=row.catalog,  # type: ignore
375                schema=row.schema,  # type: ignore
376                name=row.name,  # type: ignore
377                type=DataObjectType.from_str(row.type),  # type: ignore
378            )
379            for row in df.itertuples()
380        ]
381
382    def clone_table(
383        self,
384        target_table_name: TableName,
385        source_table_name: TableName,
386        replace: bool = False,
387        exists: bool = True,
388        clone_kwargs: t.Optional[t.Dict[str, t.Any]] = None,
389        **kwargs: t.Any,
390    ) -> None:
391        clone_kwargs = clone_kwargs or {}
392        clone_kwargs["shallow"] = True
393        super().clone_table(
394            target_table_name,
395            source_table_name,
396            replace=replace,
397            clone_kwargs=clone_kwargs,
398            **kwargs,
399        )
400
401    def wap_supported(self, table_name: TableName) -> bool:
402        return False
403
404    def close(self) -> t.Any:
405        """Closes all open connections and releases all allocated resources."""
406        super().close()
407        if self._spark_engine_adapter:
408            self._spark_engine_adapter.close()
409
410    @property
411    def default_catalog(self) -> t.Optional[str]:
412        try:
413            return super().default_catalog
414        except MissingDefaultCatalogError as e:
415            raise MissingDefaultCatalogError(
416                "Could not determine default catalog. Define the connection property `catalog` since it can't be inferred from your connection. See SQLMesh Databricks documentation for details"
417            ) from e
418
419    def _build_table_properties_exp(
420        self,
421        catalog_name: t.Optional[str] = None,
422        table_format: t.Optional[str] = None,
423        storage_format: t.Optional[str] = None,
424        partitioned_by: t.Optional[t.List[exp.Expr]] = None,
425        partition_interval_unit: t.Optional[IntervalUnit] = None,
426        clustered_by: t.Optional[t.List[exp.Expr]] = None,
427        table_properties: t.Optional[t.Dict[str, exp.Expr]] = None,
428        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None,
429        table_description: t.Optional[str] = None,
430        table_kind: t.Optional[str] = None,
431        **kwargs: t.Any,
432    ) -> t.Optional[exp.Properties]:
433        properties = super()._build_table_properties_exp(
434            catalog_name=catalog_name,
435            table_format=table_format,
436            storage_format=storage_format,
437            partitioned_by=partitioned_by,
438            partition_interval_unit=partition_interval_unit,
439            clustered_by=clustered_by,
440            table_properties=table_properties,
441            target_columns_to_types=target_columns_to_types,
442            table_description=table_description,
443            table_kind=table_kind,
444        )
445        if clustered_by:
446            if len(clustered_by) == 1 and isinstance(clustered_by[0], exp.Var):
447                if clustered_by[0].name.upper() not in LIQUID_CLUSTERING_KEYWORDS:
448                    raise ValueError(f"Unexpected bare Var in clustered_by: {clustered_by[0]!r}")
449                # exp.Cluster with a bare Var generates: CLUSTER BY AUTO (no parens)
450                clustered_by_exp = exp.Cluster(expressions=[clustered_by[0].copy()])
451            else:
452                # Databricks expects column expressions wrapped in a tuple
453                clustered_by_exp = exp.Cluster(
454                    expressions=[exp.Tuple(expressions=[c.copy() for c in clustered_by])]
455                )
456            expressions = properties.expressions if properties else []
457            expressions.append(clustered_by_exp)
458            properties = exp.Properties(expressions=expressions)
459        return properties
460
461    def _build_column_defs(
462        self,
463        target_columns_to_types: t.Dict[str, exp.DataType],
464        column_descriptions: t.Optional[t.Dict[str, str]] = None,
465        is_view: bool = False,
466        materialized: bool = False,
467    ) -> t.List[exp.ColumnDef]:
468        # Databricks requires column types to be specified when adding column comments
469        # in CREATE MATERIALIZED VIEW statements. Override is_view to False to force
470        # column types to be included when comments are present.
471        if is_view and materialized and column_descriptions:
472            is_view = False
473
474        return super()._build_column_defs(
475            target_columns_to_types, column_descriptions, is_view, materialized
476        )
477
478    def columns(
479        self, table_name: TableName, include_pseudo_columns: bool = False
480    ) -> t.Dict[str, exp.DataType]:
481        table = exp.to_table(table_name)
482
483        column_catalog = table.catalog or self.get_current_catalog()
484        query = (
485            exp.select("columns.column_name", "columns.full_data_type")
486            .from_("system.information_schema.columns")
487            .where(
488                exp.and_(
489                    exp.column("table_name").eq(table.name),
490                    exp.column("table_schema").eq(table.db),
491                    exp.column("table_catalog").eq(column_catalog),
492                )
493            )
494            .order_by("ordinal_position ASC")
495        )
496
497        self.execute(query.sql(dialect=self.dialect))
498        result = self.cursor.fetchall()
499
500        return {row[0]: exp.DataType.build(row[1], dialect=self.dialect) for row in result}
logger = <Logger sqlmesh.core.engine_adapter.databricks (WARNING)>
 72class DatabricksEngineAdapter(SparkEngineAdapter, GrantsFromInfoSchemaMixin):
 73    DIALECT = "databricks"
 74    INSERT_OVERWRITE_STRATEGY = InsertOverwriteStrategy.REPLACE_WHERE
 75    SUPPORTS_CLONING = True
 76    SUPPORTS_MATERIALIZED_VIEWS = True
 77    SUPPORTS_MATERIALIZED_VIEW_SCHEMA = True
 78    SUPPORTS_GRANTS = True
 79    USE_CATALOG_IN_GRANTS = True
 80    # Spark has this set to false for compatibility when mixing with Trino but that isn't a concern with Databricks
 81    QUOTE_IDENTIFIERS_IN_VIEWS = True
 82    SCHEMA_DIFFER_KWARGS = {
 83        "support_positional_add": True,
 84        "nested_support": NestedSupport.ALL,
 85        "array_element_selector": "element",
 86        "parameterized_type_defaults": {
 87            exp.DataType.build("DECIMAL", dialect=DIALECT).this: [(10, 0), (0,)],
 88        },
 89    }
 90
 91    def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
 92        super().__init__(*args, **kwargs)
 93        self._set_spark_engine_adapter_if_needed()
 94
 95    @classmethod
 96    def can_access_spark_session(cls, disable_spark_session: bool) -> bool:
 97        from sqlmesh import RuntimeEnv
 98
 99        if disable_spark_session:
100            return False
101
102        return RuntimeEnv.get().is_databricks
103
104    @classmethod
105    def can_access_databricks_connect(cls, disable_databricks_connect: bool) -> bool:
106        if disable_databricks_connect:
107            return False
108
109        try:
110            from databricks.connect import DatabricksSession  # noqa
111
112            return True
113        except ImportError:
114            return False
115
116    @property
117    def _use_spark_session(self) -> bool:
118        if self.can_access_spark_session(bool(self._extra_config.get("disable_spark_session"))):
119            return True
120
121        if self.can_access_databricks_connect(
122            bool(self._extra_config.get("disable_databricks_connect"))
123        ):
124            if self._extra_config.get("databricks_connect_use_serverless"):
125                return True
126
127            if {
128                "databricks_connect_cluster_id",
129                "databricks_connect_server_hostname",
130                "databricks_connect_access_token",
131            }.issubset(self._extra_config):
132                return True
133
134        return False
135
136    @property
137    def is_spark_session_connection(self) -> bool:
138        return isinstance(self.connection, SparkSessionConnection)
139
140    @property
141    def _is_databricks_sql_connector_connection(self) -> bool:
142        return not self.is_spark_session_connection and not self._connection_pool.get_attribute(
143            "use_spark_engine_adapter"
144        )
145
146    def _set_spark_engine_adapter_if_needed(self) -> None:
147        self._spark_engine_adapter = None
148
149        if not self._use_spark_session or self.is_spark_session_connection:
150            return
151
152        from databricks.connect import DatabricksSession
153
154        connect_kwargs = dict(
155            host=self._extra_config["databricks_connect_server_hostname"],
156            token=self._extra_config.get("databricks_connect_access_token"),
157        )
158        if "databricks_connect_use_serverless" in self._extra_config:
159            connect_kwargs["serverless"] = True
160        else:
161            connect_kwargs["cluster_id"] = self._extra_config["databricks_connect_cluster_id"]
162
163        catalog = self._extra_config.get("catalog")
164        spark = (
165            DatabricksSession.builder.remote(**connect_kwargs).userAgent("sqlmesh").getOrCreate()
166        )
167        self._spark_engine_adapter = SparkEngineAdapter(
168            partial(connection, spark=spark, catalog=catalog),
169            default_catalog=catalog,
170            execute_log_level=self._execute_log_level,
171            multithreaded=self._multithreaded,
172            sql_gen_kwargs=self._sql_gen_kwargs,
173            register_comments=self._register_comments,
174            pre_ping=self._pre_ping,
175            pretty_sql=self._pretty_sql,
176        )
177
178    @property
179    def cursor(self) -> t.Any:
180        if (
181            self._connection_pool.get_attribute("use_spark_engine_adapter")
182            and not self.is_spark_session_connection
183        ):
184            return self._spark_engine_adapter.cursor  # type: ignore
185        return super().cursor
186
187    @property
188    def spark(self) -> PySparkSession:
189        if not self._use_spark_session:
190            raise SQLMeshError(
191                "SparkSession is not available. "
192                "Either run from a Databricks Notebook or "
193                "install `databricks-connect` and configure it to connect to your Databricks cluster."
194            )
195        if self.is_spark_session_connection:
196            return self.connection.spark
197        return self._spark_engine_adapter.spark  # type: ignore
198
199    @property
200    def catalog_support(self) -> CatalogSupport:
201        return CatalogSupport.FULL_SUPPORT
202
203    @staticmethod
204    def _grant_object_kind(table_type: DataObjectType) -> str:
205        if table_type == DataObjectType.VIEW:
206            return "VIEW"
207        if table_type == DataObjectType.MATERIALIZED_VIEW:
208            return "MATERIALIZED VIEW"
209        return "TABLE"
210
211    def _get_grant_expression(self, table: exp.Table) -> exp.Expr:
212        # We only care about explicitly granted privileges and not inherited ones
213        # if this is removed you would see grants inherited from the catalog get returned
214        expression = super()._get_grant_expression(table)
215        expression.args["where"].set(
216            "this",
217            exp.and_(
218                expression.args["where"].this,
219                exp.column("inherited_from").eq(exp.Literal.string("NONE")),
220                wrap=False,
221            ),
222        )
223        return expression
224
225    def _begin_session(self, properties: SessionProperties) -> t.Any:
226        """Begin a new session."""
227        # Align the different possible connectors to a single catalog
228        self.set_current_catalog(self.default_catalog)  # type: ignore
229        self._connection_pool.set_attribute("query_tags", _query_tags(properties.get("query_tags")))
230
231    def _end_session(self) -> None:
232        self._connection_pool.set_attribute("query_tags", None)
233        self._connection_pool.set_attribute("use_spark_engine_adapter", False)
234
235    def _execute(self, sql: str, track_rows_processed: bool = False, **kwargs: t.Any) -> None:
236        query_tags = self._connection_pool.get_attribute("query_tags")
237        if (
238            query_tags
239            and "query_tags" not in kwargs
240            and self._is_databricks_sql_connector_connection
241        ):
242            kwargs["query_tags"] = query_tags
243
244        return super()._execute(sql, track_rows_processed, **kwargs)
245
246    def _df_to_source_queries(
247        self,
248        df: DF,
249        target_columns_to_types: t.Dict[str, exp.DataType],
250        batch_size: int,
251        target_table: TableName,
252        source_columns: t.Optional[t.List[str]] = None,
253    ) -> t.List[SourceQuery]:
254        if not self._use_spark_session:
255            return super(SparkEngineAdapter, self)._df_to_source_queries(
256                df, target_columns_to_types, batch_size, target_table, source_columns=source_columns
257            )
258        pyspark_df = self._ensure_pyspark_df(
259            df, target_columns_to_types, source_columns=source_columns
260        )
261
262        def query_factory() -> Query:
263            temp_table = self._get_temp_table(target_table or "spark", table_only=True)
264            pyspark_df.createOrReplaceTempView(temp_table.sql(dialect=self.dialect))
265            self._connection_pool.set_attribute("use_spark_engine_adapter", True)
266            return exp.select(*self._select_columns(target_columns_to_types)).from_(temp_table)
267
268        return [SourceQuery(query_factory=query_factory)]
269
270    def _fetch_native_df(
271        self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False
272    ) -> DF:
273        """Fetches a DataFrame that can be either Pandas or PySpark from the cursor"""
274        if self.is_spark_session_connection:
275            return super()._fetch_native_df(query, quote_identifiers=quote_identifiers)
276        if self._spark_engine_adapter:
277            return self._spark_engine_adapter._fetch_native_df(  # type: ignore
278                query, quote_identifiers=quote_identifiers
279            )
280        self.execute(query)
281        return self.cursor.fetchall_arrow().to_pandas()
282
283    def fetchdf(
284        self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False
285    ) -> pd.DataFrame:
286        """
287        Returns a Pandas DataFrame from a query or expression.
288        """
289        import pandas as pd
290
291        df = self._fetch_native_df(query, quote_identifiers=quote_identifiers)
292        if not isinstance(df, pd.DataFrame):
293            return df.toPandas()
294        return df
295
296    def get_current_catalog(self) -> t.Optional[str]:
297        pyspark_catalog = None
298        sql_connector_catalog = None
299        if self._spark_engine_adapter:
300            from py4j.protocol import Py4JError
301            from pyspark.errors.exceptions.connect import SparkConnectGrpcException
302
303            try:
304                # Note: Spark 3.4+ Only API
305                pyspark_catalog = self._spark_engine_adapter.get_current_catalog()
306            except (Py4JError, SparkConnectGrpcException):
307                pass
308        elif self.is_spark_session_connection:
309            pyspark_catalog = self.connection.spark.catalog.currentCatalog()
310        if not self.is_spark_session_connection:
311            result = self.fetchone(exp.select(self.CURRENT_CATALOG_EXPRESSION))
312            sql_connector_catalog = result[0] if result else None
313        if self._spark_engine_adapter and pyspark_catalog != sql_connector_catalog:
314            logger.warning(
315                f"Current catalog mismatch between Databricks SQL Connector and Databricks-Connect: `{sql_connector_catalog}` != `{pyspark_catalog}`. Set `catalog` connection property to make them the same."
316            )
317        return pyspark_catalog or sql_connector_catalog
318
319    def set_current_catalog(self, catalog_name: str) -> None:
320        def _set_spark_session_current_catalog(spark: PySparkSession) -> None:
321            from py4j.protocol import Py4JError
322            from pyspark.errors.exceptions.connect import SparkConnectGrpcException
323
324            try:
325                # Note: Spark 3.4+ Only API
326                spark.catalog.setCurrentCatalog(catalog_name)
327            except (Py4JError, SparkConnectGrpcException):
328                pass
329
330        # Since Databricks splits commands across the Dataframe API and the SQL Connector
331        # (depending if databricks-connect is installed and a Dataframe is used) we need to ensure both
332        # are set to the same catalog since they maintain their default catalog separately
333        self.execute(exp.Use(this=exp.to_identifier(catalog_name), kind="CATALOG"))
334        if self.is_spark_session_connection:
335            _set_spark_session_current_catalog(self.connection.spark)
336
337        if self._spark_engine_adapter:
338            _set_spark_session_current_catalog(self._spark_engine_adapter.spark)
339
340    def _get_data_objects(
341        self, schema_name: SchemaName, object_names: t.Optional[t.Set[str]] = None
342    ) -> t.List[DataObject]:
343        """
344        Returns all the data objects that exist in the given schema and catalog.
345        """
346        schema = to_schema(schema_name)
347        catalog_name = schema.catalog or self.get_current_catalog()
348        query = (
349            exp.select(
350                exp.column("table_name").as_("name"),
351                exp.column("table_schema").as_("schema"),
352                exp.column("table_catalog").as_("catalog"),
353                exp.case(exp.column("table_type"))
354                .when(exp.Literal.string("VIEW"), exp.Literal.string("view"))
355                .when(
356                    exp.Literal.string("MATERIALIZED_VIEW"), exp.Literal.string("materialized_view")
357                )
358                .else_(exp.Literal.string("table"))
359                .as_("type"),
360            )
361            .from_(
362                # always query `system` information_schema
363                exp.table_("tables", "information_schema", "system")
364            )
365            .where(exp.column("table_catalog").eq(catalog_name))
366            .where(exp.column("table_schema").eq(schema.db))
367        )
368
369        if object_names:
370            query = query.where(exp.column("table_name").isin(*object_names))
371
372        df = self.fetchdf(query)
373        return [
374            DataObject(
375                catalog=row.catalog,  # type: ignore
376                schema=row.schema,  # type: ignore
377                name=row.name,  # type: ignore
378                type=DataObjectType.from_str(row.type),  # type: ignore
379            )
380            for row in df.itertuples()
381        ]
382
383    def clone_table(
384        self,
385        target_table_name: TableName,
386        source_table_name: TableName,
387        replace: bool = False,
388        exists: bool = True,
389        clone_kwargs: t.Optional[t.Dict[str, t.Any]] = None,
390        **kwargs: t.Any,
391    ) -> None:
392        clone_kwargs = clone_kwargs or {}
393        clone_kwargs["shallow"] = True
394        super().clone_table(
395            target_table_name,
396            source_table_name,
397            replace=replace,
398            clone_kwargs=clone_kwargs,
399            **kwargs,
400        )
401
402    def wap_supported(self, table_name: TableName) -> bool:
403        return False
404
405    def close(self) -> t.Any:
406        """Closes all open connections and releases all allocated resources."""
407        super().close()
408        if self._spark_engine_adapter:
409            self._spark_engine_adapter.close()
410
411    @property
412    def default_catalog(self) -> t.Optional[str]:
413        try:
414            return super().default_catalog
415        except MissingDefaultCatalogError as e:
416            raise MissingDefaultCatalogError(
417                "Could not determine default catalog. Define the connection property `catalog` since it can't be inferred from your connection. See SQLMesh Databricks documentation for details"
418            ) from e
419
420    def _build_table_properties_exp(
421        self,
422        catalog_name: t.Optional[str] = None,
423        table_format: t.Optional[str] = None,
424        storage_format: t.Optional[str] = None,
425        partitioned_by: t.Optional[t.List[exp.Expr]] = None,
426        partition_interval_unit: t.Optional[IntervalUnit] = None,
427        clustered_by: t.Optional[t.List[exp.Expr]] = None,
428        table_properties: t.Optional[t.Dict[str, exp.Expr]] = None,
429        target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]] = None,
430        table_description: t.Optional[str] = None,
431        table_kind: t.Optional[str] = None,
432        **kwargs: t.Any,
433    ) -> t.Optional[exp.Properties]:
434        properties = super()._build_table_properties_exp(
435            catalog_name=catalog_name,
436            table_format=table_format,
437            storage_format=storage_format,
438            partitioned_by=partitioned_by,
439            partition_interval_unit=partition_interval_unit,
440            clustered_by=clustered_by,
441            table_properties=table_properties,
442            target_columns_to_types=target_columns_to_types,
443            table_description=table_description,
444            table_kind=table_kind,
445        )
446        if clustered_by:
447            if len(clustered_by) == 1 and isinstance(clustered_by[0], exp.Var):
448                if clustered_by[0].name.upper() not in LIQUID_CLUSTERING_KEYWORDS:
449                    raise ValueError(f"Unexpected bare Var in clustered_by: {clustered_by[0]!r}")
450                # exp.Cluster with a bare Var generates: CLUSTER BY AUTO (no parens)
451                clustered_by_exp = exp.Cluster(expressions=[clustered_by[0].copy()])
452            else:
453                # Databricks expects column expressions wrapped in a tuple
454                clustered_by_exp = exp.Cluster(
455                    expressions=[exp.Tuple(expressions=[c.copy() for c in clustered_by])]
456                )
457            expressions = properties.expressions if properties else []
458            expressions.append(clustered_by_exp)
459            properties = exp.Properties(expressions=expressions)
460        return properties
461
462    def _build_column_defs(
463        self,
464        target_columns_to_types: t.Dict[str, exp.DataType],
465        column_descriptions: t.Optional[t.Dict[str, str]] = None,
466        is_view: bool = False,
467        materialized: bool = False,
468    ) -> t.List[exp.ColumnDef]:
469        # Databricks requires column types to be specified when adding column comments
470        # in CREATE MATERIALIZED VIEW statements. Override is_view to False to force
471        # column types to be included when comments are present.
472        if is_view and materialized and column_descriptions:
473            is_view = False
474
475        return super()._build_column_defs(
476            target_columns_to_types, column_descriptions, is_view, materialized
477        )
478
479    def columns(
480        self, table_name: TableName, include_pseudo_columns: bool = False
481    ) -> t.Dict[str, exp.DataType]:
482        table = exp.to_table(table_name)
483
484        column_catalog = table.catalog or self.get_current_catalog()
485        query = (
486            exp.select("columns.column_name", "columns.full_data_type")
487            .from_("system.information_schema.columns")
488            .where(
489                exp.and_(
490                    exp.column("table_name").eq(table.name),
491                    exp.column("table_schema").eq(table.db),
492                    exp.column("table_catalog").eq(column_catalog),
493                )
494            )
495            .order_by("ordinal_position ASC")
496        )
497
498        self.execute(query.sql(dialect=self.dialect))
499        result = self.cursor.fetchall()
500
501        return {row[0]: exp.DataType.build(row[1], dialect=self.dialect) for row in result}

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.
DatabricksEngineAdapter(*args: Any, **kwargs: Any)
91    def __init__(self, *args: t.Any, **kwargs: t.Any) -> None:
92        super().__init__(*args, **kwargs)
93        self._set_spark_engine_adapter_if_needed()
DIALECT = 'databricks'
INSERT_OVERWRITE_STRATEGY = <InsertOverwriteStrategy.REPLACE_WHERE: 3>
SUPPORTS_CLONING = True
SUPPORTS_MATERIALIZED_VIEWS = True
SUPPORTS_MATERIALIZED_VIEW_SCHEMA = True
SUPPORTS_GRANTS = True
USE_CATALOG_IN_GRANTS = True
QUOTE_IDENTIFIERS_IN_VIEWS = True
SCHEMA_DIFFER_KWARGS = {'support_positional_add': True, 'nested_support': <NestedSupport.ALL: 'ALL'>, 'array_element_selector': 'element', 'parameterized_type_defaults': {<DType.DECIMAL: 'DECIMAL'>: [(10, 0), (0,)]}}
@classmethod
def can_access_spark_session(cls, disable_spark_session: bool) -> bool:
 95    @classmethod
 96    def can_access_spark_session(cls, disable_spark_session: bool) -> bool:
 97        from sqlmesh import RuntimeEnv
 98
 99        if disable_spark_session:
100            return False
101
102        return RuntimeEnv.get().is_databricks
@classmethod
def can_access_databricks_connect(cls, disable_databricks_connect: bool) -> bool:
104    @classmethod
105    def can_access_databricks_connect(cls, disable_databricks_connect: bool) -> bool:
106        if disable_databricks_connect:
107            return False
108
109        try:
110            from databricks.connect import DatabricksSession  # noqa
111
112            return True
113        except ImportError:
114            return False
is_spark_session_connection: bool
136    @property
137    def is_spark_session_connection(self) -> bool:
138        return isinstance(self.connection, SparkSessionConnection)
cursor: Any
178    @property
179    def cursor(self) -> t.Any:
180        if (
181            self._connection_pool.get_attribute("use_spark_engine_adapter")
182            and not self.is_spark_session_connection
183        ):
184            return self._spark_engine_adapter.cursor  # type: ignore
185        return super().cursor
spark: <MagicMock id='130969801294608'>
187    @property
188    def spark(self) -> PySparkSession:
189        if not self._use_spark_session:
190            raise SQLMeshError(
191                "SparkSession is not available. "
192                "Either run from a Databricks Notebook or "
193                "install `databricks-connect` and configure it to connect to your Databricks cluster."
194            )
195        if self.is_spark_session_connection:
196            return self.connection.spark
197        return self._spark_engine_adapter.spark  # type: ignore
199    @property
200    def catalog_support(self) -> CatalogSupport:
201        return CatalogSupport.FULL_SUPPORT
def fetchdf( self, query: Union[sqlglot.expressions.core.Expr, str], quote_identifiers: bool = False) -> pandas.core.frame.DataFrame:
283    def fetchdf(
284        self, query: t.Union[exp.Expr, str], quote_identifiers: bool = False
285    ) -> pd.DataFrame:
286        """
287        Returns a Pandas DataFrame from a query or expression.
288        """
289        import pandas as pd
290
291        df = self._fetch_native_df(query, quote_identifiers=quote_identifiers)
292        if not isinstance(df, pd.DataFrame):
293            return df.toPandas()
294        return df

Returns a Pandas DataFrame from a query or expression.

def get_current_catalog(self) -> Optional[str]:
296    def get_current_catalog(self) -> t.Optional[str]:
297        pyspark_catalog = None
298        sql_connector_catalog = None
299        if self._spark_engine_adapter:
300            from py4j.protocol import Py4JError
301            from pyspark.errors.exceptions.connect import SparkConnectGrpcException
302
303            try:
304                # Note: Spark 3.4+ Only API
305                pyspark_catalog = self._spark_engine_adapter.get_current_catalog()
306            except (Py4JError, SparkConnectGrpcException):
307                pass
308        elif self.is_spark_session_connection:
309            pyspark_catalog = self.connection.spark.catalog.currentCatalog()
310        if not self.is_spark_session_connection:
311            result = self.fetchone(exp.select(self.CURRENT_CATALOG_EXPRESSION))
312            sql_connector_catalog = result[0] if result else None
313        if self._spark_engine_adapter and pyspark_catalog != sql_connector_catalog:
314            logger.warning(
315                f"Current catalog mismatch between Databricks SQL Connector and Databricks-Connect: `{sql_connector_catalog}` != `{pyspark_catalog}`. Set `catalog` connection property to make them the same."
316            )
317        return pyspark_catalog or sql_connector_catalog

Returns the catalog name of the current connection.

def set_current_catalog(self, catalog_name: str) -> None:
319    def set_current_catalog(self, catalog_name: str) -> None:
320        def _set_spark_session_current_catalog(spark: PySparkSession) -> None:
321            from py4j.protocol import Py4JError
322            from pyspark.errors.exceptions.connect import SparkConnectGrpcException
323
324            try:
325                # Note: Spark 3.4+ Only API
326                spark.catalog.setCurrentCatalog(catalog_name)
327            except (Py4JError, SparkConnectGrpcException):
328                pass
329
330        # Since Databricks splits commands across the Dataframe API and the SQL Connector
331        # (depending if databricks-connect is installed and a Dataframe is used) we need to ensure both
332        # are set to the same catalog since they maintain their default catalog separately
333        self.execute(exp.Use(this=exp.to_identifier(catalog_name), kind="CATALOG"))
334        if self.is_spark_session_connection:
335            _set_spark_session_current_catalog(self.connection.spark)
336
337        if self._spark_engine_adapter:
338            _set_spark_session_current_catalog(self._spark_engine_adapter.spark)

Sets the catalog name of the current connection.

def clone_table( self, target_table_name: Union[str, sqlglot.expressions.query.Table], source_table_name: Union[str, sqlglot.expressions.query.Table], replace: bool = False, exists: bool = True, clone_kwargs: Optional[Dict[str, Any]] = None, **kwargs: Any) -> None:
383    def clone_table(
384        self,
385        target_table_name: TableName,
386        source_table_name: TableName,
387        replace: bool = False,
388        exists: bool = True,
389        clone_kwargs: t.Optional[t.Dict[str, t.Any]] = None,
390        **kwargs: t.Any,
391    ) -> None:
392        clone_kwargs = clone_kwargs or {}
393        clone_kwargs["shallow"] = True
394        super().clone_table(
395            target_table_name,
396            source_table_name,
397            replace=replace,
398            clone_kwargs=clone_kwargs,
399            **kwargs,
400        )

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.
def wap_supported(self, table_name: Union[str, sqlglot.expressions.query.Table]) -> bool:
402    def wap_supported(self, table_name: TableName) -> bool:
403        return False

Returns whether WAP for the target table is supported.

def close(self) -> Any:
405    def close(self) -> t.Any:
406        """Closes all open connections and releases all allocated resources."""
407        super().close()
408        if self._spark_engine_adapter:
409            self._spark_engine_adapter.close()

Closes all open connections and releases all allocated resources.

default_catalog: Optional[str]
411    @property
412    def default_catalog(self) -> t.Optional[str]:
413        try:
414            return super().default_catalog
415        except MissingDefaultCatalogError as e:
416            raise MissingDefaultCatalogError(
417                "Could not determine default catalog. Define the connection property `catalog` since it can't be inferred from your connection. See SQLMesh Databricks documentation for details"
418            ) from e
def columns( self, table_name: Union[str, sqlglot.expressions.query.Table], include_pseudo_columns: bool = False) -> Dict[str, sqlglot.expressions.datatypes.DataType]:
479    def columns(
480        self, table_name: TableName, include_pseudo_columns: bool = False
481    ) -> t.Dict[str, exp.DataType]:
482        table = exp.to_table(table_name)
483
484        column_catalog = table.catalog or self.get_current_catalog()
485        query = (
486            exp.select("columns.column_name", "columns.full_data_type")
487            .from_("system.information_schema.columns")
488            .where(
489                exp.and_(
490                    exp.column("table_name").eq(table.name),
491                    exp.column("table_schema").eq(table.db),
492                    exp.column("table_catalog").eq(column_catalog),
493                )
494            )
495            .order_by("ordinal_position ASC")
496        )
497
498        self.execute(query.sql(dialect=self.dialect))
499        result = self.cursor.fetchall()
500
501        return {row[0]: exp.DataType.build(row[1], dialect=self.dialect) for row in result}

Fetches column names and types for the target table.

Inherited Members
sqlmesh.core.engine_adapter.spark.SparkEngineAdapter
SUPPORTS_TRANSACTIONS
COMMENT_CREATION_TABLE
COMMENT_CREATION_VIEW
SUPPORTS_REPLACE_TABLE
SUPPORTED_DROP_CASCADE_OBJECT_KINDS
WAP_PREFIX
BRANCH_PREFIX
connection
use_serverless
spark_to_sqlglot_types
sqlglot_to_spark_types
is_pyspark_df
try_get_pyspark_df
try_get_pandas_df
fetch_pyspark_df
get_data_object
create_state_table
wap_table_name
wap_prepare
wap_publish
sqlmesh.core.engine_adapter.mixins.GetCurrentCatalogFromFunctionMixin
CURRENT_CATALOG_EXPRESSION
sqlmesh.core.engine_adapter.mixins.HiveMetastoreTablePropertiesMixin
MAX_TABLE_COMMENT_LENGTH
MAX_COLUMN_COMMENT_LENGTH
sqlmesh.core.engine_adapter.mixins.RowDiffMixin
MAX_TIMESTAMP_PRECISION
concat_columns
normalize_value
sqlmesh.core.engine_adapter.mixins.GrantsFromInfoSchemaMixin
CURRENT_USER_OR_ROLE_EXPRESSION
SUPPORTS_MULTIPLE_GRANT_PRINCIPALS
GRANT_INFORMATION_SCHEMA_TABLE_NAME
sqlmesh.core.engine_adapter.base.EngineAdapter
DEFAULT_BATCH_SIZE
DATA_OBJECT_FILTER_BATCH_SIZE
SUPPORTS_INDEXES
SUPPORTS_VIEW_SCHEMA
SUPPORTS_MANAGED_MODELS
SUPPORTS_CREATE_DROP_CATALOG
SUPPORTS_TUPLE_IN
HAS_VIEW_BINDING
RECREATE_MATERIALIZED_VIEW_ON_EVALUATION
DEFAULT_CATALOG_TYPE
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
snowpark
bigframe
comments_enabled
supports_virtual_catalog
inject_virtual_catalog
schema_differ
engine_run_mode
recycle
get_catalog_type
get_catalog_type_from_table
current_catalog_type
replace_query
create_index
create_table
create_managed_table
ctas
create_table_like
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
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_objects
fetchone
fetchall
wap_enabled
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