Skip to content

Commit 70512c3

Browse files
author
evieira
committed
Merge remote-tracking branch 'upstream/main' into local-lint-option
2 parents 9aa3543 + 97d60cb commit 70512c3

28 files changed

Lines changed: 885 additions & 78 deletions

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,8 @@ install-dev-dbt-%:
6363
fi; \
6464
if [ "$$version" = "1.3.0" ]; then \
6565
echo "Applying overrides for dbt $$version - upgrading google-cloud-bigquery"; \
66-
$(PIP) install 'google-cloud-bigquery>=3.0.0' --upgrade; \
66+
$(PIP) install 'google-cloud-bigquery>=3.0.0' \
67+
'pyOpenSSL>=24.0.0' --upgrade; \
6768
fi; \
6869
mv pyproject.toml.backup pyproject.toml; \
6970
echo "Restored original pyproject.toml"

docs/integrations/engines/redshift.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pip install "sqlmesh[redshift]"
2929
| `region` | The AWS region of the Amazon Redshift cluster | string | N |
3030
| `cluster_identifier` | The cluster identifier of the Amazon Redshift cluster | string | N |
3131
| `iam` | If IAM authentication is enabled. IAM must be True when authenticating using an IdP | dict | N |
32+
| `db_user` | The database user to authenticate as. Required when using IAM authentication | string | N |
3233
| `is_serverless` | If the Amazon Redshift cluster is serverless (Default: `False`) | bool | N |
3334
| `serverless_acct_id` | The account ID of the serverless cluster | string | N |
3435
| `serverless_work_group` | The name of work group for serverless end point | string | N |

docs/prerequisites.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ This page describes the system prerequisites needed to run SQLMesh and provides
44

55
## SQLMesh prerequisites
66

7-
You'll need Python 3.8 or higher to use SQLMesh. You can check your python version by running the following command:
7+
You'll need Python 3.9 or higher to use SQLMesh. You can check your python version by running the following command:
88
```bash
99
python3 --version
1010
```

pyproject.toml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@ authors = [{ name = "SQLMesh Contributors" }]
77
license = { file = "LICENSE" }
88
requires-python = ">= 3.9"
99
dependencies = [
10-
"astor",
1110
"click",
1211
"croniter",
1312
"duckdb>=0.10.0,!=0.10.3",
@@ -202,7 +201,6 @@ disable_error_code = "annotation-unchecked"
202201
[[tool.mypy.overrides]]
203202
module = [
204203
"api.*",
205-
"astor.*",
206204
"IPython.*",
207205
"hyperscript.*",
208206
"py.*",

sqlmesh/core/config/connection.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@
1313
from sys import version_info
1414

1515
import pydantic
16+
from pydantic import Field, computed_field
1617
from packaging import version
17-
from pydantic import Field
1818
from pydantic_core import from_json
1919
from sqlglot import exp
2020
from sqlglot.errors import ParseError
@@ -110,7 +110,14 @@ class ConnectionConfig(abc.ABC, BaseConfig):
110110
catalog_type_overrides: t.Optional[t.Dict[str, str]] = None
111111

112112
# Whether to share a single connection across threads or create a new connection per thread.
113-
shared_connection: t.ClassVar[bool] = False
113+
#
114+
# MyPy throws a "Decorators on top of @property are not supported" error despite this being a
115+
# valid decoration, and Pydantic recommend disabling the MyPy hint for this reason - see:
116+
# https://pydantic.dev/docs/validation/2.0/usage/computed_fields/
117+
@computed_field # type: ignore[prop-decorator]
118+
@property
119+
def shared_connection(self) -> bool:
120+
return False
114121

115122
@property
116123
@abc.abstractmethod
@@ -311,7 +318,10 @@ class BaseDuckDBConnectionConfig(ConnectionConfig):
311318

312319
token: t.Optional[str] = None
313320

314-
shared_connection: t.ClassVar[bool] = True
321+
@computed_field # type: ignore[prop-decorator]
322+
@property
323+
def shared_connection(self) -> bool:
324+
return True
315325

316326
_data_file_to_adapter: t.ClassVar[t.Dict[str, EngineAdapter]] = {}
317327

@@ -820,11 +830,15 @@ class DatabricksConnectionConfig(ConnectionConfig):
820830
DISPLAY_NAME: t.ClassVar[t.Literal["Databricks"]] = "Databricks"
821831
DISPLAY_ORDER: t.ClassVar[t.Literal[3]] = 3
822832

823-
shared_connection: t.ClassVar[bool] = True
824-
825833
_concurrent_tasks_validator = concurrent_tasks_validator
826834
_http_headers_validator = http_headers_validator
827835

836+
@computed_field # type: ignore[prop-decorator]
837+
@property
838+
def shared_connection(self) -> bool:
839+
"""The connection should only be shared if U2M OAuth is being used"""
840+
return self.auth_type is not None and self.oauth_client_secret is None
841+
828842
@model_validator(mode="before")
829843
def _databricks_connect_validator(cls, data: t.Any) -> t.Any:
830844
# SQLQueryContextLogger will output any error SQL queries even if they are in a try/except block.
@@ -1321,6 +1335,7 @@ class RedshiftConnectionConfig(ConnectionConfig):
13211335
region: The AWS region where the Amazon Redshift cluster is located.
13221336
cluster_identifier: The cluster identifier of the Amazon Redshift cluster.
13231337
iam: If IAM authentication is enabled. Default value is False. IAM must be True when authenticating using an IdP.
1338+
db_user: The database user to authenticate as. Required when using IAM authentication.
13241339
is_serverless: Redshift end-point is serverless or provisional. Default value false.
13251340
serverless_acct_id: The account ID of the serverless. Default value None
13261341
serverless_work_group: The name of work group for serverless end point. Default value None.
@@ -1346,6 +1361,7 @@ class RedshiftConnectionConfig(ConnectionConfig):
13461361
region: t.Optional[str] = None
13471362
cluster_identifier: t.Optional[str] = None
13481363
iam: t.Optional[bool] = None
1364+
db_user: t.Optional[str] = None
13491365
is_serverless: t.Optional[bool] = None
13501366
serverless_acct_id: t.Optional[str] = None
13511367
serverless_work_group: t.Optional[str] = None
@@ -1383,6 +1399,7 @@ def _connection_kwargs_keys(self) -> t.Set[str]:
13831399
"region",
13841400
"cluster_identifier",
13851401
"iam",
1402+
"db_user",
13861403
"is_serverless",
13871404
"serverless_acct_id",
13881405
"serverless_work_group",

sqlmesh/core/config/loader.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -272,4 +272,4 @@ def convert_config_type(
272272
config_obj: Config,
273273
config_type: t.Type[C],
274274
) -> C:
275-
return config_type.parse_obj(config_obj.dict())
275+
return config_type.parse_obj(config_obj.dict(exclude_computed_fields=True))

sqlmesh/core/engine_adapter/databricks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ def _set_spark_engine_adapter_if_needed(self) -> None:
154154
host=self._extra_config["databricks_connect_server_hostname"],
155155
token=self._extra_config.get("databricks_connect_access_token"),
156156
)
157-
if "databricks_connect_use_serverless" in self._extra_config:
157+
if self._extra_config.get("databricks_connect_use_serverless"):
158158
connect_kwargs["serverless"] = True
159159
else:
160160
connect_kwargs["cluster_id"] = self._extra_config["databricks_connect_cluster_id"]

sqlmesh/core/engine_adapter/fabric.py

Lines changed: 95 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,33 @@ def _target_catalog(self) -> t.Optional[str]:
5858
def _target_catalog(self, value: t.Optional[str]) -> None:
5959
self._connection_pool.set_attribute("target_catalog", value)
6060

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+
6188
@property
6289
def api_client(self) -> FabricHttpClient:
6390
# the requests Session is not guaranteed to be threadsafe
@@ -101,20 +128,28 @@ def _create_catalog(self, catalog_name: exp.Identifier) -> None:
101128
def _drop_catalog(self, catalog_name: exp.Identifier) -> None:
102129
"""Drop a catalog (warehouse) in Microsoft Fabric via REST API."""
103130
warehouse_name = catalog_name.sql(dialect=self.dialect, identify=False)
104-
current_catalog = self.get_current_catalog()
105131

106132
logger.info(f"Deleting Fabric warehouse: {warehouse_name}")
107133
self.api_client.delete_warehouse(warehouse_name)
108134

109-
if warehouse_name == current_catalog:
110-
# Somewhere around 2025-09-08, Fabric started validating the "Database=" connection argument and throwing 'Authentication failed' if the database doesnt exist
111-
# In addition, set_current_catalog() is implemented using a threadlocal variable "target_catalog"
112-
# So, when we drop a warehouse, and there are still threads with "target_catalog" set to reference it, any operations on those threads
113-
# that use an either use an existing connection pointing to this warehouse or trigger a new connection
114-
# will fail with an 'Authentication Failed' error unless we close all connections here, which also clears all the threadlocal data
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:
115146
self.close()
116147

117-
def set_current_catalog(self, catalog_name: str) -> None:
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:
118153
"""
119154
Set the current catalog for Microsoft Fabric connections.
120155
@@ -123,7 +158,8 @@ def set_current_catalog(self, catalog_name: str) -> None:
123158
recreate them with the new catalog in the connection configuration.
124159
125160
Args:
126-
catalog_name: The name of the catalog (warehouse) to switch to
161+
catalog_name: The name of the catalog (warehouse) to switch to.
162+
The configured default catalog is treated as the neutral state.
127163
128164
Note:
129165
Fabric doesn't support catalog switching via USE statements because each
@@ -133,33 +169,60 @@ def set_current_catalog(self, catalog_name: str) -> None:
133169
See:
134170
https://learn.microsoft.com/en-us/fabric/data-warehouse/sql-query-editor#limitations
135171
"""
136-
current_catalog = self.get_current_catalog()
137-
138-
# If already using the requested catalog, do nothing
139-
if current_catalog and current_catalog == catalog_name:
140-
logger.debug(f"Already using catalog '{catalog_name}', no action needed")
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")
141183
return
142184

143-
logger.info(f"Switching from catalog '{current_catalog}' to '{catalog_name}'")
144-
145-
# commit the transaction before closing the connection to help prevent errors like:
146-
# > Snapshot isolation transaction failed in database because the object accessed by the statement has been modified by a
147-
# > DDL statement in another concurrent transaction since the start of this transaction
148-
# on subsequent queries in the new connection
149-
self._connection_pool.commit()
150-
151-
# note: we call close() on the connection pool instead of self.close() because self.close() calls close_all()
152-
# on the connection pool but we just want to close the connection for this thread
153-
self._connection_pool.close()
154-
self._target_catalog = catalog_name # new connections will use this catalog
155-
156-
catalog_after_switch = self.get_current_catalog()
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+
)
157204

158-
if catalog_after_switch != catalog_name:
159-
# We need to raise an error if the catalog switch failed to prevent the operation that needed the catalog switch from being run against the wrong catalog
160-
raise SQLMeshError(
161-
f"Unable to switch catalog to {catalog_name}, catalog ended up as {catalog_after_switch}"
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),
162210
)
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
163226

164227
def alter_table(
165228
self, alter_expressions: t.Union[t.List[exp.Alter], t.List[TableAlterOperation]]

0 commit comments

Comments
 (0)