Skip to content

Commit 5bbdb07

Browse files
Merge remote-tracking branch 'origin/main' into fix/surrogate-key-sha2-hex
2 parents 83507b8 + 417573f commit 5bbdb07

10 files changed

Lines changed: 523 additions & 41 deletions

File tree

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"

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]]

sqlmesh/core/engine_adapter/mysql.py

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import logging
44
import typing as t
5-
65
from sqlglot import exp, parse_one
76

87
from sqlmesh.core.dialect import to_schema
@@ -22,6 +21,7 @@
2221

2322
if t.TYPE_CHECKING:
2423
from sqlmesh.core._typing import SchemaName, TableName
24+
from sqlmesh.core.engine_adapter._typing import QueryOrDF
2525

2626
logger = logging.getLogger(__name__)
2727

@@ -186,5 +186,88 @@ def _create_table_like(
186186
)
187187
)
188188

189+
def _replace_by_key(
190+
self,
191+
target_table: TableName,
192+
source_table: QueryOrDF,
193+
target_columns_to_types: t.Optional[t.Dict[str, exp.DataType]],
194+
key: t.Sequence[exp.Expr],
195+
is_unique_key: bool,
196+
source_columns: t.Optional[t.List[str]] = None,
197+
) -> None:
198+
if len(key) <= 1:
199+
return super()._replace_by_key(
200+
target_table,
201+
source_table,
202+
target_columns_to_types,
203+
key,
204+
is_unique_key,
205+
source_columns,
206+
)
207+
208+
if target_columns_to_types is None:
209+
target_columns_to_types = self.columns(target_table)
210+
211+
temp_table = self._get_temp_table(target_table)
212+
column_names = list(target_columns_to_types or [])
213+
214+
target_alias = "_target"
215+
temp_alias = "_temp"
216+
217+
with self.transaction():
218+
self.ctas(
219+
temp_table,
220+
source_table,
221+
target_columns_to_types=target_columns_to_types,
222+
exists=False,
223+
source_columns=source_columns,
224+
)
225+
226+
try:
227+
# Build a JOIN-based DELETE instead of using CONCAT_WS.
228+
# CONCAT_WS prevents MySQL/MariaDB from using indexes, causing full table scans.
229+
on_condition = exp.and_(
230+
*[
231+
self._qualify_columns(k, target_alias).eq(
232+
self._qualify_columns(k, temp_alias)
233+
)
234+
for k in key
235+
]
236+
)
237+
238+
target_table_aliased = exp.to_table(target_table).as_(target_alias, quoted=True)
239+
temp_table_aliased = exp.to_table(temp_table).as_(temp_alias, quoted=True)
240+
241+
join = exp.Join(this=temp_table_aliased, kind="INNER", on=on_condition)
242+
target_table_aliased.append("joins", join)
243+
244+
delete_stmt = exp.Delete(
245+
tables=[exp.to_table(target_alias)],
246+
this=target_table_aliased,
247+
)
248+
self.execute(delete_stmt)
249+
250+
insert_query = self._select_columns(target_columns_to_types).from_(temp_table)
251+
if is_unique_key:
252+
insert_query = insert_query.distinct(*key)
253+
254+
insert_statement = exp.insert(
255+
insert_query,
256+
target_table,
257+
columns=column_names,
258+
)
259+
self.execute(insert_statement, track_rows_processed=True)
260+
finally:
261+
self.drop_table(temp_table)
262+
263+
@staticmethod
264+
def _qualify_columns(expr: exp.Expr, table_alias: str) -> exp.Expr:
265+
"""Qualify unqualified column references in an expression with a table alias."""
266+
expr = expr.copy()
267+
for col in expr.find_all(exp.Column):
268+
if not col.table:
269+
col.set("table", exp.to_identifier(table_alias, quoted=True))
270+
return expr
271+
189272
def ping(self) -> None:
190273
self._connection_pool.get().ping(reconnect=False)

sqlmesh/core/model/definition.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1712,6 +1712,7 @@ def render(
17121712

17131713
def render_seed(self) -> t.Iterator[QueryOrDF]:
17141714
import numpy as np
1715+
import pandas as pd
17151716

17161717
self._ensure_hydrated()
17171718

@@ -1752,8 +1753,6 @@ def render_seed(self) -> t.Iterator[QueryOrDF]:
17521753

17531754
# convert all date/time types to native pandas timestamp
17541755
for column in [*date_columns, *datetime_columns]:
1755-
import pandas as pd
1756-
17571756
df[column] = pd.to_datetime(df[column], infer_datetime_format=True, errors="ignore") # type: ignore
17581757

17591758
# extract datetime.date from pandas timestamp for DATE columns
@@ -1769,7 +1768,7 @@ def render_seed(self) -> t.Iterator[QueryOrDF]:
17691768
)
17701769

17711770
for column in bool_columns:
1772-
df[column] = df[column].apply(lambda i: str_to_bool(str(i)))
1771+
df[column] = df[column].apply(lambda i: None if pd.isna(i) else str_to_bool(str(i)))
17731772

17741773
df.loc[:, string_columns] = df[string_columns].mask(
17751774
cond=lambda x: x.notna(), # type: ignore

sqlmesh/integrations/dlt.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,7 @@ def generate_dlt_models_and_settings(
6363
if db_type == "filesystem":
6464
connection_config = None
6565
else:
66-
if dlt.__version__ >= "1.10.0":
67-
client = pipeline.destination_client()
68-
else:
69-
client = pipeline._sql_job_client(schema) # type: ignore
66+
client = pipeline.destination_client()
7067
config = client.config
7168
credentials = config.credentials
7269
configs = {

sqlmesh/utils/date.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,8 @@ def make_exclusive(time: TimeLike) -> datetime:
344344

345345

346346
def make_ts_exclusive(time: TimeLike, dialect: DialectType) -> datetime:
347+
import pandas as pd
348+
347349
ts = to_datetime(time)
348350
if dialect == "tsql":
349351
return to_utc_timestamp(ts) - pd.Timedelta(1, unit="ns")

0 commit comments

Comments
 (0)