Skip to content

Commit 7baad34

Browse files
authored
fix: repair filter providers and adapter regressions (#476)
## Summary Correct various bugs and errors: | Commit | Area | GitHub issue | Targeted validation | | --- | --- | --- | --- | | `edc061c3` | Postgres data-dictionary coverage and `array_agg(a.attname)::text[]` compatibility | #361 | 15 passed | | `3735b782` | ADBC SQLite dialect fallback and transaction begin skip | #472 | 15 passed | | `ef5fe3dc` | Compiled Litestar filter provider annotation construction | #475 | focused Litestar provider tests, compiled-module validation, `make lint`, `make type-check` | | `3a07d135` | Spanner `provide_session()` write-by-default plus `provide_read_session()` | #474 | 27 passed | | `6acdddb7` | BigQuery emulator `CLUSTER BY` skip plus `job_timeout_ms` wiring | #473 | 8 passed | | `db1caab5` | Drop local pytest-databases 0.18 RustFS/pgvector workarounds | local cleanup | RustFS storage bridge and pgvector focused tests | ## Details - Fixes Postgres index metadata decoding across asyncpg, psycopg, and psqlpy by casting the aggregated column names to `text[]`. - Makes ADBC SQLite connection handling use a safe dialect fallback and avoid unsupported explicit transaction starts. - Repairs compiled Litestar filter providers without excluding provider modules from MyPyC. - Makes Spanner sessions write-capable by default and adds an explicit read-session provider for read-only call sites. - Keeps BigQuery emulator DDL compatible by omitting unsupported `CLUSTER BY` while wiring the declared `job_timeout_ms` parameter through to live query jobs. - Raises the pytest-databases floor to `>=0.19.0`, registers RustFS through the upstream pytest plugin, and removes now-redundant local RustFS credential and pgvector service overrides. ## Validation - Targeted chapter tests listed above. - MyPy clean on each adapter touched. - `make lint` - `make type-check` - Focused compiled Litestar provider validation. - RustFS storage bridge focused test. - psycopg pgvector focused test. Full suite was not run locally. Closes #361 Closes #472 Closes #473 Closes #474 Closes #475
1 parent 6f91042 commit 7baad34

27 files changed

Lines changed: 667 additions & 239 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ maintainers = [{ name = "Litestar Developers", email = "hello@litestar.dev" }]
2424
name = "sqlspec"
2525
readme = "README.md"
2626
requires-python = ">=3.10, <4.0"
27-
version = "0.47.0"
27+
version = "0.48.0"
2828

2929
[project.urls]
3030
Discord = "https://discord.gg/litestar"
@@ -148,7 +148,7 @@ test = [
148148
"pytest>=8.0.0",
149149

150150
"pytest-cov>=5.0.0",
151-
"pytest-databases[postgres,oracle,bigquery,spanner]>=0.18.0",
151+
"pytest-databases[postgres,oracle,bigquery,spanner]>=0.19.0",
152152
"pytest-mock>=3.14.0",
153153
"pytest-sugar>=1.0.0",
154154
"pytest-xdist>=3.6.1",
@@ -192,6 +192,7 @@ packages = ["sqlspec"]
192192
# Keep that smoke build from auto-discovering ignored scratch directories.
193193
packages = []
194194

195+
195196
[tool.hatch.build.targets.wheel.hooks.mypyc]
196197
dependencies = ["hatch-mypyc", "hatch-cython", "mypy>=2.0.0"]
197198
enable-by-default = false
@@ -291,7 +292,7 @@ opt_level = "3" # Maximum optimization (0-3)
291292
allow_dirty = true
292293
commit = false
293294
commit_args = "--no-verify"
294-
current_version = "0.47.0"
295+
current_version = "0.48.0"
295296
ignore_missing_files = false
296297
ignore_missing_version = false
297298
message = "chore(release): bump to v{new_version}"

sqlspec/adapters/adbc/_typing.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,11 +86,16 @@ def __init__(
8686
self._driver: AdbcDriver | None = None
8787

8888
def __enter__(self) -> "AdbcDriver":
89+
from sqlspec.adapters.adbc.core import resolve_dialect_name
8990
from sqlspec.adapters.adbc.driver import AdbcDriver
9091

9192
self._connection = self._acquire_connection()
93+
resolved_dialect = resolve_dialect_name(self._statement_config.dialect) or None
9294
self._driver = AdbcDriver(
93-
connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features
95+
connection=self._connection,
96+
statement_config=self._statement_config,
97+
driver_features=self._driver_features,
98+
dialect=resolved_dialect,
9499
)
95100
return self._prepare_driver(self._driver)
96101

sqlspec/adapters/adbc/core.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,12 +157,15 @@
157157
_TYPE_COERCION_DISPATCHERS: "dict[tuple[tuple[type, Callable[[Any], Any]], ...], TypeDispatcher[Callable[[Any], Any]]]" = {}
158158

159159

160-
def detect_dialect(connection: Any, logger: Any | None = None) -> str:
160+
def detect_dialect(connection: Any, logger: Any | None = None, *, fallback_dialect: "str | None" = None) -> str:
161161
"""Detect database dialect from ADBC driver information.
162162
163163
Args:
164164
connection: ADBC connection with driver metadata.
165165
logger: Optional logger for diagnostics.
166+
fallback_dialect: Pre-resolved dialect from config (e.g. from
167+
``resolve_dialect_from_config``). Used when introspection
168+
yields no match, before defaulting to ``postgres``.
166169
167170
Returns:
168171
Detected dialect name, defaulting to ``postgres``.
@@ -182,6 +185,11 @@ def detect_dialect(connection: Any, logger: Any | None = None) -> str:
182185
if logger is not None:
183186
logger.debug("Dialect detection failed: %s", exc)
184187

188+
if fallback_dialect:
189+
if logger is not None:
190+
logger.debug("Dialect from connection_config: %s (introspection yielded no match)", fallback_dialect)
191+
return fallback_dialect
192+
185193
if logger is not None:
186194
logger.warning("Could not determine dialect from driver info. Defaulting to 'postgres'.")
187195
return "postgres"

sqlspec/adapters/adbc/driver.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,8 +89,10 @@ def __init__(
8989
connection: "AdbcConnection",
9090
statement_config: "StatementConfig | None" = None,
9191
driver_features: "dict[str, Any] | None" = None,
92+
*,
93+
dialect: "str | None" = None,
9294
) -> None:
93-
self._detected_dialect = detect_dialect(connection, logger)
95+
self._detected_dialect = detect_dialect(connection, logger, fallback_dialect=dialect)
9496

9597
if statement_config is None:
9698
base_config = get_statement_config(self._detected_dialect)
@@ -256,7 +258,15 @@ def dispatch_execute_script(self, cursor: "AdbcRawCursor", statement: "SQL") ->
256258
# ─────────────────────────────────────────────────────────────────────────────
257259

258260
def begin(self) -> None:
259-
"""Begin database transaction."""
261+
"""Begin database transaction.
262+
263+
ADBC SQLite holds an implicit transaction after the first DML and
264+
rejects an explicit ``BEGIN`` with "cannot start a transaction
265+
within a transaction." On that dialect this is a no-op; the
266+
implicit transaction is closed by ``commit()`` / ``rollback()``.
267+
"""
268+
if self._dialect_name == "sqlite":
269+
return
260270
try:
261271
with self.with_cursor(self.connection) as cursor:
262272
cursor.execute("BEGIN")

sqlspec/adapters/bigquery/config.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,10 @@ def _setup_default_job_config(self) -> None:
263263
if query_timeout_ms is not None:
264264
job_config.job_timeout_ms = query_timeout_ms
265265

266+
job_timeout_ms = self.connection_config.get("job_timeout_ms")
267+
if job_timeout_ms is not None:
268+
job_config.job_timeout_ms = job_timeout_ms
269+
266270
self.connection_config["default_query_job_config"] = job_config
267271

268272
def create_connection(self) -> BigQueryConnection:

sqlspec/adapters/bigquery/core.py

Lines changed: 1 addition & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@
33
import datetime
44
import importlib
55
import io
6-
import os
76
from decimal import Decimal
87
from typing import TYPE_CHECKING, Any, cast
98

@@ -57,7 +56,6 @@
5756
"create_mapped_exception",
5857
"create_parameters",
5958
"default_statement_config",
60-
"detect_emulator",
6159
"driver_profile",
6260
"extract_insert_table",
6361
"is_simple_insert",
@@ -335,27 +333,6 @@ def _inline_bigquery_literals(
335333
return str(transformed_expression.sql(dialect="bigquery"))
336334

337335

338-
def detect_emulator(connection: "BigQueryConnection") -> bool:
339-
"""Detect whether the BigQuery client targets an emulator endpoint."""
340-
emulator_host = os.getenv("BIGQUERY_EMULATOR_HOST") or os.getenv("BIGQUERY_EMULATOR_HOST_HTTP")
341-
if emulator_host:
342-
return True
343-
344-
try:
345-
inner_connection = cast("Any", connection)._connection
346-
except AttributeError:
347-
inner_connection = None
348-
if inner_connection is None:
349-
return False
350-
try:
351-
api_base_url = inner_connection.API_BASE_URL
352-
except AttributeError:
353-
api_base_url = ""
354-
if not api_base_url:
355-
return False
356-
return "googleapis.com" not in api_base_url
357-
358-
359336
def _should_retry_bigquery_job(exception: Exception) -> bool:
360337
"""Return True when a BigQuery job exception is safe to retry."""
361338
if not isinstance(exception, GoogleCloudError):
@@ -381,10 +358,8 @@ def _should_retry_bigquery_job(exception: Exception) -> bool:
381358
return False
382359

383360

384-
def build_retry(deadline: float, using_emulator: bool) -> "Retry | None":
361+
def build_retry(deadline: float) -> "Retry":
385362
"""Build retry policy for job restarts based on error reason codes."""
386-
if using_emulator:
387-
return None
388363
return Retry(predicate=_should_retry_bigquery_job, deadline=deadline)
389364

390365

sqlspec/adapters/bigquery/driver.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,6 @@
2020
collect_rows,
2121
create_mapped_exception,
2222
default_statement_config,
23-
detect_emulator,
2423
driver_profile,
2524
is_simple_insert,
2625
normalize_script_rowcount,
@@ -103,7 +102,6 @@ class BigQueryDriver(SyncDriverAdapterBase):
103102
"_json_serializer",
104103
"_literal_inliner",
105104
"_type_converter",
106-
"_using_emulator",
107105
)
108106
dialect = "bigquery"
109107

@@ -132,9 +130,8 @@ def __init__(
132130
self._default_query_job_config: QueryJobConfig | None = (driver_features or {}).get("default_query_job_config")
133131
self._data_dictionary: BigQueryDataDictionary | None = None
134132
self._column_name_cache: dict[int, tuple[Any, list[str]]] = {}
135-
self._using_emulator = detect_emulator(connection)
136133
self._job_retry_deadline = float(features.get("job_retry_deadline", 60.0))
137-
self._job_retry = build_retry(self._job_retry_deadline, self._using_emulator)
134+
self._job_retry = build_retry(self._job_retry_deadline)
138135

139136
# ─────────────────────────────────────────────────────────────────────────────
140137
# CORE DISPATCH METHODS

sqlspec/adapters/spanner/config.py

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,14 @@
2929

3030
__all__ = ("SpannerConnectionParams", "SpannerDriverFeatures", "SpannerPoolParams", "SpannerSyncConfig")
3131

32+
_DEFAULT_SESSION_TRANSACTION: bool = True
33+
"""Default ``transaction`` flag for ``provide_session`` / ``provide_connection``.
34+
35+
``True`` yields a write-capable :class:`Transaction` context matching every
36+
other sqlspec adapter. Read-only :class:`Snapshot` contexts are available via
37+
:meth:`SpannerSyncConfig.provide_read_session`. Pulled into a module-level
38+
constant so an eventual ``SpannerAsyncConfig`` can import the same default."""
39+
3240

3341
class SpannerConnectionParams(TypedDict):
3442
"""Spanner connection parameters."""
@@ -278,27 +286,38 @@ def _close_pool(self) -> None:
278286
self._client = None
279287
self._database = None
280288

281-
def provide_connection(self, *args: Any, transaction: "bool" = False, **kwargs: Any) -> "SpannerConnectionContext":
282-
"""Yield a Snapshot (default) or Transaction context from the configured pool.
289+
def provide_connection(
290+
self, *args: Any, transaction: "bool" = _DEFAULT_SESSION_TRANSACTION, **kwargs: Any
291+
) -> "SpannerConnectionContext":
292+
"""Yield a Transaction (default) or Snapshot context from the configured pool.
283293
284294
Args:
285295
*args: Additional positional arguments (unused, for interface compatibility).
286-
transaction: If True, yields a Transaction context that supports
287-
execute_update() for DML statements. If False (default), yields
296+
transaction: If True (default), yields a Transaction context that
297+
supports execute_update() for DML statements. If False, yields
288298
a read-only Snapshot context for SELECT queries.
289299
**kwargs: Additional keyword arguments (unused, for interface compatibility).
290300
"""
291301
return SpannerConnectionContext(self, transaction=transaction)
292302

293303
def provide_session(
294-
self, *args: Any, statement_config: "StatementConfig | None" = None, transaction: "bool" = False, **kwargs: Any
304+
self,
305+
*args: Any,
306+
statement_config: "StatementConfig | None" = None,
307+
transaction: "bool" = _DEFAULT_SESSION_TRANSACTION,
308+
**kwargs: Any,
295309
) -> "SpannerSessionContext":
296310
"""Provide a Spanner driver session context manager.
297311
312+
Returns a write-capable Transaction session by default, matching every
313+
other sqlspec adapter. Pass ``transaction=False`` or use
314+
:meth:`provide_read_session` to obtain a read-only Snapshot session.
315+
298316
Args:
299317
*args: Additional arguments.
300318
statement_config: Optional statement configuration override.
301-
transaction: Whether to use a transaction.
319+
transaction: Whether to use a Transaction (True, default) or
320+
Snapshot (False).
302321
**kwargs: Additional keyword arguments.
303322
304323
Returns:
@@ -318,17 +337,18 @@ def provide_session(
318337
def provide_write_session(
319338
self, *args: Any, statement_config: "StatementConfig | None" = None, **kwargs: Any
320339
) -> "SpannerSessionContext":
321-
"""Provide a Spanner driver write session context manager.
340+
"""Provide a write-capable Spanner session (alias for :meth:`provide_session`)."""
341+
return self.provide_session(*args, statement_config=statement_config, transaction=True, **kwargs)
322342

323-
Args:
324-
*args: Additional arguments.
325-
statement_config: Optional statement configuration override.
326-
**kwargs: Additional keyword arguments.
343+
def provide_read_session(
344+
self, *args: Any, statement_config: "StatementConfig | None" = None, **kwargs: Any
345+
) -> "SpannerSessionContext":
346+
"""Provide a read-only Snapshot Spanner session.
327347
328-
Returns:
329-
A Spanner driver write session context manager.
348+
Use for query workloads that benefit from Spanner's snapshot reads.
349+
For DDL/DML, use :meth:`provide_session` (write-capable by default).
330350
"""
331-
return self.provide_session(*args, statement_config=statement_config, transaction=True, **kwargs)
351+
return self.provide_session(*args, statement_config=statement_config, transaction=False, **kwargs)
332352

333353
def get_signature_namespace(self) -> "dict[str, Any]":
334354
"""Get the signature namespace for SpannerSyncConfig types.

sqlspec/adapters/spanner/driver.py

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,12 @@
2727
from sqlspec.exceptions import SQLConversionError
2828
from sqlspec.utils.serializers import from_json
2929

30+
_READ_ONLY_SNAPSHOT_ERROR_MESSAGE = (
31+
"Cannot execute DML in a read-only Snapshot context. "
32+
"SpannerSyncConfig.provide_session() opens a write-capable Transaction by default; "
33+
"the current session must have been opened via SpannerSyncConfig.provide_read_session()."
34+
)
35+
3036
if TYPE_CHECKING:
3137
from collections.abc import Callable
3238

@@ -160,8 +166,7 @@ def dispatch_execute(self, cursor: "SpannerConnection", statement: "SQL") -> Exe
160166
row_count = writer.execute_update(sql, params=coerced_params, param_types=param_types_map)
161167
return self.create_execution_result(cursor, rowcount_override=row_count)
162168

163-
msg = "Cannot execute DML in a read-only Snapshot context."
164-
raise SQLConversionError(msg)
169+
raise SQLConversionError(_READ_ONLY_SNAPSHOT_ERROR_MESSAGE)
165170

166171
def dispatch_execute_many(self, cursor: "SpannerConnection", statement: "SQL") -> ExecutionResult:
167172
if not supports_batch_update(cursor):
@@ -210,8 +215,7 @@ def dispatch_execute_script(self, cursor: "SpannerConnection", statement: "SQL")
210215
is_select = stmt.upper().strip().startswith("SELECT")
211216
coerced_params = self._coerce_params(script_params)
212217
if not is_select and not is_transaction:
213-
msg = "Cannot execute DML in a read-only Snapshot context."
214-
raise SQLConversionError(msg)
218+
raise SQLConversionError(_READ_ONLY_SNAPSHOT_ERROR_MESSAGE)
215219
if not is_select and is_transaction:
216220
writer = cast("_SpannerWriteProtocol", cursor)
217221
writer.execute_update(stmt, params=coerced_params, param_types=self._infer_param_types(coerced_params))

sqlspec/data_dictionary/sql/postgres/indexes.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ SELECT
55
t.relname as table_name,
66
ix.indisunique as is_unique,
77
ix.indisprimary as is_primary,
8-
array_agg(a.attname ORDER BY array_position(ix.indkey, a.attnum)) as columns
8+
array_agg(a.attname::text ORDER BY array_position(ix.indkey, a.attnum)) as columns
99
FROM
1010
pg_class t,
1111
pg_class i,
@@ -34,7 +34,7 @@ SELECT
3434
t.relname as table_name,
3535
ix.indisunique as is_unique,
3636
ix.indisprimary as is_primary,
37-
array_agg(a.attname ORDER BY array_position(ix.indkey, a.attnum)) as columns
37+
array_agg(a.attname::text ORDER BY array_position(ix.indkey, a.attnum)) as columns
3838
FROM
3939
pg_class t,
4040
pg_class i,

0 commit comments

Comments
 (0)