Edit on GitHub

sqlmesh.core.engine_adapter.fabric

  1from __future__ import annotations
  2
  3import typing as t
  4import logging
  5import requests
  6import time
  7from functools import cached_property
  8from sqlglot import exp
  9from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_result
 10from sqlmesh.core.engine_adapter.mssql import MSSQLEngineAdapter
 11from sqlmesh.core.engine_adapter.shared import (
 12    CommentCreationTable,
 13    CommentCreationView,
 14    InsertOverwriteStrategy,
 15)
 16from sqlmesh.utils.errors import SQLMeshError
 17from sqlmesh.utils.connection_pool import ConnectionPool
 18from sqlmesh.core.schema_diff import TableAlterOperation
 19from sqlmesh.utils import random_id
 20
 21
 22logger = logging.getLogger(__name__)
 23
 24
 25class FabricEngineAdapter(MSSQLEngineAdapter):
 26    """
 27    Adapter for Microsoft Fabric.
 28    """
 29
 30    DIALECT = "fabric"
 31    SUPPORTS_INDEXES = False
 32    SUPPORTS_TRANSACTIONS = False
 33    SUPPORTS_CREATE_DROP_CATALOG = True
 34    INSERT_OVERWRITE_STRATEGY = InsertOverwriteStrategy.DELETE_INSERT
 35    # There is no standard method to handle comments in Fabric for now, so we disable it.
 36    # Otherwise, it would be inherited from MSSQL and would not work.
 37    COMMENT_CREATION_TABLE = CommentCreationTable.UNSUPPORTED
 38    COMMENT_CREATION_VIEW = CommentCreationView.UNSUPPORTED
 39
 40    def __init__(
 41        self, connection_factory_or_pool: t.Union[t.Callable, t.Any], *args: t.Any, **kwargs: t.Any
 42    ) -> None:
 43        # Wrap connection factory to support changing the catalog dynamically at runtime
 44        if not isinstance(connection_factory_or_pool, ConnectionPool):
 45            original_connection_factory = connection_factory_or_pool
 46
 47            connection_factory_or_pool = lambda *args, **kwargs: original_connection_factory(
 48                target_catalog=self._target_catalog, *args, **kwargs
 49            )
 50
 51        super().__init__(connection_factory_or_pool, *args, **kwargs)
 52
 53    @property
 54    def _target_catalog(self) -> t.Optional[str]:
 55        return self._connection_pool.get_attribute("target_catalog")
 56
 57    @_target_catalog.setter
 58    def _target_catalog(self, value: t.Optional[str]) -> None:
 59        self._connection_pool.set_attribute("target_catalog", value)
 60
 61    @property
 62    def _connected_catalog(self) -> t.Optional[str]:
 63        """Catalog the currently-open thread-local connection is actually using."""
 64        return self._connection_pool.get_attribute("connected_catalog")
 65
 66    @_connected_catalog.setter
 67    def _connected_catalog(self, value: t.Optional[str]) -> None:
 68        self._connection_pool.set_attribute("connected_catalog", value)
 69
 70    def _normalize_catalog(self, catalog_name: t.Optional[str]) -> t.Optional[str]:
 71        if not catalog_name:
 72            return None
 73
 74        default_catalog = self._default_catalog or self._extra_config.get("database")
 75        if default_catalog and catalog_name == default_catalog:
 76            return None
 77
 78        return catalog_name
 79
 80    def _catalog_state_label(self, catalog_name: t.Optional[str]) -> str:
 81        return (
 82            catalog_name
 83            or self._default_catalog
 84            or self._extra_config.get("database")
 85            or "<default>"
 86        )
 87
 88    @property
 89    def api_client(self) -> FabricHttpClient:
 90        # the requests Session is not guaranteed to be threadsafe
 91        # so we create a http client per thread on demand
 92        if existing_client := self._connection_pool.get_attribute("api_client"):
 93            return existing_client
 94
 95        tenant_id: t.Optional[str] = self._extra_config.get("tenant_id")
 96        workspace_id: t.Optional[str] = self._extra_config.get("workspace_id")
 97        client_id: t.Optional[str] = self._extra_config.get("user")
 98        client_secret: t.Optional[str] = self._extra_config.get("password")
 99
100        if not tenant_id or not client_id or not client_secret:
101            raise SQLMeshError(
102                "Service Principal authentication requires tenant_id, client_id, and client_secret "
103                "in the Fabric connection configuration"
104            )
105
106        if not workspace_id:
107            raise SQLMeshError(
108                "Fabric requires the workspace_id to be configured in the connection configuration to create / drop catalogs"
109            )
110
111        client = FabricHttpClient(
112            tenant_id=tenant_id,
113            workspace_id=workspace_id,
114            client_id=client_id,
115            client_secret=client_secret,
116        )
117
118        self._connection_pool.set_attribute("api_client", client)
119        return client
120
121    def _create_catalog(self, catalog_name: exp.Identifier) -> None:
122        """Create a catalog (warehouse) in Microsoft Fabric via REST API."""
123        warehouse_name = catalog_name.sql(dialect=self.dialect, identify=False)
124        logger.info(f"Creating Fabric warehouse: {warehouse_name}")
125
126        self.api_client.create_warehouse(warehouse_name)
127
128    def _drop_catalog(self, catalog_name: exp.Identifier) -> None:
129        """Drop a catalog (warehouse) in Microsoft Fabric via REST API."""
130        warehouse_name = catalog_name.sql(dialect=self.dialect, identify=False)
131
132        logger.info(f"Deleting Fabric warehouse: {warehouse_name}")
133        self.api_client.delete_warehouse(warehouse_name)
134
135        # Close all connections if any thread may be using the dropped warehouse.
136        # We must check both the logical target and the physical connection catalog
137        # (falling back to the configured default when either is neutral) because
138        # Fabric validates the DATABASE= connection argument and raises
139        # 'Authentication Failed' when it points at a non-existent warehouse.
140        default_db = self._extra_config.get("database")
141        in_use = {
142            self.get_current_catalog() or default_db,
143            self._normalize_catalog(self._connected_catalog) or default_db,
144        }
145        if warehouse_name in in_use:
146            self.close()
147
148    def get_current_catalog(self) -> t.Optional[str]:
149        """Return the explicit Fabric catalog target for the current thread."""
150        return self._normalize_catalog(self._target_catalog)
151
152    def set_current_catalog(self, catalog_name: t.Optional[str]) -> None:
153        """
154        Set the current catalog for Microsoft Fabric connections.
155
156        Override to handle Fabric's stateless session limitation where USE statements
157        don't persist across queries. Instead, we close existing connections and
158        recreate them with the new catalog in the connection configuration.
159
160        Args:
161            catalog_name: The name of the catalog (warehouse) to switch to.
162                The configured default catalog is treated as the neutral state.
163
164        Note:
165            Fabric doesn't support catalog switching via USE statements because each
166            statement runs as an independent session. This method works around this
167            limitation by updating the connection pool with new catalog configuration.
168
169        See:
170            https://learn.microsoft.com/en-us/fabric/data-warehouse/sql-query-editor#limitations
171        """
172        target_catalog = self._normalize_catalog(catalog_name)
173        explicit_default_catalog = catalog_name is not None and target_catalog is None
174        connected_catalog = self._normalize_catalog(self._connected_catalog)
175
176        # An explicit request for the default catalog must also match the catalog
177        # used by the open connection. A lazy restore with None only updates the
178        # logical target and intentionally leaves that connection in place.
179        if self.get_current_catalog() == target_catalog and (
180            not explicit_default_catalog or connected_catalog is None
181        ):
182            logger.debug("Already using requested Fabric catalog state, no action needed")
183            return
184
185        # Decide whether the open connection needs to be replaced.
186        #
187        # The set_catalog decorator restores the previous catalog (often None)
188        # after every catalog-scoped call.  For Fabric, a connection close +
189        # reopen is expensive because each new connection goes through ODBC and
190        # the Fabric gateway.  We therefore apply lazy connection management:
191        #
192        #  * When restoring to neutral (target=None): just update _target_catalog.
193        #    The existing connection stays alive and will be reused or replaced
194        #    on the next real switch, avoiding a pointless bounce through the
195        #    default catalog.
196        #
197        #  * When switching to a non-neutral catalog: only close/reopen if the
198        #    open connection is already on a different catalog.  If a previous
199        #    restore-to-neutral left the connection on the right catalog, we
200        #    skip the close entirely.
201        needs_reconnect = (target_catalog is not None or explicit_default_catalog) and (
202            connected_catalog != target_catalog
203        )
204
205        if needs_reconnect:
206            logger.info(
207                "Switching connection from catalog '%s' to '%s'",
208                self._catalog_state_label(connected_catalog),
209                self._catalog_state_label(target_catalog),
210            )
211            # Commit before closing to avoid snapshot-isolation errors on
212            # subsequent queries in the new connection.
213            self._connection_pool.commit()
214            # note: close() on the pool (not self.close()) to only affect this
215            # thread's connection rather than all threads.
216            self._connection_pool.close()
217            self._connected_catalog = target_catalog
218        else:
219            logger.debug(
220                "Updating catalog target to '%s' (connection remains on '%s')",
221                self._catalog_state_label(target_catalog),
222                self._catalog_state_label(connected_catalog),
223            )
224
225        self._target_catalog = target_catalog
226
227    def alter_table(
228        self, alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]]
229    ) -> None:
230        """
231        Applies alter expressions to a table. Fabric has limited support for ALTER TABLE,
232        so this method implements a workaround for column type changes.
233        This method is self-contained and sets its own catalog context.
234        """
235        if not alter_expressions:
236            return
237
238        # Get the target table from the first expression to determine the correct catalog.
239        first_op = alter_expressions[0]
240        expression = first_op.expression if isinstance(first_op, TableAlterOperation) else first_op
241        if not isinstance(expression, exp.Alter) or not expression.this.catalog:
242            # Fallback for unexpected scenarios
243            logger.warning(
244                "Could not determine catalog from alter expression, executing with current context."
245            )
246            super().alter_table(alter_expressions)
247            return
248
249        target_catalog = expression.this.catalog
250        self.set_current_catalog(target_catalog)
251
252        with self.transaction():
253            for op in alter_expressions:
254                expression = op.expression if isinstance(op, TableAlterOperation) else op
255
256                if not isinstance(expression, exp.Alter):
257                    self.execute(expression)
258                    continue
259
260                for action in expression.actions:
261                    table_name = expression.this
262
263                    table_name_without_catalog = table_name.copy()
264                    table_name_without_catalog.set("catalog", None)
265
266                    is_type_change = isinstance(action, exp.AlterColumn) and action.args.get(
267                        "dtype"
268                    )
269
270                    if is_type_change:
271                        column_to_alter = action.this
272                        new_type = action.args["dtype"]
273                        temp_column_name_str = f"{column_to_alter.name}__{random_id(short=True)}"
274                        temp_column_name = exp.to_identifier(temp_column_name_str)
275
276                        logger.info(
277                            "Applying workaround for column '%s' on table '%s' to change type to '%s'.",
278                            column_to_alter.sql(),
279                            table_name.sql(),
280                            new_type.sql(),
281                        )
282
283                        # Step 1: Add a temporary column.
284                        add_column_expr = exp.Alter(
285                            this=table_name_without_catalog.copy(),
286                            kind="TABLE",
287                            actions=[
288                                exp.ColumnDef(this=temp_column_name.copy(), kind=new_type.copy())
289                            ],
290                        )
291                        add_sql = self._to_sql(add_column_expr)
292                        self.execute(add_sql)
293
294                        # Step 2: Copy and cast data.
295                        update_sql = self._to_sql(
296                            exp.Update(
297                                this=table_name_without_catalog.copy(),
298                                expressions=[
299                                    exp.EQ(
300                                        this=temp_column_name.copy(),
301                                        expression=exp.Cast(
302                                            this=column_to_alter.copy(), to=new_type.copy()
303                                        ),
304                                    )
305                                ],
306                            )
307                        )
308                        self.execute(update_sql)
309
310                        # Step 3: Drop the original column.
311                        drop_sql = self._to_sql(
312                            exp.Alter(
313                                this=table_name_without_catalog.copy(),
314                                kind="TABLE",
315                                actions=[exp.Drop(this=column_to_alter.copy(), kind="COLUMN")],
316                            )
317                        )
318                        self.execute(drop_sql)
319
320                        # Step 4: Rename the temporary column.
321                        old_name_qualified = f"{table_name_without_catalog.sql(dialect=self.dialect)}.{temp_column_name.sql(dialect=self.dialect)}"
322                        new_name_unquoted = column_to_alter.sql(
323                            dialect=self.dialect, identify=False
324                        )
325                        rename_sql = f"EXEC sp_rename '{old_name_qualified}', '{new_name_unquoted}', 'COLUMN'"
326                        self.execute(rename_sql)
327                    else:
328                        # For other alterations, execute directly.
329                        direct_alter_expr = exp.Alter(
330                            this=table_name_without_catalog.copy(), kind="TABLE", actions=[action]
331                        )
332                        self.execute(direct_alter_expr)
333
334
335class FabricHttpClient:
336    def __init__(self, tenant_id: str, workspace_id: str, client_id: str, client_secret: str):
337        self.tenant_id = tenant_id
338        self.client_id = client_id
339        self.client_secret = client_secret
340        self.workspace_id = workspace_id
341
342    def create_warehouse(
343        self, warehouse_name: str, if_not_exists: bool = True, attempt: int = 0
344    ) -> None:
345        """Create a catalog (warehouse) in Microsoft Fabric via REST API."""
346
347        # attempt count is arbitrary, it essentially equates to 5 minutes of 30 second waits
348        if attempt > 10:
349            raise SQLMeshError(
350                f"Gave up waiting for Fabric warehouse {warehouse_name} to become available"
351            )
352
353        logger.info(f"Creating Fabric warehouse: {warehouse_name}")
354
355        request_data = {
356            "displayName": warehouse_name,
357            "description": f"Warehouse created by SQLMesh: {warehouse_name}",
358        }
359
360        response = self.session.post(self._endpoint_url("warehouses"), json=request_data)
361
362        if (
363            if_not_exists
364            and response.status_code == 400
365            and (errorCode := response.json().get("errorCode", None))
366        ):
367            if errorCode == "ItemDisplayNameAlreadyInUse":
368                logger.warning(f"Fabric warehouse {warehouse_name} already exists")
369                return
370            if errorCode == "ItemDisplayNameNotAvailableYet":
371                logger.warning(f"Fabric warehouse {warehouse_name} is still spinning up; waiting")
372                # Fabric error message is something like:
373                #  - "Requested 'circleci_51d7087e__dev' is not available yet and is expected to become available in the upcoming minutes."
374                # This seems to happen if a catalog is dropped and then a new one with the same name is immediately created.
375                # There appears to be some delayed async process on the Fabric side that actually drops the warehouses and frees up the names to be used again
376                time.sleep(30)
377                return self.create_warehouse(
378                    warehouse_name=warehouse_name, if_not_exists=if_not_exists, attempt=attempt + 1
379                )
380
381        try:
382            response.raise_for_status()
383        except:
384            # the important information to actually debug anything is in the response body which Requests never prints
385            logger.exception(
386                f"Failed to create warehouse {warehouse_name}. status: {response.status_code}, body: {response.text}"
387            )
388            raise
389
390        # Handle direct success (201) or async creation (202)
391        if response.status_code == 201:
392            logger.info(f"Successfully created Fabric warehouse: {warehouse_name}")
393            return
394
395        if response.status_code == 202 and (location_header := response.headers.get("location")):
396            logger.info(f"Warehouse creation initiated for: {warehouse_name}")
397            self._wait_for_completion(location_header, warehouse_name)
398            logger.info(f"Successfully created Fabric warehouse: {warehouse_name}")
399        else:
400            logger.error(f"Unexpected response from Fabric API: {response}\n{response.text}")
401            raise SQLMeshError(f"Unable to create warehouse: {response}")
402
403    def delete_warehouse(self, warehouse_name: str, if_exists: bool = True) -> None:
404        """Drop a catalog (warehouse) in Microsoft Fabric via REST API."""
405        logger.info(f"Deleting Fabric warehouse: {warehouse_name}")
406
407        # Get the warehouse ID by listing warehouses
408        # TODO: handle continuationUri for pagination, ref: https://learn.microsoft.com/en-us/rest/api/fabric/warehouse/items/list-warehouses?tabs=HTTP#warehouses
409        response = self.session.get(self._endpoint_url("warehouses"))
410        response.raise_for_status()
411
412        warehouse_name_to_id = {
413            warehouse.get("displayName"): warehouse.get("id")
414            for warehouse in response.json().get("value", [])
415        }
416
417        warehouse_id = warehouse_name_to_id.get(warehouse_name, None)
418
419        if not warehouse_id:
420            logger.warning(
421                f"Fabric warehouse does not exist: {warehouse_name}\n(available warehouses: {', '.join(warehouse_name_to_id)})"
422            )
423            if if_exists:
424                return
425
426            raise SQLMeshError(
427                f"Unable to delete Fabric warehouse {warehouse_name} as it doesnt exist"
428            )
429
430        # Delete the warehouse by ID
431        response = self.session.delete(self._endpoint_url(f"warehouses/{warehouse_id}"))
432        response.raise_for_status()
433
434        logger.info(f"Successfully deleted Fabric warehouse: {warehouse_name}")
435
436    @cached_property
437    def session(self) -> requests.Session:
438        s = requests.Session()
439
440        access_token = self._get_access_token()
441        s.headers.update({"Authorization": f"Bearer {access_token}"})
442
443        return s
444
445    def _endpoint_url(self, endpoint: str) -> str:
446        if endpoint.startswith("/"):
447            endpoint = endpoint[1:]
448
449        return f"https://api.fabric.microsoft.com/v1/workspaces/{self.workspace_id}/{endpoint}"
450
451    def _get_access_token(self) -> str:
452        """Get access token using Service Principal authentication."""
453
454        # Use Azure AD OAuth2 token endpoint
455        token_url = f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token"
456
457        data = {
458            "grant_type": "client_credentials",
459            "client_id": self.client_id,
460            "client_secret": self.client_secret,
461            "scope": "https://api.fabric.microsoft.com/.default",
462        }
463
464        response = requests.post(token_url, data=data)
465        response.raise_for_status()
466        token_data = response.json()
467        return token_data["access_token"]
468
469    def _wait_for_completion(self, location_url: str, operation_name: str) -> None:
470        """Poll the operation status until completion."""
471
472        @retry(
473            wait=wait_exponential(multiplier=1, min=1, max=30),
474            stop=stop_after_attempt(20),
475            retry=retry_if_result(lambda result: result not in ["Succeeded", "Failed"]),
476        )
477        def _poll() -> str:
478            response = self.session.get(location_url)
479            response.raise_for_status()
480
481            result = response.json()
482            status = result.get("status", "Unknown")
483
484            logger.debug(f"Operation {operation_name} status: {status}")
485
486            if status == "Failed":
487                error_msg = result.get("error", {}).get("message", "Unknown error")
488                raise SQLMeshError(f"Operation {operation_name} failed: {error_msg}")
489            elif status in ["InProgress", "Running"]:
490                logger.debug(f"Operation {operation_name} still in progress...")
491            elif status not in ["Succeeded"]:
492                logger.warning(f"Unknown status '{status}' for operation {operation_name}")
493
494            return status
495
496        final_status = _poll()
497        if final_status != "Succeeded":
498            raise SQLMeshError(f"Operation {operation_name} completed with status: {final_status}")
logger = <Logger sqlmesh.core.engine_adapter.fabric (WARNING)>
class FabricEngineAdapter(sqlmesh.core.engine_adapter.mssql.MSSQLEngineAdapter):
 26class FabricEngineAdapter(MSSQLEngineAdapter):
 27    """
 28    Adapter for Microsoft Fabric.
 29    """
 30
 31    DIALECT = "fabric"
 32    SUPPORTS_INDEXES = False
 33    SUPPORTS_TRANSACTIONS = False
 34    SUPPORTS_CREATE_DROP_CATALOG = True
 35    INSERT_OVERWRITE_STRATEGY = InsertOverwriteStrategy.DELETE_INSERT
 36    # There is no standard method to handle comments in Fabric for now, so we disable it.
 37    # Otherwise, it would be inherited from MSSQL and would not work.
 38    COMMENT_CREATION_TABLE = CommentCreationTable.UNSUPPORTED
 39    COMMENT_CREATION_VIEW = CommentCreationView.UNSUPPORTED
 40
 41    def __init__(
 42        self, connection_factory_or_pool: t.Union[t.Callable, t.Any], *args: t.Any, **kwargs: t.Any
 43    ) -> None:
 44        # Wrap connection factory to support changing the catalog dynamically at runtime
 45        if not isinstance(connection_factory_or_pool, ConnectionPool):
 46            original_connection_factory = connection_factory_or_pool
 47
 48            connection_factory_or_pool = lambda *args, **kwargs: original_connection_factory(
 49                target_catalog=self._target_catalog, *args, **kwargs
 50            )
 51
 52        super().__init__(connection_factory_or_pool, *args, **kwargs)
 53
 54    @property
 55    def _target_catalog(self) -> t.Optional[str]:
 56        return self._connection_pool.get_attribute("target_catalog")
 57
 58    @_target_catalog.setter
 59    def _target_catalog(self, value: t.Optional[str]) -> None:
 60        self._connection_pool.set_attribute("target_catalog", value)
 61
 62    @property
 63    def _connected_catalog(self) -> t.Optional[str]:
 64        """Catalog the currently-open thread-local connection is actually using."""
 65        return self._connection_pool.get_attribute("connected_catalog")
 66
 67    @_connected_catalog.setter
 68    def _connected_catalog(self, value: t.Optional[str]) -> None:
 69        self._connection_pool.set_attribute("connected_catalog", value)
 70
 71    def _normalize_catalog(self, catalog_name: t.Optional[str]) -> t.Optional[str]:
 72        if not catalog_name:
 73            return None
 74
 75        default_catalog = self._default_catalog or self._extra_config.get("database")
 76        if default_catalog and catalog_name == default_catalog:
 77            return None
 78
 79        return catalog_name
 80
 81    def _catalog_state_label(self, catalog_name: t.Optional[str]) -> str:
 82        return (
 83            catalog_name
 84            or self._default_catalog
 85            or self._extra_config.get("database")
 86            or "<default>"
 87        )
 88
 89    @property
 90    def api_client(self) -> FabricHttpClient:
 91        # the requests Session is not guaranteed to be threadsafe
 92        # so we create a http client per thread on demand
 93        if existing_client := self._connection_pool.get_attribute("api_client"):
 94            return existing_client
 95
 96        tenant_id: t.Optional[str] = self._extra_config.get("tenant_id")
 97        workspace_id: t.Optional[str] = self._extra_config.get("workspace_id")
 98        client_id: t.Optional[str] = self._extra_config.get("user")
 99        client_secret: t.Optional[str] = self._extra_config.get("password")
100
101        if not tenant_id or not client_id or not client_secret:
102            raise SQLMeshError(
103                "Service Principal authentication requires tenant_id, client_id, and client_secret "
104                "in the Fabric connection configuration"
105            )
106
107        if not workspace_id:
108            raise SQLMeshError(
109                "Fabric requires the workspace_id to be configured in the connection configuration to create / drop catalogs"
110            )
111
112        client = FabricHttpClient(
113            tenant_id=tenant_id,
114            workspace_id=workspace_id,
115            client_id=client_id,
116            client_secret=client_secret,
117        )
118
119        self._connection_pool.set_attribute("api_client", client)
120        return client
121
122    def _create_catalog(self, catalog_name: exp.Identifier) -> None:
123        """Create a catalog (warehouse) in Microsoft Fabric via REST API."""
124        warehouse_name = catalog_name.sql(dialect=self.dialect, identify=False)
125        logger.info(f"Creating Fabric warehouse: {warehouse_name}")
126
127        self.api_client.create_warehouse(warehouse_name)
128
129    def _drop_catalog(self, catalog_name: exp.Identifier) -> None:
130        """Drop a catalog (warehouse) in Microsoft Fabric via REST API."""
131        warehouse_name = catalog_name.sql(dialect=self.dialect, identify=False)
132
133        logger.info(f"Deleting Fabric warehouse: {warehouse_name}")
134        self.api_client.delete_warehouse(warehouse_name)
135
136        # Close all connections if any thread may be using the dropped warehouse.
137        # We must check both the logical target and the physical connection catalog
138        # (falling back to the configured default when either is neutral) because
139        # Fabric validates the DATABASE= connection argument and raises
140        # 'Authentication Failed' when it points at a non-existent warehouse.
141        default_db = self._extra_config.get("database")
142        in_use = {
143            self.get_current_catalog() or default_db,
144            self._normalize_catalog(self._connected_catalog) or default_db,
145        }
146        if warehouse_name in in_use:
147            self.close()
148
149    def get_current_catalog(self) -> t.Optional[str]:
150        """Return the explicit Fabric catalog target for the current thread."""
151        return self._normalize_catalog(self._target_catalog)
152
153    def set_current_catalog(self, catalog_name: t.Optional[str]) -> None:
154        """
155        Set the current catalog for Microsoft Fabric connections.
156
157        Override to handle Fabric's stateless session limitation where USE statements
158        don't persist across queries. Instead, we close existing connections and
159        recreate them with the new catalog in the connection configuration.
160
161        Args:
162            catalog_name: The name of the catalog (warehouse) to switch to.
163                The configured default catalog is treated as the neutral state.
164
165        Note:
166            Fabric doesn't support catalog switching via USE statements because each
167            statement runs as an independent session. This method works around this
168            limitation by updating the connection pool with new catalog configuration.
169
170        See:
171            https://learn.microsoft.com/en-us/fabric/data-warehouse/sql-query-editor#limitations
172        """
173        target_catalog = self._normalize_catalog(catalog_name)
174        explicit_default_catalog = catalog_name is not None and target_catalog is None
175        connected_catalog = self._normalize_catalog(self._connected_catalog)
176
177        # An explicit request for the default catalog must also match the catalog
178        # used by the open connection. A lazy restore with None only updates the
179        # logical target and intentionally leaves that connection in place.
180        if self.get_current_catalog() == target_catalog and (
181            not explicit_default_catalog or connected_catalog is None
182        ):
183            logger.debug("Already using requested Fabric catalog state, no action needed")
184            return
185
186        # Decide whether the open connection needs to be replaced.
187        #
188        # The set_catalog decorator restores the previous catalog (often None)
189        # after every catalog-scoped call.  For Fabric, a connection close +
190        # reopen is expensive because each new connection goes through ODBC and
191        # the Fabric gateway.  We therefore apply lazy connection management:
192        #
193        #  * When restoring to neutral (target=None): just update _target_catalog.
194        #    The existing connection stays alive and will be reused or replaced
195        #    on the next real switch, avoiding a pointless bounce through the
196        #    default catalog.
197        #
198        #  * When switching to a non-neutral catalog: only close/reopen if the
199        #    open connection is already on a different catalog.  If a previous
200        #    restore-to-neutral left the connection on the right catalog, we
201        #    skip the close entirely.
202        needs_reconnect = (target_catalog is not None or explicit_default_catalog) and (
203            connected_catalog != target_catalog
204        )
205
206        if needs_reconnect:
207            logger.info(
208                "Switching connection from catalog '%s' to '%s'",
209                self._catalog_state_label(connected_catalog),
210                self._catalog_state_label(target_catalog),
211            )
212            # Commit before closing to avoid snapshot-isolation errors on
213            # subsequent queries in the new connection.
214            self._connection_pool.commit()
215            # note: close() on the pool (not self.close()) to only affect this
216            # thread's connection rather than all threads.
217            self._connection_pool.close()
218            self._connected_catalog = target_catalog
219        else:
220            logger.debug(
221                "Updating catalog target to '%s' (connection remains on '%s')",
222                self._catalog_state_label(target_catalog),
223                self._catalog_state_label(connected_catalog),
224            )
225
226        self._target_catalog = target_catalog
227
228    def alter_table(
229        self, alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]]
230    ) -> None:
231        """
232        Applies alter expressions to a table. Fabric has limited support for ALTER TABLE,
233        so this method implements a workaround for column type changes.
234        This method is self-contained and sets its own catalog context.
235        """
236        if not alter_expressions:
237            return
238
239        # Get the target table from the first expression to determine the correct catalog.
240        first_op = alter_expressions[0]
241        expression = first_op.expression if isinstance(first_op, TableAlterOperation) else first_op
242        if not isinstance(expression, exp.Alter) or not expression.this.catalog:
243            # Fallback for unexpected scenarios
244            logger.warning(
245                "Could not determine catalog from alter expression, executing with current context."
246            )
247            super().alter_table(alter_expressions)
248            return
249
250        target_catalog = expression.this.catalog
251        self.set_current_catalog(target_catalog)
252
253        with self.transaction():
254            for op in alter_expressions:
255                expression = op.expression if isinstance(op, TableAlterOperation) else op
256
257                if not isinstance(expression, exp.Alter):
258                    self.execute(expression)
259                    continue
260
261                for action in expression.actions:
262                    table_name = expression.this
263
264                    table_name_without_catalog = table_name.copy()
265                    table_name_without_catalog.set("catalog", None)
266
267                    is_type_change = isinstance(action, exp.AlterColumn) and action.args.get(
268                        "dtype"
269                    )
270
271                    if is_type_change:
272                        column_to_alter = action.this
273                        new_type = action.args["dtype"]
274                        temp_column_name_str = f"{column_to_alter.name}__{random_id(short=True)}"
275                        temp_column_name = exp.to_identifier(temp_column_name_str)
276
277                        logger.info(
278                            "Applying workaround for column '%s' on table '%s' to change type to '%s'.",
279                            column_to_alter.sql(),
280                            table_name.sql(),
281                            new_type.sql(),
282                        )
283
284                        # Step 1: Add a temporary column.
285                        add_column_expr = exp.Alter(
286                            this=table_name_without_catalog.copy(),
287                            kind="TABLE",
288                            actions=[
289                                exp.ColumnDef(this=temp_column_name.copy(), kind=new_type.copy())
290                            ],
291                        )
292                        add_sql = self._to_sql(add_column_expr)
293                        self.execute(add_sql)
294
295                        # Step 2: Copy and cast data.
296                        update_sql = self._to_sql(
297                            exp.Update(
298                                this=table_name_without_catalog.copy(),
299                                expressions=[
300                                    exp.EQ(
301                                        this=temp_column_name.copy(),
302                                        expression=exp.Cast(
303                                            this=column_to_alter.copy(), to=new_type.copy()
304                                        ),
305                                    )
306                                ],
307                            )
308                        )
309                        self.execute(update_sql)
310
311                        # Step 3: Drop the original column.
312                        drop_sql = self._to_sql(
313                            exp.Alter(
314                                this=table_name_without_catalog.copy(),
315                                kind="TABLE",
316                                actions=[exp.Drop(this=column_to_alter.copy(), kind="COLUMN")],
317                            )
318                        )
319                        self.execute(drop_sql)
320
321                        # Step 4: Rename the temporary column.
322                        old_name_qualified = f"{table_name_without_catalog.sql(dialect=self.dialect)}.{temp_column_name.sql(dialect=self.dialect)}"
323                        new_name_unquoted = column_to_alter.sql(
324                            dialect=self.dialect, identify=False
325                        )
326                        rename_sql = f"EXEC sp_rename '{old_name_qualified}', '{new_name_unquoted}', 'COLUMN'"
327                        self.execute(rename_sql)
328                    else:
329                        # For other alterations, execute directly.
330                        direct_alter_expr = exp.Alter(
331                            this=table_name_without_catalog.copy(), kind="TABLE", actions=[action]
332                        )
333                        self.execute(direct_alter_expr)

Adapter for Microsoft Fabric.

FabricEngineAdapter( connection_factory_or_pool: Union[Callable, Any], *args: Any, **kwargs: Any)
41    def __init__(
42        self, connection_factory_or_pool: t.Union[t.Callable, t.Any], *args: t.Any, **kwargs: t.Any
43    ) -> None:
44        # Wrap connection factory to support changing the catalog dynamically at runtime
45        if not isinstance(connection_factory_or_pool, ConnectionPool):
46            original_connection_factory = connection_factory_or_pool
47
48            connection_factory_or_pool = lambda *args, **kwargs: original_connection_factory(
49                target_catalog=self._target_catalog, *args, **kwargs
50            )
51
52        super().__init__(connection_factory_or_pool, *args, **kwargs)
DIALECT = 'fabric'
SUPPORTS_INDEXES = False
SUPPORTS_TRANSACTIONS = False
SUPPORTS_CREATE_DROP_CATALOG = True
INSERT_OVERWRITE_STRATEGY = <InsertOverwriteStrategy.DELETE_INSERT: 1>
COMMENT_CREATION_TABLE = <CommentCreationTable.UNSUPPORTED: 1>
COMMENT_CREATION_VIEW = <CommentCreationView.UNSUPPORTED: 1>
api_client: FabricHttpClient
 89    @property
 90    def api_client(self) -> FabricHttpClient:
 91        # the requests Session is not guaranteed to be threadsafe
 92        # so we create a http client per thread on demand
 93        if existing_client := self._connection_pool.get_attribute("api_client"):
 94            return existing_client
 95
 96        tenant_id: t.Optional[str] = self._extra_config.get("tenant_id")
 97        workspace_id: t.Optional[str] = self._extra_config.get("workspace_id")
 98        client_id: t.Optional[str] = self._extra_config.get("user")
 99        client_secret: t.Optional[str] = self._extra_config.get("password")
100
101        if not tenant_id or not client_id or not client_secret:
102            raise SQLMeshError(
103                "Service Principal authentication requires tenant_id, client_id, and client_secret "
104                "in the Fabric connection configuration"
105            )
106
107        if not workspace_id:
108            raise SQLMeshError(
109                "Fabric requires the workspace_id to be configured in the connection configuration to create / drop catalogs"
110            )
111
112        client = FabricHttpClient(
113            tenant_id=tenant_id,
114            workspace_id=workspace_id,
115            client_id=client_id,
116            client_secret=client_secret,
117        )
118
119        self._connection_pool.set_attribute("api_client", client)
120        return client
def get_current_catalog(self) -> Optional[str]:
149    def get_current_catalog(self) -> t.Optional[str]:
150        """Return the explicit Fabric catalog target for the current thread."""
151        return self._normalize_catalog(self._target_catalog)

Return the explicit Fabric catalog target for the current thread.

def set_current_catalog(self, catalog_name: Optional[str]) -> None:
153    def set_current_catalog(self, catalog_name: t.Optional[str]) -> None:
154        """
155        Set the current catalog for Microsoft Fabric connections.
156
157        Override to handle Fabric's stateless session limitation where USE statements
158        don't persist across queries. Instead, we close existing connections and
159        recreate them with the new catalog in the connection configuration.
160
161        Args:
162            catalog_name: The name of the catalog (warehouse) to switch to.
163                The configured default catalog is treated as the neutral state.
164
165        Note:
166            Fabric doesn't support catalog switching via USE statements because each
167            statement runs as an independent session. This method works around this
168            limitation by updating the connection pool with new catalog configuration.
169
170        See:
171            https://learn.microsoft.com/en-us/fabric/data-warehouse/sql-query-editor#limitations
172        """
173        target_catalog = self._normalize_catalog(catalog_name)
174        explicit_default_catalog = catalog_name is not None and target_catalog is None
175        connected_catalog = self._normalize_catalog(self._connected_catalog)
176
177        # An explicit request for the default catalog must also match the catalog
178        # used by the open connection. A lazy restore with None only updates the
179        # logical target and intentionally leaves that connection in place.
180        if self.get_current_catalog() == target_catalog and (
181            not explicit_default_catalog or connected_catalog is None
182        ):
183            logger.debug("Already using requested Fabric catalog state, no action needed")
184            return
185
186        # Decide whether the open connection needs to be replaced.
187        #
188        # The set_catalog decorator restores the previous catalog (often None)
189        # after every catalog-scoped call.  For Fabric, a connection close +
190        # reopen is expensive because each new connection goes through ODBC and
191        # the Fabric gateway.  We therefore apply lazy connection management:
192        #
193        #  * When restoring to neutral (target=None): just update _target_catalog.
194        #    The existing connection stays alive and will be reused or replaced
195        #    on the next real switch, avoiding a pointless bounce through the
196        #    default catalog.
197        #
198        #  * When switching to a non-neutral catalog: only close/reopen if the
199        #    open connection is already on a different catalog.  If a previous
200        #    restore-to-neutral left the connection on the right catalog, we
201        #    skip the close entirely.
202        needs_reconnect = (target_catalog is not None or explicit_default_catalog) and (
203            connected_catalog != target_catalog
204        )
205
206        if needs_reconnect:
207            logger.info(
208                "Switching connection from catalog '%s' to '%s'",
209                self._catalog_state_label(connected_catalog),
210                self._catalog_state_label(target_catalog),
211            )
212            # Commit before closing to avoid snapshot-isolation errors on
213            # subsequent queries in the new connection.
214            self._connection_pool.commit()
215            # note: close() on the pool (not self.close()) to only affect this
216            # thread's connection rather than all threads.
217            self._connection_pool.close()
218            self._connected_catalog = target_catalog
219        else:
220            logger.debug(
221                "Updating catalog target to '%s' (connection remains on '%s')",
222                self._catalog_state_label(target_catalog),
223                self._catalog_state_label(connected_catalog),
224            )
225
226        self._target_catalog = target_catalog

Set the current catalog for Microsoft Fabric connections.

Override to handle Fabric's stateless session limitation where USE statements don't persist across queries. Instead, we close existing connections and recreate them with the new catalog in the connection configuration.

Arguments:
  • catalog_name: The name of the catalog (warehouse) to switch to. The configured default catalog is treated as the neutral state.
Note:

Fabric doesn't support catalog switching via USE statements because each statement runs as an independent session. This method works around this limitation by updating the connection pool with new catalog configuration.

See:

https://learn.microsoft.com/en-us/fabric/data-warehouse/sql-query-editor#limitations

def alter_table( self, alter_expressions: Union[List[sqlglot.expressions.ddl.Alter], List[sqlmesh.core.schema_diff.TableAlterOperation]]) -> None:
228    def alter_table(
229        self, alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]]
230    ) -> None:
231        """
232        Applies alter expressions to a table. Fabric has limited support for ALTER TABLE,
233        so this method implements a workaround for column type changes.
234        This method is self-contained and sets its own catalog context.
235        """
236        if not alter_expressions:
237            return
238
239        # Get the target table from the first expression to determine the correct catalog.
240        first_op = alter_expressions[0]
241        expression = first_op.expression if isinstance(first_op, TableAlterOperation) else first_op
242        if not isinstance(expression, exp.Alter) or not expression.this.catalog:
243            # Fallback for unexpected scenarios
244            logger.warning(
245                "Could not determine catalog from alter expression, executing with current context."
246            )
247            super().alter_table(alter_expressions)
248            return
249
250        target_catalog = expression.this.catalog
251        self.set_current_catalog(target_catalog)
252
253        with self.transaction():
254            for op in alter_expressions:
255                expression = op.expression if isinstance(op, TableAlterOperation) else op
256
257                if not isinstance(expression, exp.Alter):
258                    self.execute(expression)
259                    continue
260
261                for action in expression.actions:
262                    table_name = expression.this
263
264                    table_name_without_catalog = table_name.copy()
265                    table_name_without_catalog.set("catalog", None)
266
267                    is_type_change = isinstance(action, exp.AlterColumn) and action.args.get(
268                        "dtype"
269                    )
270
271                    if is_type_change:
272                        column_to_alter = action.this
273                        new_type = action.args["dtype"]
274                        temp_column_name_str = f"{column_to_alter.name}__{random_id(short=True)}"
275                        temp_column_name = exp.to_identifier(temp_column_name_str)
276
277                        logger.info(
278                            "Applying workaround for column '%s' on table '%s' to change type to '%s'.",
279                            column_to_alter.sql(),
280                            table_name.sql(),
281                            new_type.sql(),
282                        )
283
284                        # Step 1: Add a temporary column.
285                        add_column_expr = exp.Alter(
286                            this=table_name_without_catalog.copy(),
287                            kind="TABLE",
288                            actions=[
289                                exp.ColumnDef(this=temp_column_name.copy(), kind=new_type.copy())
290                            ],
291                        )
292                        add_sql = self._to_sql(add_column_expr)
293                        self.execute(add_sql)
294
295                        # Step 2: Copy and cast data.
296                        update_sql = self._to_sql(
297                            exp.Update(
298                                this=table_name_without_catalog.copy(),
299                                expressions=[
300                                    exp.EQ(
301                                        this=temp_column_name.copy(),
302                                        expression=exp.Cast(
303                                            this=column_to_alter.copy(), to=new_type.copy()
304                                        ),
305                                    )
306                                ],
307                            )
308                        )
309                        self.execute(update_sql)
310
311                        # Step 3: Drop the original column.
312                        drop_sql = self._to_sql(
313                            exp.Alter(
314                                this=table_name_without_catalog.copy(),
315                                kind="TABLE",
316                                actions=[exp.Drop(this=column_to_alter.copy(), kind="COLUMN")],
317                            )
318                        )
319                        self.execute(drop_sql)
320
321                        # Step 4: Rename the temporary column.
322                        old_name_qualified = f"{table_name_without_catalog.sql(dialect=self.dialect)}.{temp_column_name.sql(dialect=self.dialect)}"
323                        new_name_unquoted = column_to_alter.sql(
324                            dialect=self.dialect, identify=False
325                        )
326                        rename_sql = f"EXEC sp_rename '{old_name_qualified}', '{new_name_unquoted}', 'COLUMN'"
327                        self.execute(rename_sql)
328                    else:
329                        # For other alterations, execute directly.
330                        direct_alter_expr = exp.Alter(
331                            this=table_name_without_catalog.copy(), kind="TABLE", actions=[action]
332                        )
333                        self.execute(direct_alter_expr)

Applies alter expressions to a table. Fabric has limited support for ALTER TABLE, so this method implements a workaround for column type changes. This method is self-contained and sets its own catalog context.

Inherited Members
sqlmesh.core.engine_adapter.mssql.MSSQLEngineAdapter
SUPPORTS_TUPLE_IN
SUPPORTS_MATERIALIZED_VIEWS
CURRENT_CATALOG_EXPRESSION
SUPPORTS_REPLACE_TABLE
MAX_IDENTIFIER_LENGTH
SUPPORTS_QUERY_EXECUTION_TRACKING
SCHEMA_DIFFER_KWARGS
VARIABLE_LENGTH_DATA_TYPES
catalog_support
columns
table_exists
drop_schema
merge
delete_from
sqlmesh.core.engine_adapter.mixins.RowDiffMixin
MAX_TIMESTAMP_PRECISION
concat_columns
normalize_value
sqlmesh.core.engine_adapter.base.EngineAdapter
DEFAULT_BATCH_SIZE
DATA_OBJECT_FILTER_BATCH_SIZE
MAX_TABLE_COMMENT_LENGTH
MAX_COLUMN_COMMENT_LENGTH
SUPPORTS_MATERIALIZED_VIEW_SCHEMA
SUPPORTS_VIEW_SCHEMA
SUPPORTS_CLONING
SUPPORTS_MANAGED_MODELS
SUPPORTED_DROP_CASCADE_OBJECT_KINDS
HAS_VIEW_BINDING
RECREATE_MATERIALIZED_VIEW_ON_EVALUATION
SUPPORTS_GRANTS
DEFAULT_CATALOG_TYPE
QUOTE_IDENTIFIERS_IN_VIEWS
ATTACH_CORRELATION_ID
SUPPORTS_METADATA_TABLE_LAST_MODIFIED_TS
RESOLVE_TABLE_REFS_IN_PHYSICAL_PROPERTIES
dialect
correlation_id
with_settings
cursor
connection
spark
snowpark
bigframe
comments_enabled
supports_virtual_catalog
inject_virtual_catalog
schema_differ
default_catalog
engine_run_mode
recycle
close
get_catalog_type
get_catalog_type_from_table
current_catalog_type
replace_query
create_index
create_table
create_managed_table
ctas
create_state_table
create_table_like
clone_table
drop_data_object
drop_table
drop_managed_table
get_alter_operations
create_view
create_schema
drop_view
create_catalog
drop_catalog
insert_append
insert_overwrite_by_partition
insert_overwrite_by_time_partition
update_table
scd_type_2_by_time
scd_type_2_by_column
rename_table
get_data_object
get_data_objects
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
class FabricHttpClient:
336class FabricHttpClient:
337    def __init__(self, tenant_id: str, workspace_id: str, client_id: str, client_secret: str):
338        self.tenant_id = tenant_id
339        self.client_id = client_id
340        self.client_secret = client_secret
341        self.workspace_id = workspace_id
342
343    def create_warehouse(
344        self, warehouse_name: str, if_not_exists: bool = True, attempt: int = 0
345    ) -> None:
346        """Create a catalog (warehouse) in Microsoft Fabric via REST API."""
347
348        # attempt count is arbitrary, it essentially equates to 5 minutes of 30 second waits
349        if attempt > 10:
350            raise SQLMeshError(
351                f"Gave up waiting for Fabric warehouse {warehouse_name} to become available"
352            )
353
354        logger.info(f"Creating Fabric warehouse: {warehouse_name}")
355
356        request_data = {
357            "displayName": warehouse_name,
358            "description": f"Warehouse created by SQLMesh: {warehouse_name}",
359        }
360
361        response = self.session.post(self._endpoint_url("warehouses"), json=request_data)
362
363        if (
364            if_not_exists
365            and response.status_code == 400
366            and (errorCode := response.json().get("errorCode", None))
367        ):
368            if errorCode == "ItemDisplayNameAlreadyInUse":
369                logger.warning(f"Fabric warehouse {warehouse_name} already exists")
370                return
371            if errorCode == "ItemDisplayNameNotAvailableYet":
372                logger.warning(f"Fabric warehouse {warehouse_name} is still spinning up; waiting")
373                # Fabric error message is something like:
374                #  - "Requested 'circleci_51d7087e__dev' is not available yet and is expected to become available in the upcoming minutes."
375                # This seems to happen if a catalog is dropped and then a new one with the same name is immediately created.
376                # There appears to be some delayed async process on the Fabric side that actually drops the warehouses and frees up the names to be used again
377                time.sleep(30)
378                return self.create_warehouse(
379                    warehouse_name=warehouse_name, if_not_exists=if_not_exists, attempt=attempt + 1
380                )
381
382        try:
383            response.raise_for_status()
384        except:
385            # the important information to actually debug anything is in the response body which Requests never prints
386            logger.exception(
387                f"Failed to create warehouse {warehouse_name}. status: {response.status_code}, body: {response.text}"
388            )
389            raise
390
391        # Handle direct success (201) or async creation (202)
392        if response.status_code == 201:
393            logger.info(f"Successfully created Fabric warehouse: {warehouse_name}")
394            return
395
396        if response.status_code == 202 and (location_header := response.headers.get("location")):
397            logger.info(f"Warehouse creation initiated for: {warehouse_name}")
398            self._wait_for_completion(location_header, warehouse_name)
399            logger.info(f"Successfully created Fabric warehouse: {warehouse_name}")
400        else:
401            logger.error(f"Unexpected response from Fabric API: {response}\n{response.text}")
402            raise SQLMeshError(f"Unable to create warehouse: {response}")
403
404    def delete_warehouse(self, warehouse_name: str, if_exists: bool = True) -> None:
405        """Drop a catalog (warehouse) in Microsoft Fabric via REST API."""
406        logger.info(f"Deleting Fabric warehouse: {warehouse_name}")
407
408        # Get the warehouse ID by listing warehouses
409        # TODO: handle continuationUri for pagination, ref: https://learn.microsoft.com/en-us/rest/api/fabric/warehouse/items/list-warehouses?tabs=HTTP#warehouses
410        response = self.session.get(self._endpoint_url("warehouses"))
411        response.raise_for_status()
412
413        warehouse_name_to_id = {
414            warehouse.get("displayName"): warehouse.get("id")
415            for warehouse in response.json().get("value", [])
416        }
417
418        warehouse_id = warehouse_name_to_id.get(warehouse_name, None)
419
420        if not warehouse_id:
421            logger.warning(
422                f"Fabric warehouse does not exist: {warehouse_name}\n(available warehouses: {', '.join(warehouse_name_to_id)})"
423            )
424            if if_exists:
425                return
426
427            raise SQLMeshError(
428                f"Unable to delete Fabric warehouse {warehouse_name} as it doesnt exist"
429            )
430
431        # Delete the warehouse by ID
432        response = self.session.delete(self._endpoint_url(f"warehouses/{warehouse_id}"))
433        response.raise_for_status()
434
435        logger.info(f"Successfully deleted Fabric warehouse: {warehouse_name}")
436
437    @cached_property
438    def session(self) -> requests.Session:
439        s = requests.Session()
440
441        access_token = self._get_access_token()
442        s.headers.update({"Authorization": f"Bearer {access_token}"})
443
444        return s
445
446    def _endpoint_url(self, endpoint: str) -> str:
447        if endpoint.startswith("/"):
448            endpoint = endpoint[1:]
449
450        return f"https://api.fabric.microsoft.com/v1/workspaces/{self.workspace_id}/{endpoint}"
451
452    def _get_access_token(self) -> str:
453        """Get access token using Service Principal authentication."""
454
455        # Use Azure AD OAuth2 token endpoint
456        token_url = f"https://login.microsoftonline.com/{self.tenant_id}/oauth2/v2.0/token"
457
458        data = {
459            "grant_type": "client_credentials",
460            "client_id": self.client_id,
461            "client_secret": self.client_secret,
462            "scope": "https://api.fabric.microsoft.com/.default",
463        }
464
465        response = requests.post(token_url, data=data)
466        response.raise_for_status()
467        token_data = response.json()
468        return token_data["access_token"]
469
470    def _wait_for_completion(self, location_url: str, operation_name: str) -> None:
471        """Poll the operation status until completion."""
472
473        @retry(
474            wait=wait_exponential(multiplier=1, min=1, max=30),
475            stop=stop_after_attempt(20),
476            retry=retry_if_result(lambda result: result not in ["Succeeded", "Failed"]),
477        )
478        def _poll() -> str:
479            response = self.session.get(location_url)
480            response.raise_for_status()
481
482            result = response.json()
483            status = result.get("status", "Unknown")
484
485            logger.debug(f"Operation {operation_name} status: {status}")
486
487            if status == "Failed":
488                error_msg = result.get("error", {}).get("message", "Unknown error")
489                raise SQLMeshError(f"Operation {operation_name} failed: {error_msg}")
490            elif status in ["InProgress", "Running"]:
491                logger.debug(f"Operation {operation_name} still in progress...")
492            elif status not in ["Succeeded"]:
493                logger.warning(f"Unknown status '{status}' for operation {operation_name}")
494
495            return status
496
497        final_status = _poll()
498        if final_status != "Succeeded":
499            raise SQLMeshError(f"Operation {operation_name} completed with status: {final_status}")
FabricHttpClient( tenant_id: str, workspace_id: str, client_id: str, client_secret: str)
337    def __init__(self, tenant_id: str, workspace_id: str, client_id: str, client_secret: str):
338        self.tenant_id = tenant_id
339        self.client_id = client_id
340        self.client_secret = client_secret
341        self.workspace_id = workspace_id
tenant_id
client_id
client_secret
workspace_id
def create_warehouse( self, warehouse_name: str, if_not_exists: bool = True, attempt: int = 0) -> None:
343    def create_warehouse(
344        self, warehouse_name: str, if_not_exists: bool = True, attempt: int = 0
345    ) -> None:
346        """Create a catalog (warehouse) in Microsoft Fabric via REST API."""
347
348        # attempt count is arbitrary, it essentially equates to 5 minutes of 30 second waits
349        if attempt > 10:
350            raise SQLMeshError(
351                f"Gave up waiting for Fabric warehouse {warehouse_name} to become available"
352            )
353
354        logger.info(f"Creating Fabric warehouse: {warehouse_name}")
355
356        request_data = {
357            "displayName": warehouse_name,
358            "description": f"Warehouse created by SQLMesh: {warehouse_name}",
359        }
360
361        response = self.session.post(self._endpoint_url("warehouses"), json=request_data)
362
363        if (
364            if_not_exists
365            and response.status_code == 400
366            and (errorCode := response.json().get("errorCode", None))
367        ):
368            if errorCode == "ItemDisplayNameAlreadyInUse":
369                logger.warning(f"Fabric warehouse {warehouse_name} already exists")
370                return
371            if errorCode == "ItemDisplayNameNotAvailableYet":
372                logger.warning(f"Fabric warehouse {warehouse_name} is still spinning up; waiting")
373                # Fabric error message is something like:
374                #  - "Requested 'circleci_51d7087e__dev' is not available yet and is expected to become available in the upcoming minutes."
375                # This seems to happen if a catalog is dropped and then a new one with the same name is immediately created.
376                # There appears to be some delayed async process on the Fabric side that actually drops the warehouses and frees up the names to be used again
377                time.sleep(30)
378                return self.create_warehouse(
379                    warehouse_name=warehouse_name, if_not_exists=if_not_exists, attempt=attempt + 1
380                )
381
382        try:
383            response.raise_for_status()
384        except:
385            # the important information to actually debug anything is in the response body which Requests never prints
386            logger.exception(
387                f"Failed to create warehouse {warehouse_name}. status: {response.status_code}, body: {response.text}"
388            )
389            raise
390
391        # Handle direct success (201) or async creation (202)
392        if response.status_code == 201:
393            logger.info(f"Successfully created Fabric warehouse: {warehouse_name}")
394            return
395
396        if response.status_code == 202 and (location_header := response.headers.get("location")):
397            logger.info(f"Warehouse creation initiated for: {warehouse_name}")
398            self._wait_for_completion(location_header, warehouse_name)
399            logger.info(f"Successfully created Fabric warehouse: {warehouse_name}")
400        else:
401            logger.error(f"Unexpected response from Fabric API: {response}\n{response.text}")
402            raise SQLMeshError(f"Unable to create warehouse: {response}")

Create a catalog (warehouse) in Microsoft Fabric via REST API.

def delete_warehouse(self, warehouse_name: str, if_exists: bool = True) -> None:
404    def delete_warehouse(self, warehouse_name: str, if_exists: bool = True) -> None:
405        """Drop a catalog (warehouse) in Microsoft Fabric via REST API."""
406        logger.info(f"Deleting Fabric warehouse: {warehouse_name}")
407
408        # Get the warehouse ID by listing warehouses
409        # TODO: handle continuationUri for pagination, ref: https://learn.microsoft.com/en-us/rest/api/fabric/warehouse/items/list-warehouses?tabs=HTTP#warehouses
410        response = self.session.get(self._endpoint_url("warehouses"))
411        response.raise_for_status()
412
413        warehouse_name_to_id = {
414            warehouse.get("displayName"): warehouse.get("id")
415            for warehouse in response.json().get("value", [])
416        }
417
418        warehouse_id = warehouse_name_to_id.get(warehouse_name, None)
419
420        if not warehouse_id:
421            logger.warning(
422                f"Fabric warehouse does not exist: {warehouse_name}\n(available warehouses: {', '.join(warehouse_name_to_id)})"
423            )
424            if if_exists:
425                return
426
427            raise SQLMeshError(
428                f"Unable to delete Fabric warehouse {warehouse_name} as it doesnt exist"
429            )
430
431        # Delete the warehouse by ID
432        response = self.session.delete(self._endpoint_url(f"warehouses/{warehouse_id}"))
433        response.raise_for_status()
434
435        logger.info(f"Successfully deleted Fabric warehouse: {warehouse_name}")

Drop a catalog (warehouse) in Microsoft Fabric via REST API.

session: requests.sessions.Session
437    @cached_property
438    def session(self) -> requests.Session:
439        s = requests.Session()
440
441        access_token = self._get_access_token()
442        s.headers.update({"Authorization": f"Bearer {access_token}"})
443
444        return s