From edc061c3f8a8fd7c90869f861dd3f246b31da4f3 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 26 May 2026 21:18:52 +0000 Subject: [PATCH 01/10] fix(data-dictionary): cast name to text in postgres indexes query; add get_columns coverage The postgres indexes query used array_agg(a.attname ...) which returns PostgreSQL's internal name[] array type. asyncpg and psycopg decode it transparently; psqlpy raises "Cannot convert _name into Python type." Casting attname::text yields text[] which all three adapters decode to list[str]. Adds first integration coverage for data_dictionary.get_columns(table=...) and get_columns(schema=...) across asyncpg, psycopg, and psqlpy. The original #361 symptom (server-side syntax error on the introspection query) does not reproduce on master, but no integration test exercised this query path until now. Closes #361. --- .../data_dictionary/sql/postgres/indexes.sql | 4 +- .../adapters/asyncpg/test_data_dictionary.py | 41 +++++++ .../adapters/psqlpy/test_data_dictionary.py | 108 +++++++++++++++++ .../adapters/psycopg/test_data_dictionary.py | 113 ++++++++++++++++++ 4 files changed, 264 insertions(+), 2 deletions(-) create mode 100644 tests/integration/adapters/psqlpy/test_data_dictionary.py create mode 100644 tests/integration/adapters/psycopg/test_data_dictionary.py diff --git a/sqlspec/data_dictionary/sql/postgres/indexes.sql b/sqlspec/data_dictionary/sql/postgres/indexes.sql index 46c0b1f33..8bfa3f0c1 100644 --- a/sqlspec/data_dictionary/sql/postgres/indexes.sql +++ b/sqlspec/data_dictionary/sql/postgres/indexes.sql @@ -5,7 +5,7 @@ SELECT t.relname as table_name, ix.indisunique as is_unique, ix.indisprimary as is_primary, - array_agg(a.attname ORDER BY array_position(ix.indkey, a.attnum)) as columns + array_agg(a.attname::text ORDER BY array_position(ix.indkey, a.attnum)) as columns FROM pg_class t, pg_class i, @@ -34,7 +34,7 @@ SELECT t.relname as table_name, ix.indisunique as is_unique, ix.indisprimary as is_primary, - array_agg(a.attname ORDER BY array_position(ix.indkey, a.attnum)) as columns + array_agg(a.attname::text ORDER BY array_position(ix.indkey, a.attnum)) as columns FROM pg_class t, pg_class i, diff --git a/tests/integration/adapters/asyncpg/test_data_dictionary.py b/tests/integration/adapters/asyncpg/test_data_dictionary.py index 2b5fb5fe8..0fd0a8819 100644 --- a/tests/integration/adapters/asyncpg/test_data_dictionary.py +++ b/tests/integration/adapters/asyncpg/test_data_dictionary.py @@ -165,3 +165,44 @@ async def test_asyncpg_data_dictionary_topology_and_fks(asyncpg_async_driver: "A DROP TABLE IF EXISTS {orders_table} CASCADE; DROP TABLE IF EXISTS {users_table} CASCADE; """) + + +@pytest.mark.asyncpg +async def test_asyncpg_data_dictionary_get_columns(asyncpg_async_driver: "AsyncpgDriver") -> None: + """Exercise get_columns(table=...) end-to-end (regression for #361).""" + import uuid + + table_name = f"dd_cols_{uuid.uuid4().hex[:8]}" + schema_name = "public" + + await asyncpg_async_driver.execute_script(f""" + CREATE TABLE {table_name} ( + id SERIAL PRIMARY KEY, + label TEXT NOT NULL, + status VARCHAR(20) DEFAULT 'pending', + note TEXT + ); + """) + + try: + cols = await asyncpg_async_driver.data_dictionary.get_columns( + asyncpg_async_driver, table=table_name, schema=schema_name + ) + by_name = {c["column_name"]: c for c in cols} + assert set(by_name) >= {"id", "label", "status", "note"} + + assert by_name["label"]["is_nullable"] == "NO" + assert by_name["note"]["is_nullable"] == "YES" + + status_default = by_name["status"].get("column_default") + assert status_default is not None + assert "pending" in status_default + finally: + await asyncpg_async_driver.execute_script(f"DROP TABLE IF EXISTS {table_name} CASCADE;") + + +@pytest.mark.asyncpg +async def test_asyncpg_data_dictionary_get_columns_by_schema(asyncpg_async_driver: "AsyncpgDriver") -> None: + """Exercise get_columns(schema=...) without a table filter (regression for #361).""" + cols = await asyncpg_async_driver.data_dictionary.get_columns(asyncpg_async_driver, schema="pg_catalog") + assert len(cols) > 0 diff --git a/tests/integration/adapters/psqlpy/test_data_dictionary.py b/tests/integration/adapters/psqlpy/test_data_dictionary.py new file mode 100644 index 000000000..0ff48e9f1 --- /dev/null +++ b/tests/integration/adapters/psqlpy/test_data_dictionary.py @@ -0,0 +1,108 @@ +"""Integration tests for psqlpy PostgreSQL data dictionary (regression for #361).""" + +import uuid +from typing import TYPE_CHECKING + +import pytest + +from sqlspec.typing import VersionInfo + +if TYPE_CHECKING: + from sqlspec.adapters.psqlpy import PsqlpyDriver + +pytestmark = pytest.mark.xdist_group("postgres") + + +@pytest.mark.psqlpy +async def test_psqlpy_data_dictionary_version_detection(psqlpy_driver: "PsqlpyDriver") -> None: + """Test PostgreSQL version detection via psqlpy.""" + version = await psqlpy_driver.data_dictionary.get_version(psqlpy_driver) + assert version is not None + assert isinstance(version, VersionInfo) + assert version.major >= 9 + + +@pytest.mark.psqlpy +async def test_psqlpy_data_dictionary_get_columns(psqlpy_driver: "PsqlpyDriver") -> None: + """Exercise get_columns(table=...) end-to-end (regression for #361).""" + table_name = f"dd_cols_{uuid.uuid4().hex[:8]}" + await psqlpy_driver.execute_script(f""" + CREATE TABLE {table_name} ( + id SERIAL PRIMARY KEY, + label TEXT NOT NULL, + status VARCHAR(20) DEFAULT 'pending', + note TEXT + ); + """) + try: + cols = await psqlpy_driver.data_dictionary.get_columns(psqlpy_driver, table=table_name, schema="public") + by_name = {c["column_name"]: c for c in cols} + assert set(by_name) >= {"id", "label", "status", "note"} + + assert by_name["label"]["is_nullable"] == "NO" + assert by_name["note"]["is_nullable"] == "YES" + + status_default = by_name["status"].get("column_default") + assert status_default is not None + assert "pending" in status_default + finally: + await psqlpy_driver.execute_script(f"DROP TABLE IF EXISTS {table_name} CASCADE;") + + +@pytest.mark.psqlpy +async def test_psqlpy_data_dictionary_get_columns_by_schema(psqlpy_driver: "PsqlpyDriver") -> None: + """Exercise get_columns(schema=...) without a table filter (regression for #361).""" + cols = await psqlpy_driver.data_dictionary.get_columns(psqlpy_driver, schema="pg_catalog") + assert len(cols) > 0 + + +@pytest.mark.psqlpy +async def test_psqlpy_data_dictionary_topology_and_fks(psqlpy_driver: "PsqlpyDriver") -> None: + """Test topological sort and FK metadata via psqlpy.""" + unique_suffix = uuid.uuid4().hex[:8] + users_table = f"dd_users_{unique_suffix}" + orders_table = f"dd_orders_{unique_suffix}" + items_table = f"dd_items_{unique_suffix}" + + await psqlpy_driver.execute_script(f""" + CREATE TABLE {users_table} ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) + ); + CREATE TABLE {orders_table} ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES {users_table}(id), + amount INTEGER + ); + CREATE TABLE {items_table} ( + id SERIAL PRIMARY KEY, + order_id INTEGER REFERENCES {orders_table}(id), + name VARCHAR(50) + ); + """) + try: + sorted_tables = await psqlpy_driver.data_dictionary.get_tables(psqlpy_driver) + table_names = [table.get("table_name") for table in sorted_tables if table.get("table_name")] + test_tables = [name for name in table_names if name in (users_table, orders_table, items_table)] + assert len(test_tables) == 3 + + idx_users = test_tables.index(users_table) + idx_orders = test_tables.index(orders_table) + idx_items = test_tables.index(items_table) + assert idx_users < idx_orders + assert idx_orders < idx_items + + fks = await psqlpy_driver.data_dictionary.get_foreign_keys(psqlpy_driver, table=orders_table) + assert len(fks) >= 1 + my_fk = next((fk for fk in fks if fk.referenced_table == users_table), None) + assert my_fk is not None + assert my_fk.column_name == "user_id" + + indexes = await psqlpy_driver.data_dictionary.get_indexes(psqlpy_driver, table=users_table) + assert len(indexes) >= 1 + finally: + await psqlpy_driver.execute_script(f""" + DROP TABLE IF EXISTS {items_table} CASCADE; + DROP TABLE IF EXISTS {orders_table} CASCADE; + DROP TABLE IF EXISTS {users_table} CASCADE; + """) diff --git a/tests/integration/adapters/psycopg/test_data_dictionary.py b/tests/integration/adapters/psycopg/test_data_dictionary.py new file mode 100644 index 000000000..82436cd83 --- /dev/null +++ b/tests/integration/adapters/psycopg/test_data_dictionary.py @@ -0,0 +1,113 @@ +"""Integration tests for psycopg PostgreSQL data dictionary (regression for #361).""" + +import uuid +from typing import TYPE_CHECKING + +import pytest + +from sqlspec.typing import VersionInfo + +if TYPE_CHECKING: + from sqlspec.adapters.psycopg import PsycopgAsyncConfig + +pytestmark = pytest.mark.xdist_group("postgres") + + +@pytest.mark.psycopg +async def test_psycopg_data_dictionary_version_detection(psycopg_async_config: "PsycopgAsyncConfig") -> None: + """Test PostgreSQL version detection via psycopg.""" + async with psycopg_async_config.provide_session() as driver: + version = await driver.data_dictionary.get_version(driver) + assert version is not None + assert isinstance(version, VersionInfo) + assert version.major >= 9 + + +@pytest.mark.psycopg +async def test_psycopg_data_dictionary_get_columns(psycopg_async_config: "PsycopgAsyncConfig") -> None: + """Exercise get_columns(table=...) end-to-end (regression for #361).""" + table_name = f"dd_cols_{uuid.uuid4().hex[:8]}" + async with psycopg_async_config.provide_session() as driver: + await driver.execute_script(f""" + CREATE TABLE {table_name} ( + id SERIAL PRIMARY KEY, + label TEXT NOT NULL, + status VARCHAR(20) DEFAULT 'pending', + note TEXT + ); + """) + try: + cols = await driver.data_dictionary.get_columns(driver, table=table_name, schema="public") + by_name = {c["column_name"]: c for c in cols} + assert set(by_name) >= {"id", "label", "status", "note"} + + assert by_name["label"]["is_nullable"] == "NO" + assert by_name["note"]["is_nullable"] == "YES" + + status_default = by_name["status"].get("column_default") + assert status_default is not None + assert "pending" in status_default + finally: + await driver.execute_script(f"DROP TABLE IF EXISTS {table_name} CASCADE;") + + +@pytest.mark.psycopg +async def test_psycopg_data_dictionary_get_columns_by_schema(psycopg_async_config: "PsycopgAsyncConfig") -> None: + """Exercise get_columns(schema=...) without a table filter (regression for #361).""" + async with psycopg_async_config.provide_session() as driver: + cols = await driver.data_dictionary.get_columns(driver, schema="pg_catalog") + assert len(cols) > 0 + + +@pytest.mark.psycopg +async def test_psycopg_data_dictionary_topology_and_fks(psycopg_async_config: "PsycopgAsyncConfig") -> None: + """Test topological sort and FK metadata via psycopg.""" + unique_suffix = uuid.uuid4().hex[:8] + users_table = f"dd_users_{unique_suffix}" + orders_table = f"dd_orders_{unique_suffix}" + items_table = f"dd_items_{unique_suffix}" + + async with psycopg_async_config.provide_session() as driver: + await driver.execute_script(f""" + CREATE TABLE {users_table} ( + id SERIAL PRIMARY KEY, + name VARCHAR(50) + ); + CREATE TABLE {orders_table} ( + id SERIAL PRIMARY KEY, + user_id INTEGER REFERENCES {users_table}(id), + amount INTEGER + ); + CREATE TABLE {items_table} ( + id SERIAL PRIMARY KEY, + order_id INTEGER REFERENCES {orders_table}(id), + name VARCHAR(50) + ); + """) + + try: + sorted_tables = await driver.data_dictionary.get_tables(driver) + table_names = [table.get("table_name") for table in sorted_tables if table.get("table_name")] + test_tables = [name for name in table_names if name in (users_table, orders_table, items_table)] + assert len(test_tables) == 3 + + idx_users = test_tables.index(users_table) + idx_orders = test_tables.index(orders_table) + idx_items = test_tables.index(items_table) + assert idx_users < idx_orders + assert idx_orders < idx_items + + fks = await driver.data_dictionary.get_foreign_keys(driver, table=orders_table) + assert len(fks) >= 1 + my_fk = next((fk for fk in fks if fk.referenced_table == users_table), None) + assert my_fk is not None + assert my_fk.column_name == "user_id" + + indexes = await driver.data_dictionary.get_indexes(driver, table=users_table) + assert len(indexes) >= 1 + finally: + await driver.execute_script(f""" + DROP TABLE IF EXISTS {items_table} CASCADE; + DROP TABLE IF EXISTS {orders_table} CASCADE; + DROP TABLE IF EXISTS {users_table} CASCADE; + """) From 3735b782e8c15c1a6a92696e08ad74162f9ee6a5 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 26 May 2026 21:25:59 +0000 Subject: [PATCH 02/10] fix(adbc): honor connection_config driver_name; skip BEGIN on SQLite Two distinct bugs made AdbcConfig(connection_config={"driver_name": "sqlite", ...}) unusable: 1. detect_dialect() consulted only connection.adbc_get_info(); when the ADBC SQLite driver's GetInfo did not match a known pattern, the function warned "Could not determine dialect from driver info. Defaulting to 'postgres'." and returned "postgres". The user-supplied driver_name was ignored. 2. AdbcDriver.begin() ran cursor.execute("BEGIN") unconditionally. ADBC SQLite holds an implicit transaction and rejected the explicit BEGIN with "cannot start a transaction within a transaction." detect_dialect now accepts an optional fallback_dialect; AdbcSessionContext threads the config-resolved dialect through to AdbcDriver, which passes it down. begin() is a no-op on the sqlite dialect; commit()/rollback() still close the implicit transaction. Closes #472. --- sqlspec/adapters/adbc/_typing.py | 7 +++- sqlspec/adapters/adbc/core.py | 10 +++++- sqlspec/adapters/adbc/driver.py | 14 ++++++-- .../adapters/adbc/test_sqlite_session.py | 36 +++++++++++++++++++ tests/unit/adapters/test_adbc/test_core.py | 24 +++++++++++++ 5 files changed, 87 insertions(+), 4 deletions(-) create mode 100644 tests/integration/adapters/adbc/test_sqlite_session.py diff --git a/sqlspec/adapters/adbc/_typing.py b/sqlspec/adapters/adbc/_typing.py index 699eea030..df9982cfc 100644 --- a/sqlspec/adapters/adbc/_typing.py +++ b/sqlspec/adapters/adbc/_typing.py @@ -86,11 +86,16 @@ def __init__( self._driver: AdbcDriver | None = None def __enter__(self) -> "AdbcDriver": + from sqlspec.adapters.adbc.core import resolve_dialect_name from sqlspec.adapters.adbc.driver import AdbcDriver self._connection = self._acquire_connection() + resolved_dialect = resolve_dialect_name(self._statement_config.dialect) or None self._driver = AdbcDriver( - connection=self._connection, statement_config=self._statement_config, driver_features=self._driver_features + connection=self._connection, + statement_config=self._statement_config, + driver_features=self._driver_features, + dialect=resolved_dialect, ) return self._prepare_driver(self._driver) diff --git a/sqlspec/adapters/adbc/core.py b/sqlspec/adapters/adbc/core.py index abc273aef..e1bf88c88 100644 --- a/sqlspec/adapters/adbc/core.py +++ b/sqlspec/adapters/adbc/core.py @@ -157,12 +157,15 @@ _TYPE_COERCION_DISPATCHERS: "dict[tuple[tuple[type, Callable[[Any], Any]], ...], TypeDispatcher[Callable[[Any], Any]]]" = {} -def detect_dialect(connection: Any, logger: Any | None = None) -> str: +def detect_dialect(connection: Any, logger: Any | None = None, *, fallback_dialect: "str | None" = None) -> str: """Detect database dialect from ADBC driver information. Args: connection: ADBC connection with driver metadata. logger: Optional logger for diagnostics. + fallback_dialect: Pre-resolved dialect from config (e.g. from + ``resolve_dialect_from_config``). Used when introspection + yields no match, before defaulting to ``postgres``. Returns: Detected dialect name, defaulting to ``postgres``. @@ -182,6 +185,11 @@ def detect_dialect(connection: Any, logger: Any | None = None) -> str: if logger is not None: logger.debug("Dialect detection failed: %s", exc) + if fallback_dialect: + if logger is not None: + logger.debug("Dialect from connection_config: %s (introspection yielded no match)", fallback_dialect) + return fallback_dialect + if logger is not None: logger.warning("Could not determine dialect from driver info. Defaulting to 'postgres'.") return "postgres" diff --git a/sqlspec/adapters/adbc/driver.py b/sqlspec/adapters/adbc/driver.py index fb67fe468..01c287928 100644 --- a/sqlspec/adapters/adbc/driver.py +++ b/sqlspec/adapters/adbc/driver.py @@ -89,8 +89,10 @@ def __init__( connection: "AdbcConnection", statement_config: "StatementConfig | None" = None, driver_features: "dict[str, Any] | None" = None, + *, + dialect: "str | None" = None, ) -> None: - self._detected_dialect = detect_dialect(connection, logger) + self._detected_dialect = detect_dialect(connection, logger, fallback_dialect=dialect) if statement_config is None: base_config = get_statement_config(self._detected_dialect) @@ -256,7 +258,15 @@ def dispatch_execute_script(self, cursor: "AdbcRawCursor", statement: "SQL") -> # ───────────────────────────────────────────────────────────────────────────── def begin(self) -> None: - """Begin database transaction.""" + """Begin database transaction. + + ADBC SQLite holds an implicit transaction after the first DML and + rejects an explicit ``BEGIN`` with "cannot start a transaction + within a transaction." On that dialect this is a no-op; the + implicit transaction is closed by ``commit()`` / ``rollback()``. + """ + if self._dialect_name == "sqlite": + return try: with self.with_cursor(self.connection) as cursor: cursor.execute("BEGIN") diff --git a/tests/integration/adapters/adbc/test_sqlite_session.py b/tests/integration/adapters/adbc/test_sqlite_session.py new file mode 100644 index 000000000..17e3a897d --- /dev/null +++ b/tests/integration/adapters/adbc/test_sqlite_session.py @@ -0,0 +1,36 @@ +"""ADBC SQLite integration smoke (regression for #472). + +Two upstream bugs are exercised here: + +1. ``detect_dialect`` previously ignored ``connection_config["driver_name"]`` + and defaulted to ``postgres``; the user got a misleading warning and a + wrong fallback when ``adbc_get_info()`` did not yield a pattern match. +2. ``AdbcDriver.begin()`` ran ``cursor.execute("BEGIN")`` unconditionally; + ADBC SQLite holds an implicit transaction and rejected the explicit + ``BEGIN`` with "cannot start a transaction within a transaction." +""" + +from typing import TYPE_CHECKING + +import pytest + +from sqlspec.adapters.adbc import AdbcConfig + +if TYPE_CHECKING: + from pathlib import Path + +pytestmark = pytest.mark.adbc + + +def test_adbc_sqlite_dialect_and_begin(tmp_path: "Path") -> None: + db = tmp_path / "demo.db" + config = AdbcConfig(connection_config={"driver_name": "sqlite", "uri": str(db)}) + + with config.provide_session() as driver: + assert driver._dialect_name == "sqlite" + driver.execute("CREATE TABLE t (id INTEGER PRIMARY KEY, body TEXT)") + driver.begin() + driver.execute("INSERT INTO t (body) VALUES (?)", ("hello",)) + driver.commit() + row = driver.execute("SELECT body FROM t").one() + assert row["body"] == "hello" diff --git a/tests/unit/adapters/test_adbc/test_core.py b/tests/unit/adapters/test_adbc/test_core.py index b6b479314..a28f582b9 100644 --- a/tests/unit/adapters/test_adbc/test_core.py +++ b/tests/unit/adapters/test_adbc/test_core.py @@ -130,3 +130,27 @@ def test_prepare_parameters_with_casts_supports_virtual_abc_dispatch() -> None: ) assert prepared == [(1, 2)] + + +def test_detect_dialect_uses_fallback_when_introspection_returns_unknown() -> None: + """detect_dialect honors fallback_dialect when GetInfo yields no pattern match.""" + + conn = SimpleNamespace(adbc_get_info=lambda: {"vendor_name": "", "driver_name": ""}) + result = adbc_core.detect_dialect(conn, fallback_dialect="sqlite") + assert result == "sqlite" + + +def test_detect_dialect_still_defaults_to_postgres_without_fallback() -> None: + """Without fallback_dialect, the legacy 'default to postgres' path still fires.""" + + conn = SimpleNamespace(adbc_get_info=lambda: {"vendor_name": "", "driver_name": ""}) + result = adbc_core.detect_dialect(conn, fallback_dialect=None) + assert result == "postgres" + + +def test_detect_dialect_introspection_match_beats_fallback() -> None: + """An introspection match always wins over the fallback signal.""" + + conn = SimpleNamespace(adbc_get_info=lambda: {"vendor_name": "duckdb", "driver_name": ""}) + result = adbc_core.detect_dialect(conn, fallback_dialect="sqlite") + assert result == "duckdb" From ef5fe3dc444552284d185dafbd6b5e1f184a4d6d Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 26 May 2026 21:33:50 +0000 Subject: [PATCH 03/10] fix: repair compiled litestar filter providers (#475) --- sqlspec/extensions/litestar/providers.py | 40 ++++++++------ .../test_litestar/test_providers.py | 53 ++++++++++++++++++- 2 files changed, 76 insertions(+), 17 deletions(-) diff --git a/sqlspec/extensions/litestar/providers.py b/sqlspec/extensions/litestar/providers.py index 61e5b91c3..b05f97abc 100644 --- a/sqlspec/extensions/litestar/providers.py +++ b/sqlspec/extensions/litestar/providers.py @@ -449,6 +449,10 @@ def _collection_value_annotation(collection_type: type[Any], value_type: type[An return GenericAlias(collection_type, (value_type,)) | None +def _query_parameter_annotation(value_annotation: Any, query: Any) -> Any: + return Annotated[value_annotation, query] + + class _CollectionFilterProvider: """Per-field `IN` / `NOT IN` provider with a unique parameter name (issue #435).""" @@ -458,10 +462,10 @@ def __init__(self, field: FieldNameType, *, negated: bool) -> None: self.param_name = f"{field.name}_values" self.filter_cls: Any = NotInCollectionFilter if negated else InCollectionFilter self.return_annotation = self.filter_cls[field.type_hint] | None - annotation = Annotated[ # type: ignore[valid-type] + annotation = _query_parameter_annotation( _collection_value_annotation(list, field.type_hint), QueryParameter(name=camelize(f"{field.name}_{'not_in' if negated else 'in'}"), required=False), - ] + ) self.signature = inspect.Signature( parameters=[ inspect.Parameter( @@ -484,7 +488,9 @@ def __init__(self, field_name: str, *, negated: bool) -> None: self.param_name = f"{field_name}_{suffix}" self.filter_cls: type[Any] = NotNullFilter if negated else NullFilter self.return_annotation = self.filter_cls | None - annotation = Annotated[bool | None, QueryParameter(name=camelize(self.param_name), required=False)] + annotation = _query_parameter_annotation( + bool | None, QueryParameter(name=camelize(self.param_name), required=False) + ) self.signature = inspect.Signature( parameters=[ inspect.Parameter( @@ -506,8 +512,8 @@ def __init__(self, field_name: str, before_alias: str, after_alias: str) -> None self.field_name = field_name self.before_param = f"{field_name}_before" self.after_param = f"{field_name}_after" - before_annotation = Annotated[DTorNone, QueryParameter(name=before_alias, required=False)] - after_annotation = Annotated[DTorNone, QueryParameter(name=after_alias, required=False)] + before_annotation = _query_parameter_annotation(DTorNone, QueryParameter(name=before_alias, required=False)) + after_annotation = _query_parameter_annotation(DTorNone, QueryParameter(name=after_alias, required=False)) self.signature = inspect.Signature( parameters=[ inspect.Parameter( @@ -533,7 +539,9 @@ class _IdFilterProvider: def __init__(self, field_name: str, id_type: type[Any]) -> None: self.field_name = field_name self.return_annotation = InCollectionFilter[id_type] # type: ignore[valid-type] - annotation = Annotated[_collection_value_annotation(list, id_type), QueryParameter(name="ids", required=False)] # type: ignore[valid-type] + annotation = _query_parameter_annotation( + _collection_value_annotation(list, id_type), QueryParameter(name="ids", required=False) + ) self.signature = inspect.Signature( parameters=[ inspect.Parameter("ids", kind=inspect.Parameter.KEYWORD_ONLY, default=None, annotation=annotation) @@ -550,8 +558,8 @@ class _LimitOffsetFilterProvider: def __init__(self, default_page_size: int) -> None: self.default_page_size = default_page_size self.return_annotation = LimitOffsetFilter - current_annotation = Annotated[int, QueryParameter(name="currentPage", required=False, ge=1)] - size_annotation = Annotated[int, QueryParameter(name="pageSize", required=False, ge=1)] + current_annotation = _query_parameter_annotation(int, QueryParameter(name="currentPage", required=False, ge=1)) + size_annotation = _query_parameter_annotation(int, QueryParameter(name="pageSize", required=False, ge=1)) self.signature = inspect.Signature( parameters=[ inspect.Parameter( @@ -582,13 +590,13 @@ def __init__(self, search_fields: str | set[str] | list[str], ignore_case_defaul self.search_fields = search_fields self.ignore_case_default = ignore_case_default self.return_annotation = SearchFilter - search_annotation = Annotated[ + search_annotation = _query_parameter_annotation( StringOrNone, QueryParameter(name="searchString", required=False, title="Field to search") - ] - ignore_annotation = Annotated[ + ) + ignore_annotation = _query_parameter_annotation( BooleanOrNone, QueryParameter(name="searchIgnoreCase", required=False, title="Search should be case sensitive"), - ] + ) self.signature = inspect.Signature( parameters=[ inspect.Parameter( @@ -630,12 +638,12 @@ def __init__(self, sort_field: SortField, config: FilterConfig) -> None: self.allowed_field_names = ", ".join(self.sort_resolution.allowed_display_names) self.sort_order_default: SortOrder = config.get("sort_order", "desc") self.return_annotation = OrderByFilter - field_annotation = Annotated[ + field_annotation = _query_parameter_annotation( StringOrNone, QueryParameter(name="orderBy", required=False, title="Order by field") - ] - order_annotation = Annotated[ + ) + order_annotation = _query_parameter_annotation( SortOrderOrNone, QueryParameter(name="sortOrder", required=False, title="Field to search") - ] + ) self.signature = inspect.Signature( parameters=[ inspect.Parameter( diff --git a/tests/unit/extensions/test_litestar/test_providers.py b/tests/unit/extensions/test_litestar/test_providers.py index 693b337fa..58bc81f2d 100644 --- a/tests/unit/extensions/test_litestar/test_providers.py +++ b/tests/unit/extensions/test_litestar/test_providers.py @@ -1,12 +1,20 @@ """Unit tests for Litestar filter providers (issue #405).""" +import datetime import inspect from typing import Any import pytest from litestar.exceptions import ValidationException -from sqlspec.core import InCollectionFilter, NotInCollectionFilter, OrderByFilter +from sqlspec.core import ( + BeforeAfterFilter, + InCollectionFilter, + LimitOffsetFilter, + NotInCollectionFilter, + OrderByFilter, + SearchFilter, +) from sqlspec.extensions.litestar.providers import ( FieldNameType, FilterConfig, @@ -88,6 +96,49 @@ def test_not_in_fields_provider_returns_filter_when_values_present() -> None: assert list(result.values) == ["deleted", "archived"] # type: ignore[arg-type] +def test_compiled_annotation_pair_providers_build_and_call() -> None: + """Providers with paired annotation locals instantiate and call under compiled builds.""" + before = datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) + after = datetime.datetime(2025, 1, 1, tzinfo=datetime.timezone.utc) + deps = _create_statement_filters( + FilterConfig( + created_at=True, + updated_at=True, + pagination_type="limit_offset", + pagination_size=25, + search=["name", "email"], + search_ignore_case=True, + sort_field=["created_at", "name"], + sort_order="asc", + ) + ) + + created_filter = deps["created_filter"].dependency(created_at_before=before, created_at_after=after) + updated_filter = deps["updated_filter"].dependency(updated_at_before=before, updated_at_after=after) + limit_offset_filter = deps["limit_offset_filter"].dependency(current_page=3, page_size=10) + search_filter = deps["search_filter"].dependency(search_string="alice", ignore_case=False) + order_by_filter = deps["order_by_filter"].dependency(field_name="name", sort_order="desc") + + assert isinstance(created_filter, BeforeAfterFilter) + assert created_filter.field_name == "created_at" + assert created_filter.before == before + assert created_filter.after == after + assert isinstance(updated_filter, BeforeAfterFilter) + assert updated_filter.field_name == "updated_at" + assert updated_filter.before == before + assert updated_filter.after == after + assert isinstance(limit_offset_filter, LimitOffsetFilter) + assert limit_offset_filter.limit == 10 + assert limit_offset_filter.offset == 20 + assert isinstance(search_filter, SearchFilter) + assert search_filter.field_name == {"name", "email"} + assert search_filter.value == "alice" + assert search_filter.ignore_case is False + assert isinstance(order_by_filter, OrderByFilter) + assert order_by_filter.field_name == "name" + assert order_by_filter.sort_order == "desc" + + def test_aggregate_function_includes_in_filter() -> None: """Aggregate function includes InCollectionFilter in results.""" config = FilterConfig(in_fields={FieldNameType(name="status", type_hint=str)}) From 3a07d135e6557b1dd3cec3950d59ab60a5414c92 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 26 May 2026 21:36:13 +0000 Subject: [PATCH 04/10] fix(spanner): provide_session() write-capable by default; add provide_read_session() SpannerSyncConfig.provide_session() defaulted transaction=False, which yields a google.cloud.spanner_v1.snapshot.Snapshot context. Any DML through that session raised SQLConversionError("Cannot execute DML in a read-only Snapshot context.") with no pointer at the existing provide_write_session() workaround. Flips the default to transaction=True so provide_session() matches every other sqlspec adapter (write-capable). Adds provide_read_session() for the explicit snapshot path; keeps provide_write_session() as an alias for callers already on it. Pulls the default-transaction flag and the snapshot error message into module-level constants (_DEFAULT_SESSION_TRANSACTION, _READ_ONLY_SNAPSHOT_ERROR_MESSAGE) so an eventual SpannerAsyncConfig can mirror them line-for-line when google-cloud-spanner ships high-level async (currently only the gapic SpannerAsyncClient exists upstream). The snapshot raise sites now point readers at provide_read_session by name. Closes #474. --- sqlspec/adapters/spanner/config.py | 48 +++++++++++++------ sqlspec/adapters/spanner/driver.py | 12 +++-- .../integration/adapters/spanner/conftest.py | 2 +- .../adapters/spanner/test_session_defaults.py | 47 ++++++++++++++++++ 4 files changed, 90 insertions(+), 19 deletions(-) create mode 100644 tests/integration/adapters/spanner/test_session_defaults.py diff --git a/sqlspec/adapters/spanner/config.py b/sqlspec/adapters/spanner/config.py index 4a34274bb..535d62e13 100644 --- a/sqlspec/adapters/spanner/config.py +++ b/sqlspec/adapters/spanner/config.py @@ -29,6 +29,14 @@ __all__ = ("SpannerConnectionParams", "SpannerDriverFeatures", "SpannerPoolParams", "SpannerSyncConfig") +_DEFAULT_SESSION_TRANSACTION: bool = True +"""Default ``transaction`` flag for ``provide_session`` / ``provide_connection``. + +``True`` yields a write-capable :class:`Transaction` context matching every +other sqlspec adapter. Read-only :class:`Snapshot` contexts are available via +:meth:`SpannerSyncConfig.provide_read_session`. Pulled into a module-level +constant so an eventual ``SpannerAsyncConfig`` can import the same default.""" + class SpannerConnectionParams(TypedDict): """Spanner connection parameters.""" @@ -278,27 +286,38 @@ def _close_pool(self) -> None: self._client = None self._database = None - def provide_connection(self, *args: Any, transaction: "bool" = False, **kwargs: Any) -> "SpannerConnectionContext": - """Yield a Snapshot (default) or Transaction context from the configured pool. + def provide_connection( + self, *args: Any, transaction: "bool" = _DEFAULT_SESSION_TRANSACTION, **kwargs: Any + ) -> "SpannerConnectionContext": + """Yield a Transaction (default) or Snapshot context from the configured pool. Args: *args: Additional positional arguments (unused, for interface compatibility). - transaction: If True, yields a Transaction context that supports - execute_update() for DML statements. If False (default), yields + transaction: If True (default), yields a Transaction context that + supports execute_update() for DML statements. If False, yields a read-only Snapshot context for SELECT queries. **kwargs: Additional keyword arguments (unused, for interface compatibility). """ return SpannerConnectionContext(self, transaction=transaction) def provide_session( - self, *args: Any, statement_config: "StatementConfig | None" = None, transaction: "bool" = False, **kwargs: Any + self, + *args: Any, + statement_config: "StatementConfig | None" = None, + transaction: "bool" = _DEFAULT_SESSION_TRANSACTION, + **kwargs: Any, ) -> "SpannerSessionContext": """Provide a Spanner driver session context manager. + Returns a write-capable Transaction session by default, matching every + other sqlspec adapter. Pass ``transaction=False`` or use + :meth:`provide_read_session` to obtain a read-only Snapshot session. + Args: *args: Additional arguments. statement_config: Optional statement configuration override. - transaction: Whether to use a transaction. + transaction: Whether to use a Transaction (True, default) or + Snapshot (False). **kwargs: Additional keyword arguments. Returns: @@ -318,17 +337,18 @@ def provide_session( def provide_write_session( self, *args: Any, statement_config: "StatementConfig | None" = None, **kwargs: Any ) -> "SpannerSessionContext": - """Provide a Spanner driver write session context manager. + """Provide a write-capable Spanner session (alias for :meth:`provide_session`).""" + return self.provide_session(*args, statement_config=statement_config, transaction=True, **kwargs) - Args: - *args: Additional arguments. - statement_config: Optional statement configuration override. - **kwargs: Additional keyword arguments. + def provide_read_session( + self, *args: Any, statement_config: "StatementConfig | None" = None, **kwargs: Any + ) -> "SpannerSessionContext": + """Provide a read-only Snapshot Spanner session. - Returns: - A Spanner driver write session context manager. + Use for query workloads that benefit from Spanner's snapshot reads. + For DDL/DML, use :meth:`provide_session` (write-capable by default). """ - return self.provide_session(*args, statement_config=statement_config, transaction=True, **kwargs) + return self.provide_session(*args, statement_config=statement_config, transaction=False, **kwargs) def get_signature_namespace(self) -> "dict[str, Any]": """Get the signature namespace for SpannerSyncConfig types. diff --git a/sqlspec/adapters/spanner/driver.py b/sqlspec/adapters/spanner/driver.py index cba8b6526..9a461ad1f 100644 --- a/sqlspec/adapters/spanner/driver.py +++ b/sqlspec/adapters/spanner/driver.py @@ -27,6 +27,12 @@ from sqlspec.exceptions import SQLConversionError from sqlspec.utils.serializers import from_json +_READ_ONLY_SNAPSHOT_ERROR_MESSAGE = ( + "Cannot execute DML in a read-only Snapshot context. " + "SpannerSyncConfig.provide_session() opens a write-capable Transaction by default; " + "the current session must have been opened via SpannerSyncConfig.provide_read_session()." +) + if TYPE_CHECKING: from collections.abc import Callable @@ -160,8 +166,7 @@ def dispatch_execute(self, cursor: "SpannerConnection", statement: "SQL") -> Exe row_count = writer.execute_update(sql, params=coerced_params, param_types=param_types_map) return self.create_execution_result(cursor, rowcount_override=row_count) - msg = "Cannot execute DML in a read-only Snapshot context." - raise SQLConversionError(msg) + raise SQLConversionError(_READ_ONLY_SNAPSHOT_ERROR_MESSAGE) def dispatch_execute_many(self, cursor: "SpannerConnection", statement: "SQL") -> ExecutionResult: if not supports_batch_update(cursor): @@ -210,8 +215,7 @@ def dispatch_execute_script(self, cursor: "SpannerConnection", statement: "SQL") is_select = stmt.upper().strip().startswith("SELECT") coerced_params = self._coerce_params(script_params) if not is_select and not is_transaction: - msg = "Cannot execute DML in a read-only Snapshot context." - raise SQLConversionError(msg) + raise SQLConversionError(_READ_ONLY_SNAPSHOT_ERROR_MESSAGE) if not is_select and is_transaction: writer = cast("_SpannerWriteProtocol", cursor) writer.execute_update(stmt, params=coerced_params, param_types=self._infer_param_types(coerced_params)) diff --git a/tests/integration/adapters/spanner/conftest.py b/tests/integration/adapters/spanner/conftest.py index 50bced49a..2b26ac491 100644 --- a/tests/integration/adapters/spanner/conftest.py +++ b/tests/integration/adapters/spanner/conftest.py @@ -125,7 +125,7 @@ def spanner_write_session(spanner_config: "SpannerSyncConfig") -> "Generator[Spa @pytest.fixture def spanner_read_session(spanner_config: "SpannerSyncConfig") -> "Generator[SpannerSyncDriver, None, None]": """Read-only session for SELECT operations.""" - with spanner_config.provide_session() as session: + with spanner_config.provide_read_session() as session: yield session diff --git a/tests/integration/adapters/spanner/test_session_defaults.py b/tests/integration/adapters/spanner/test_session_defaults.py new file mode 100644 index 000000000..4759c08a6 --- /dev/null +++ b/tests/integration/adapters/spanner/test_session_defaults.py @@ -0,0 +1,47 @@ +"""Spanner default-session DML smoke (regression for #474). + +Locks in two behaviours: + +1. ``SpannerSyncConfig.provide_session()`` returns a write-capable Transaction + by default. DML succeeds without the caller knowing about + ``provide_write_session()``. +2. ``SpannerSyncConfig.provide_read_session()`` opens a read-only Snapshot, + and DML through it raises ``SQLConversionError`` whose message names + ``provide_read_session`` explicitly so the next reader can self-serve. + +Spanner DDL is processed via the ``UpdateDatabaseDdl`` admin API rather +than session.execute(), so this test exercises only DML against a +fixture-created table. +""" + +from typing import TYPE_CHECKING + +import pytest + +from sqlspec.exceptions import SQLConversionError + +if TYPE_CHECKING: + from sqlspec.adapters.spanner import SpannerSyncConfig + +pytestmark = pytest.mark.spanner + + +def test_default_provide_session_runs_dml(spanner_config: "SpannerSyncConfig", test_users_table: str) -> None: + """provide_session() default is write-capable: INSERT + SELECT succeed.""" + with spanner_config.provide_session() as driver: + driver.execute( + f"INSERT INTO {test_users_table} (id, name, email, age) VALUES (@id, @name, @email, @age)", + {"id": "u1", "name": "Alice", "email": "alice@example.com", "age": 30}, + ) + row = driver.execute(f"SELECT name FROM {test_users_table} WHERE id = @id", {"id": "u1"}).one() + assert row["name"] == "Alice" + + +def test_provide_read_session_blocks_writes(spanner_config: "SpannerSyncConfig", test_users_table: str) -> None: + """provide_read_session() yields a Snapshot; DML raises and names the entrypoint.""" + with spanner_config.provide_read_session() as driver: + with pytest.raises(SQLConversionError, match="provide_read_session"): + driver.execute( + f"INSERT INTO {test_users_table} (id, name, email, age) VALUES (@id, @name, @email, @age)", + {"id": "u2", "name": "Bob", "email": "bob@example.com", "age": 25}, + ) From 6acdddb76cfc047a0a2c21602ba100eb1cf195d6 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 26 May 2026 21:38:48 +0000 Subject: [PATCH 05/10] fix(bigquery): omit CLUSTER BY against the emulator; wire job_timeout_ms knob MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two emulator-interop issues: 1. sqlspec/adapters/bigquery/events/store.py:78 returned " CLUSTER BY channel, status, available_at" unconditionally. The driver already detected the emulator via _using_emulator (driver.py:142), but the events store builds DDL from BigQueryConfig and has no driver handle. Extracts is_emulator_active_from_env() alongside detect_emulator() so the events store can branch on the env-var path without a connection. _table_clause() returns "" when the emulator is active. 2. BigQueryConnectionParams declared job_timeout_ms: NotRequired[int] (config.py:51) but nothing read it. The parallel query_timeout_ms field was the only live knob (config.py:262-264 → QueryJobConfig. job_timeout_ms). Wires job_timeout_ms through the same setup so the TypedDict surface is honest; when both are set, job_timeout_ms wins (applied last). Either knob now lets a caller surface emulator unresponsiveness as a timeout error instead of an indefinite block. Closes #473. --- sqlspec/adapters/bigquery/config.py | 4 ++ sqlspec/adapters/bigquery/core.py | 13 +++++- sqlspec/adapters/bigquery/events/store.py | 9 +++- .../adapters/test_bigquery/test_config.py | 23 ++++++++++ .../test_bigquery/test_events_store.py | 44 +++++++++++++++++++ 5 files changed, 90 insertions(+), 3 deletions(-) create mode 100644 tests/unit/adapters/test_bigquery/test_events_store.py diff --git a/sqlspec/adapters/bigquery/config.py b/sqlspec/adapters/bigquery/config.py index 8fe671512..23d28e591 100644 --- a/sqlspec/adapters/bigquery/config.py +++ b/sqlspec/adapters/bigquery/config.py @@ -263,6 +263,10 @@ def _setup_default_job_config(self) -> None: if query_timeout_ms is not None: job_config.job_timeout_ms = query_timeout_ms + job_timeout_ms = self.connection_config.get("job_timeout_ms") + if job_timeout_ms is not None: + job_config.job_timeout_ms = job_timeout_ms + self.connection_config["default_query_job_config"] = job_config def create_connection(self) -> BigQueryConnection: diff --git a/sqlspec/adapters/bigquery/core.py b/sqlspec/adapters/bigquery/core.py index bc267778a..273e90abb 100644 --- a/sqlspec/adapters/bigquery/core.py +++ b/sqlspec/adapters/bigquery/core.py @@ -60,6 +60,7 @@ "detect_emulator", "driver_profile", "extract_insert_table", + "is_emulator_active_from_env", "is_simple_insert", "normalize_script_rowcount", "resolve_column_names", @@ -335,10 +336,18 @@ def _inline_bigquery_literals( return str(transformed_expression.sql(dialect="bigquery")) +def is_emulator_active_from_env() -> bool: + """Return True if a BigQuery emulator endpoint is set via environment. + + Cheap env-only check for code paths that do not have a connection + handle yet (e.g. events store DDL builders constructed from config). + """ + return bool(os.getenv("BIGQUERY_EMULATOR_HOST") or os.getenv("BIGQUERY_EMULATOR_HOST_HTTP")) + + def detect_emulator(connection: "BigQueryConnection") -> bool: """Detect whether the BigQuery client targets an emulator endpoint.""" - emulator_host = os.getenv("BIGQUERY_EMULATOR_HOST") or os.getenv("BIGQUERY_EMULATOR_HOST_HTTP") - if emulator_host: + if is_emulator_active_from_env(): return True try: diff --git a/sqlspec/adapters/bigquery/events/store.py b/sqlspec/adapters/bigquery/events/store.py index dcaa64874..ff868a224 100644 --- a/sqlspec/adapters/bigquery/events/store.py +++ b/sqlspec/adapters/bigquery/events/store.py @@ -13,6 +13,7 @@ """ from sqlspec.adapters.bigquery.config import BigQueryConfig +from sqlspec.adapters.bigquery.core import is_emulator_active_from_env from sqlspec.extensions.events import BaseEventQueueStore __all__ = ("BigQueryEventQueueStore",) @@ -74,7 +75,13 @@ def _timestamp_default(self) -> str: return "CURRENT_TIMESTAMP()" def _table_clause(self) -> str: - """Return BigQuery CLUSTER BY clause for query optimization.""" + """Return BigQuery CLUSTER BY clause for query optimization. + + Omitted when running against the BigQuery emulator, which rejects + CLUSTER BY on the events table. + """ + if is_emulator_active_from_env(): + return "" return " CLUSTER BY channel, status, available_at" def _build_create_table_sql(self) -> str: diff --git a/tests/unit/adapters/test_bigquery/test_config.py b/tests/unit/adapters/test_bigquery/test_config.py index 1189c5bd2..075b5d24b 100644 --- a/tests/unit/adapters/test_bigquery/test_config.py +++ b/tests/unit/adapters/test_bigquery/test_config.py @@ -26,3 +26,26 @@ def serializer(_: object) -> str: parameter_config = config.statement_config.parameter_config assert parameter_config.json_serializer is serializer + + +def test_bigquery_config_wires_query_timeout_ms_to_default_job_config() -> None: + """query_timeout_ms on connection_config reaches QueryJobConfig.job_timeout_ms (existing behaviour).""" + config = BigQueryConfig(connection_config={"project": "p", "dataset_id": "d", "query_timeout_ms": 12345}) + default_job_config = config.connection_config["default_query_job_config"] + assert int(default_job_config.job_timeout_ms) == 12345 + + +def test_bigquery_config_wires_job_timeout_ms_to_default_job_config() -> None: + """job_timeout_ms on connection_config reaches QueryJobConfig.job_timeout_ms (regression for #473).""" + config = BigQueryConfig(connection_config={"project": "p", "dataset_id": "d", "job_timeout_ms": 30000}) + default_job_config = config.connection_config["default_query_job_config"] + assert int(default_job_config.job_timeout_ms) == 30000 + + +def test_bigquery_config_job_timeout_ms_overrides_query_timeout_ms() -> None: + """When both are set, job_timeout_ms wins (applied after query_timeout_ms).""" + config = BigQueryConfig( + connection_config={"project": "p", "dataset_id": "d", "query_timeout_ms": 1000, "job_timeout_ms": 30000} + ) + default_job_config = config.connection_config["default_query_job_config"] + assert int(default_job_config.job_timeout_ms) == 30000 diff --git a/tests/unit/adapters/test_bigquery/test_events_store.py b/tests/unit/adapters/test_bigquery/test_events_store.py new file mode 100644 index 000000000..ee05e6227 --- /dev/null +++ b/tests/unit/adapters/test_bigquery/test_events_store.py @@ -0,0 +1,44 @@ +"""Unit tests for BigQuery event store DDL emulator branching (regression for #473).""" + +import pytest + +from sqlspec.adapters.bigquery.config import BigQueryConfig +from sqlspec.adapters.bigquery.core import is_emulator_active_from_env +from sqlspec.adapters.bigquery.events import BigQueryEventQueueStore + + +def _make_store() -> BigQueryEventQueueStore: + cfg = BigQueryConfig( + connection_config={"project": "test", "dataset_id": "evt"}, + extension_config={"events": {"queue_table": "q"}}, + ) + return BigQueryEventQueueStore(cfg) + + +def test_is_emulator_active_from_env_reads_both_vars(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("BIGQUERY_EMULATOR_HOST", raising=False) + monkeypatch.delenv("BIGQUERY_EMULATOR_HOST_HTTP", raising=False) + assert is_emulator_active_from_env() is False + + monkeypatch.setenv("BIGQUERY_EMULATOR_HOST", "localhost:9050") + assert is_emulator_active_from_env() is True + + monkeypatch.delenv("BIGQUERY_EMULATOR_HOST") + monkeypatch.setenv("BIGQUERY_EMULATOR_HOST_HTTP", "http://localhost:9050") + assert is_emulator_active_from_env() is True + + +def test_events_store_omits_cluster_by_against_emulator(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("BIGQUERY_EMULATOR_HOST", "localhost:9050") + store = _make_store() + ddl = store._build_create_table_sql() + assert "CLUSTER BY" not in ddl + assert ddl.rstrip().endswith(")") + + +def test_events_store_keeps_cluster_by_without_emulator(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("BIGQUERY_EMULATOR_HOST", raising=False) + monkeypatch.delenv("BIGQUERY_EMULATOR_HOST_HTTP", raising=False) + store = _make_store() + ddl = store._build_create_table_sql() + assert "CLUSTER BY channel, status, available_at" in ddl From db1caab5c854eaccdee1ab6c0fadb6415f837733 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 26 May 2026 21:43:56 +0000 Subject: [PATCH 06/10] chore: rely on pytest-databases 0.19 fixtures --- pyproject.toml | 2 +- tests/conftest.py | 1 + tests/integration/conftest.py | 69 ------------------ .../test_rustfs_fixture_contract.py | 60 ---------------- uv.lock | 70 +++++++++++-------- 5 files changed, 42 insertions(+), 160 deletions(-) delete mode 100644 tests/integration/test_rustfs_fixture_contract.py diff --git a/pyproject.toml b/pyproject.toml index fe82b3b83..396515a57 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -148,7 +148,7 @@ test = [ "pytest>=8.0.0", "pytest-cov>=5.0.0", - "pytest-databases[postgres,oracle,bigquery,spanner]>=0.18.0", + "pytest-databases[postgres,oracle,bigquery,spanner]>=0.19.0", "pytest-mock>=3.14.0", "pytest-sugar>=1.0.0", "pytest-xdist>=3.6.1", diff --git a/tests/conftest.py b/tests/conftest.py index 95b237bc0..cda078fa4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -28,6 +28,7 @@ "pytest_databases.docker.bigquery", "pytest_databases.docker.spanner", "pytest_databases.docker.cockroachdb", + "pytest_databases.docker.rustfs", ] pytestmark = pytest.mark.anyio diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 9256c69f2..dfdcca295 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -1,25 +1,13 @@ """Pytest configuration and fixtures for integration tests.""" -import contextlib -import os from typing import TYPE_CHECKING, Any import pytest -from pytest_databases.docker.postgres import _provide_postgres_service -from pytest_databases.docker.rustfs import rustfs_default_bucket_name as rustfs_default_bucket_name -from pytest_databases.docker.rustfs import rustfs_secure as rustfs_secure -from pytest_databases.docker.rustfs import rustfs_service as rustfs_service -from pytest_databases.docker.rustfs import xdist_rustfs_isolation_level as xdist_rustfs_isolation_level from tests.fixtures.rustfs import ensure_rustfs_bucket if TYPE_CHECKING: - from collections.abc import Generator - - from pytest_databases._service import DockerService - from pytest_databases.docker.postgres import PostgresService from pytest_databases.docker.rustfs import RustfsService - from pytest_databases.types import XdistIsolationLevel @pytest.fixture @@ -44,64 +32,7 @@ def complex_data() -> list[dict[str, Any]]: ] -# HACK: Remove these overrides once pytest-databases ships non-default RustFS credentials. # noqa: FIX004 -# Tracking upstream: https://github.com/litestar-org/pytest-databases/issues/132 -@pytest.fixture(scope="session") -def rustfs_access_key() -> str: - """Return non-default RustFS credentials for native pytest-databases fixture startup.""" - return os.getenv("RUSTFS_ACCESS_KEY", "sqlspec-rustfs") - - -@pytest.fixture(scope="session") -def rustfs_secret_key() -> str: - """Return non-default RustFS credentials for native pytest-databases fixture startup.""" - return os.getenv("RUSTFS_SECRET_KEY", "sqlspec-rustfs-secret") - - @pytest.fixture(scope="session") def rustfs_bucket_name(rustfs_service: "RustfsService", rustfs_default_bucket_name: str) -> str: """Return the verified RustFS bucket used by S3-compatible integration tests.""" return ensure_rustfs_bucket(rustfs_service, rustfs_default_bucket_name) - - -# HACK: Remove this override once pytest-databases exposes a host-port hook. # noqa: FIX004 -# Tracking upstream: https://github.com/litestar-org/pytest-databases/issues/131 -@pytest.fixture(scope="session") -def pgvector_service( - docker_service: "DockerService", - pgvector_image: str, - xdist_postgres_isolation_level: "XdistIsolationLevel", - postgres_host: str, - postgres_user: str, - postgres_password: str, -) -> "Generator[PostgresService, None, None]": - """Override upstream pgvector_service to allow pinning the host port via SQLSPEC_PGVECTOR_PORT.""" - fixed = os.getenv("SQLSPEC_PGVECTOR_PORT") - if fixed: - import docker as docker_module - - client = docker_module.from_env() - name = "pytest_databases_pgvector" - for stale in client.containers.list(all=True, filters={"name": name}): - with contextlib.suppress(Exception): - stale.remove(force=True) - client.containers.run( - pgvector_image, - detach=True, - remove=True, - ports={"5432/tcp": int(fixed)}, - labels=["pytest_databases"], - name=name, - environment={"POSTGRES_PASSWORD": postgres_password}, - ) - - with _provide_postgres_service( - docker_service, - image=pgvector_image, - name="pgvector", - xdist_postgres_isolate=xdist_postgres_isolation_level, - host=postgres_host, - user=postgres_user, - password=postgres_password, - ) as service: - yield service diff --git a/tests/integration/test_rustfs_fixture_contract.py b/tests/integration/test_rustfs_fixture_contract.py deleted file mode 100644 index bc6184a30..000000000 --- a/tests/integration/test_rustfs_fixture_contract.py +++ /dev/null @@ -1,60 +0,0 @@ -"""Tests for the RustFS-backed S3 integration fixture contract.""" - -from pathlib import Path - -import tomli - -PROJECT_ROOT = Path(__file__).resolve().parents[2] -ROOT_CONFTEST = PROJECT_ROOT / "tests" / "conftest.py" -INTEGRATION_CONFTEST = PROJECT_ROOT / "tests" / "integration" / "conftest.py" -LIVE_TEST_PATHS = ( - ROOT_CONFTEST, - INTEGRATION_CONFTEST, - PROJECT_ROOT / "tests" / "integration" / "storage", - PROJECT_ROOT / "tests" / "integration" / "adapters" / "_storage_bridge_helpers.py", - PROJECT_ROOT / "tests" / "integration" / "adapters" / "asyncpg" / "test_storage_bridge.py", - PROJECT_ROOT / "tests" / "integration" / "adapters" / "duckdb" / "test_storage_bridge.py", - PROJECT_ROOT / "tests" / "integration" / "adapters" / "psqlpy" / "test_storage_bridge.py", - PROJECT_ROOT / "tests" / "integration" / "adapters" / "psycopg" / "test_storage_bridge.py", -) -STALE_MINIO_MARKERS = ( - "from minio import", - "MinioService", - "minio_client", - "minio_default_bucket_name", - "pytest_databases.docker.minio", - "register_minio_alias", -) - - -def _read_live_test_text() -> str: - parts: list[str] = [] - for path in LIVE_TEST_PATHS: - if path.is_dir(): - parts.extend(child.read_text(encoding="utf-8") for child in sorted(path.rglob("*.py"))) - else: - parts.append(path.read_text(encoding="utf-8")) - return "\n".join(parts) - - -def test_pytest_databases_test_extra_uses_rustfs_capable_release() -> None: - pyproject = tomli.loads((PROJECT_ROOT / "pyproject.toml").read_text(encoding="utf-8")) - test_deps = pyproject["dependency-groups"]["test"] - pytest_databases_deps = [dep for dep in test_deps if dep.startswith("pytest-databases")] - - assert pytest_databases_deps == ["pytest-databases[postgres,oracle,bigquery,spanner]>=0.18.0"] - - -def test_live_tests_use_rustfs_fixture_instead_of_minio_client() -> None: - live_test_text = _read_live_test_text() - root_conftest_text = ROOT_CONFTEST.read_text(encoding="utf-8") - integration_conftest_text = INTEGRATION_CONFTEST.read_text(encoding="utf-8") - - assert "rustfs" not in root_conftest_text.lower() - assert "pytest_databases.docker.rustfs" in live_test_text - assert "from pytest_databases.docker.rustfs import rustfs_service as rustfs_service" in integration_conftest_text - assert "def rustfs_service(" not in integration_conftest_text - assert "rustfs_service" in live_test_text - assert "rustfs_default_bucket_name" in live_test_text - for marker in STALE_MINIO_MARKERS: - assert marker not in live_test_text diff --git a/uv.lock b/uv.lock index 6e516fd99..7fe2fd8d2 100644 --- a/uv.lock +++ b/uv.lock @@ -1501,19 +1501,19 @@ wheels = [ [[package]] name = "faker" -version = "40.18.0" +version = "40.19.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tzdata", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/18/06/70886e82d8f1d2b73454f3a7c1b7405300128df22e70d85a828951366932/faker-40.18.0.tar.gz", hash = "sha256:2207575c0e8f90e6ccd6dbef764de875c614d16d3db4eee9712d9a00087f2e70", size = 1968243, upload-time = "2026-05-14T16:43:04.834Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/01/28c8ddae8caaf82c929655000963d83e3f01265a9af34e823c2ef2eee8ac/faker-40.19.1.tar.gz", hash = "sha256:76fa71fd3bf320db25e5504eb356f9a76b8a95cd6098524d006f446035b6b89d", size = 1969318, upload-time = "2026-05-22T15:57:37.433Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/84/0b/5c0b2d3a4b7a715f1835dd3f963bfbe841a02ae5cad1df8ee0325dfad235/faker-40.18.0-py3-none-any.whl", hash = "sha256:61a6b94b74605ddb090a065deb197a1c585ae7a874c094cf6693671d271e6083", size = 2006355, upload-time = "2026-05-14T16:43:02.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/b4/40a1ec12ec834604f3848143343baf1c67bc9a1096e401907eaa0d25876a/faker-40.19.1-py3-none-any.whl", hash = "sha256:265259b37c013838baaae34940207288170df385d6c5281413fce56a3504d580", size = 2007643, upload-time = "2026-05-22T15:57:35.867Z" }, ] [[package]] name = "fastapi" -version = "0.136.1" +version = "0.136.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-doc" }, @@ -1522,9 +1522,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5d/45/c130091c2dfa061bbfe3150f2a5091ef1adf149f2a8d2ae769ecaf6e99a2/fastapi-0.136.1.tar.gz", hash = "sha256:7af665ad7acfa0a3baf8983d393b6b471b9da10ede59c60045f49fbc89a0fa7f", size = 397448, upload-time = "2026-04-23T16:49:44.046Z" } +sdist = { url = "https://files.pythonhosted.org/packages/81/2d/ff8d91d7b564d464629a0fd50a4489c97fcb836ac230bf3a7269232a9b1f/fastapi-0.136.3.tar.gz", hash = "sha256:e487fae93ad408e6f47641ee4dfe389864fd7bec92e547ea8498fc13f43e83ab", size = 396410, upload-time = "2026-05-23T18:53:15.192Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5a/ff/2e4eca3ade2c22fe1dea7043b8ee9dabe47753349eb1b56a202de8af6349/fastapi-0.136.1-py3-none-any.whl", hash = "sha256:a6e9d7eeada96c93a4d69cb03836b44fa34e2854accb7244a1ece36cd4781c3f", size = 117683, upload-time = "2026-04-23T16:49:42.437Z" }, + { url = "https://files.pythonhosted.org/packages/e0/82/45359b62a067409bd929ae8a56b8ed13e5a8c8a61194b3c236920999ab83/fastapi-0.136.3-py3-none-any.whl", hash = "sha256:3d2a69bdf04b7e9f3afa292c3bc7a98816bbfafa10bc9b45f3f3700d2f761620", size = 117481, upload-time = "2026-05-23T18:53:16.924Z" }, ] [[package]] @@ -1791,7 +1791,7 @@ s3 = [ [[package]] name = "google-adk" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "aiosqlite" }, @@ -1808,6 +1808,7 @@ dependencies = [ { name = "packaging" }, { name = "pydantic" }, { name = "python-dotenv" }, + { name = "python-multipart" }, { name = "pyyaml" }, { name = "requests" }, { name = "starlette" }, @@ -1818,9 +1819,9 @@ dependencies = [ { name = "watchdog" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a5/08/e9af9ab3b0df422f9c9c07251840f8be876694852d3ac06dbe4e15ce01a7/google_adk-2.0.0.tar.gz", hash = "sha256:2f53c70b5de8409d427f0955bc89f1ba30a8397dec5aefa0ac7c3ecd1b4018d4", size = 3337944, upload-time = "2026-05-19T16:17:10.664Z" } +sdist = { url = "https://files.pythonhosted.org/packages/02/d2/58823ea0d5ac32143773d377b014123191ce420480c003190e1f86a9667c/google_adk-2.1.0.tar.gz", hash = "sha256:fd1709bf5e70e5aaa7d148c7b788d2cd00bb659ee10505a731bb2ad609d28968", size = 3340742, upload-time = "2026-05-23T00:13:56.793Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/79/f5/596e879aacad5214945ede50a4c8e4a0811979ecff83c91e0df11ef13961/google_adk-2.0.0-py3-none-any.whl", hash = "sha256:6d06d8e3b9119ccd7721505356f8c0a5253dcfdc426dd9a72e0fef1cf9ed4703", size = 3846211, upload-time = "2026-05-19T16:17:08.081Z" }, + { url = "https://files.pythonhosted.org/packages/0f/5b/56a9992b6f9447437f29e3691a487b16dfdea42aedec16ceced9de88b9f8/google_adk-2.1.0-py3-none-any.whl", hash = "sha256:4ec8a0ccdf8af90f9fa505c740cae921b47590aee3d61bca14f7bfa8bc886e4e", size = 3852259, upload-time = "2026-05-23T00:13:59.611Z" }, ] [[package]] @@ -2610,14 +2611,14 @@ wheels = [ [[package]] name = "joserfc" -version = "1.6.5" +version = "1.6.7" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3b/dc/5f768c2e391e9afabe5d18e3221346deb5fb6338565f1ccc9e7c6d7befdd/joserfc-1.6.5.tar.gz", hash = "sha256:1482a7db78fb4602e44ed89e51b599d052e091288c7c532c5b694e20149dec48", size = 231881, upload-time = "2026-05-06T04:58:13.408Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/cb/52e479f20804904f5df20ac4539d292dcecd1287aaa33cba1d1def1d9d8e/joserfc-1.6.7.tar.gz", hash = "sha256:6999fe89457069ecacd8cc797c88a805f83054dd883333fa0409f74b46479fd7", size = 232158, upload-time = "2026-05-23T01:46:44.069Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/54/3b/ad1cb22e75c963b1f07c8a2329bf47227ce7e4361df5eb2fb101b2ce33ef/joserfc-1.6.5-py3-none-any.whl", hash = "sha256:e9878a0f8243fe7b95e11fdda81374ca9f7a689e302751579d3dfdeec559675e", size = 70464, upload-time = "2026-05-06T04:58:11.668Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e4/bcf6718b5662894c6831f46296b73cd4b1a2e90c20b6d437e20c4997388c/joserfc-1.6.7-py3-none-any.whl", hash = "sha256:9e51e4a64840aa1734a058258e80a4480e2ff2d5686e480e7c92c954a92fbe05", size = 70603, upload-time = "2026-05-23T01:46:42.129Z" }, ] [[package]] @@ -4365,30 +4366,30 @@ wheels = [ [[package]] name = "polars" -version = "1.40.1" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "polars-runtime-32" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/b3/8c/bc9bc948058348ed43117cecc3007cd608f395915dae8a00974579a5dab1/polars-1.40.1.tar.gz", hash = "sha256:ab2694134b137596b5a59bfd7b4c54ebbc9b59f9403127f18e32d363777552e8", size = 733574, upload-time = "2026-04-22T19:15:55.507Z" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/13/fe30b3e2f9ab54a27d82af04fb2edc51c7342cbaa88815e175769a9f5901/polars-1.41.0.tar.gz", hash = "sha256:7cb5465eb66eb868fde779bf5c41c9f2f244481d72c52133e8ed10ba64372e4f", size = 737530, upload-time = "2026-05-22T20:20:56.209Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ea/91/74fc60d94488685a92ac9d49d7ec55f3e91fe9b77942a6235a5fa7f249c3/polars-1.40.1-py3-none-any.whl", hash = "sha256:c0f861219d1319cdea45c4ce4d30355a47176b8f98dcedf95ea8269f131b8abd", size = 828723, upload-time = "2026-04-22T19:14:25.452Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c8/5807714256c5f3de08593113df17f14f99417a451cb2d91530ad94785003/polars-1.41.0-py3-none-any.whl", hash = "sha256:35dcd24de88a198dc50929924f064ba12a0a0a4a3e77e116689491b4b3ab58ac", size = 832953, upload-time = "2026-05-22T20:19:30.958Z" }, ] [[package]] name = "polars-runtime-32" -version = "1.40.1" +version = "1.41.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/54/ba/26d40f039be9f552b5fd7365a621bdfc0f8e912ef77094ae4693491b0bae/polars_runtime_32-1.40.1.tar.gz", hash = "sha256:37f3065615d1bf90d03b5326222df4c5c1f8a5d33e50470aa588e3465e6eb814", size = 2935843, upload-time = "2026-04-22T19:15:57.26Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1e/8f/30dc715ea1135b4b80397edf33fe7b1bb124850e96e38d9918e2b3d6d0b0/polars_runtime_32-1.41.0.tar.gz", hash = "sha256:37ffbe5414f14bf43bcc8e08a0386c97c692e3fd4e87af74529d7f14b1b2d1cb", size = 2985826, upload-time = "2026-05-22T20:20:57.622Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/46/22c8af5eed68ac2eeb556e0fa3ca8a7b798e984ceff4450888f3b5ac61fd/polars_runtime_32-1.40.1-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b748ef652270cc49e9e69f99a035e0eb4d5f856d42bcd6ac4d9d80a40142aa1e", size = 52098755, upload-time = "2026-04-22T19:14:28.555Z" }, - { url = "https://files.pythonhosted.org/packages/c6/3e/48599a38009ca60ff82a6f38c8a621ce3c0286aa7397c7d79e741bd9060e/polars_runtime_32-1.40.1-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:d249b3743e05986060cec0a7aaa542d020df6c6b876e556023a310efd581f9be", size = 46367542, upload-time = "2026-04-22T19:14:32.433Z" }, - { url = "https://files.pythonhosted.org/packages/43/e9/384bc069367a1a36ee31c13782c178dbd039b2b873b772d4a0fc23a2373d/polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5987b30e7aa1059d069498496e8dda35afd592b0ac3d46ed87e3ff8df1ad652c", size = 50252104, upload-time = "2026-04-22T19:14:35.945Z" }, - { url = "https://files.pythonhosted.org/packages/15/ef/7d57ceb0651af74194e97ed6583e148d352f03d696090221b8059cdfc90b/polars_runtime_32-1.40.1-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d7f42a8b3f16fc66002cc0f6516f7dd7653396886ae0ed362ab95c0b3408b59", size = 56250788, upload-time = "2026-04-22T19:14:39.743Z" }, - { url = "https://files.pythonhosted.org/packages/10/0f/e4b3ffc748827a14a474ec9c42e45c066050e440fec57e914091d9adda75/polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:e5f7becc237a7ec9d9a10878dc8e54b73bbf4e2d94a2991c37d7a0b38590d8f9", size = 50432590, upload-time = "2026-04-22T19:14:43.388Z" }, - { url = "https://files.pythonhosted.org/packages/d9/0b/b8d95fbed869fa4caabe9c400e4210374913b376e925e96fdcfa9be6416b/polars_runtime_32-1.40.1-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:992d14cf191dde043d36fbdbc98a65e43fbc7e9a5024cecd45f838ac4988c1ee", size = 54155564, upload-time = "2026-04-22T19:14:47.239Z" }, - { url = "https://files.pythonhosted.org/packages/06/d9/d091d8fb5cbed5e9536adfed955c4c89987a4cc3b8e73ae4532402b91c74/polars_runtime_32-1.40.1-cp310-abi3-win_amd64.whl", hash = "sha256:f78bb2abd00101cbb23cc0cb068f7e36e081057a15d2ec2dde3dda280709f030", size = 51829755, upload-time = "2026-04-22T19:14:50.85Z" }, - { url = "https://files.pythonhosted.org/packages/65/ad/b33c3022a394f3eb55c3310597cec615412a8a33880055eee191d154a628/polars_runtime_32-1.40.1-cp310-abi3-win_arm64.whl", hash = "sha256:b5cbfaf6b085b420b4bfcbe24e8f665076d1cccfdb80c0484c02a023ce205537", size = 45822104, upload-time = "2026-04-22T19:14:54.192Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/9d1630d666eca6a67e2096c0ed2c0e18f1355fe440043fd0830de1b71ab6/polars_runtime_32-1.41.0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:766b60c74550382731b604ed62a385a8403b341bf18282d3fd2f746fa3c4cafd", size = 52163350, upload-time = "2026-05-22T20:19:34.431Z" }, + { url = "https://files.pythonhosted.org/packages/11/b3/01538d51cd2790729ae13c23db44bc787bdfe20867faeb1087afc390c53b/polars_runtime_32-1.41.0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:bee0d294daca79cedd5749e1bf3373c2d4107eb849fe544a60df6c08abc972ce", size = 46474331, upload-time = "2026-05-22T20:19:37.936Z" }, + { url = "https://files.pythonhosted.org/packages/79/29/efa82e1b3e6711f254df3793f3d3fd99f26ef1bcaffa6533266fa6522de4/polars_runtime_32-1.41.0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08b3f915468bf00d327b4a1236935a4ec3174dcb163785fcd98185ad1319a503", size = 50358997, upload-time = "2026-05-22T20:19:42.209Z" }, + { url = "https://files.pythonhosted.org/packages/53/18/5a04b06b773047cbf43912ab802eef3c1d50ef7e66d51a41b16726f9bc62/polars_runtime_32-1.41.0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72269f768c57229190dba0cb5abb8a1b228e96cc6331273a77a7957576885bd", size = 56332032, upload-time = "2026-05-22T20:19:45.928Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d5/d728ce7a39ea925555db7d2c9f7b5df3ca17568e483db6501783f653e0b9/polars_runtime_32-1.41.0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1532c7560a5c0fd06943080ee42aade721f186becdfbb1baafa622e3199c3b62", size = 50529017, upload-time = "2026-05-22T20:19:49.489Z" }, + { url = "https://files.pythonhosted.org/packages/0b/92/6b2092dfed4278f636499217767204fce19ed72695690e2c4eba99e2892e/polars_runtime_32-1.41.0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:43368d8a754ee274f1e1099a7c09aae59cd69d22b8a6f83c0f782a00cd3a6662", size = 54244707, upload-time = "2026-05-22T20:19:52.852Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/46b43be0f5becbef65843f438de3950ac6f8d0fa0008d7de0025eed00097/polars_runtime_32-1.41.0-cp310-abi3-win_amd64.whl", hash = "sha256:a9bd6095ecadc6799d166b9e8f7183a7ca8ba0a5aef8a426ec41df8ed8b09df7", size = 51918379, upload-time = "2026-05-22T20:19:55.832Z" }, + { url = "https://files.pythonhosted.org/packages/a3/da/30f15f0c3959b70e7a6583eccd37140a30b5c643ca374792d150b3a357df/polars_runtime_32-1.41.0-cp310-abi3-win_arm64.whl", hash = "sha256:ed922400f0eb393345fd7b6874b150eb943af2b816297a3dde03735cb5f3de08", size = 45921961, upload-time = "2026-05-22T20:19:59.525Z" }, ] [[package]] @@ -5294,16 +5295,16 @@ wheels = [ [[package]] name = "pytest-databases" -version = "0.18.0" +version = "0.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "docker" }, { name = "filelock" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f7/ce/e0458cdb8d84b14156392b4bbc5575b6145b4ae6c3962a500e3d66002da3/pytest_databases-0.18.0.tar.gz", hash = "sha256:d49fa4e85494ec33dd6224affada1ddfdb83736b28f5ae40377220ad5dbbb658", size = 324907, upload-time = "2026-05-12T13:43:52.438Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ab/4e/e899556b3876eec2db9bd630ad3054ced94a9541c26319bec5c4cd00579d/pytest_databases-0.19.0.tar.gz", hash = "sha256:ba7b8e51b551455daf3bd144384f6d4fba23d747b001f071795b02e6be2a3cbf", size = 350034, upload-time = "2026-05-23T18:33:49.262Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d2/ae/129e2529248eceb422e9051f408f752a78c80628700ffde94cc9edbf2d9d/pytest_databases-0.18.0-py3-none-any.whl", hash = "sha256:e2114c9e36ec7f4ff118453e9511e5cffb85294214838a70a8dee36c40340bd1", size = 40309, upload-time = "2026-05-12T13:43:51.033Z" }, + { url = "https://files.pythonhosted.org/packages/de/f4/d6e2edae47a0421a8531a9805fb787b993380222f00dc25cee1634f55f3c/pytest_databases-0.19.0-py3-none-any.whl", hash = "sha256:d14651e23a716ed6f2317bf9ac317c9e7891db701253abf78e9cef1049e8f26b", size = 41525, upload-time = "2026-05-23T18:33:47.983Z" }, ] [package.optional-dependencies] @@ -5404,6 +5405,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, ] +[[package]] +name = "python-multipart" +version = "0.0.29" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, +] + [[package]] name = "pytz" version = "2026.2" @@ -6919,7 +6929,7 @@ dev = [ { name = "pyright", specifier = ">=1.1.386" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-cov", specifier = ">=5.0.0" }, - { name = "pytest-databases", extras = ["postgres", "oracle", "bigquery", "spanner"], specifier = ">=0.18.0" }, + { name = "pytest-databases", extras = ["postgres", "oracle", "bigquery", "spanner"], specifier = ">=0.19.0" }, { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "pytest-sugar", specifier = ">=1.0.0" }, { name = "pytest-timeout", specifier = ">=2.3.1" }, @@ -7011,7 +7021,7 @@ test = [ { name = "coverage", specifier = ">=7.6.1" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-cov", specifier = ">=5.0.0" }, - { name = "pytest-databases", extras = ["postgres", "oracle", "bigquery", "spanner"], specifier = ">=0.18.0" }, + { name = "pytest-databases", extras = ["postgres", "oracle", "bigquery", "spanner"], specifier = ">=0.19.0" }, { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "pytest-sugar", specifier = ">=1.0.0" }, { name = "pytest-timeout", specifier = ">=2.3.1" }, From 481cb277fa2dc2d8d1c082ead89581c8aa8c74f7 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 26 May 2026 22:21:13 +0000 Subject: [PATCH 07/10] fix: bump version to 0.48.0 in pyproject.toml and uv.lock --- pyproject.toml | 5 +++-- uv.lock | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 396515a57..f0376b69c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ maintainers = [{ name = "Litestar Developers", email = "hello@litestar.dev" }] name = "sqlspec" readme = "README.md" requires-python = ">=3.10, <4.0" -version = "0.47.0" +version = "0.48.0" [project.urls] Discord = "https://discord.gg/litestar" @@ -192,6 +192,7 @@ packages = ["sqlspec"] # Keep that smoke build from auto-discovering ignored scratch directories. packages = [] + [tool.hatch.build.targets.wheel.hooks.mypyc] dependencies = ["hatch-mypyc", "hatch-cython", "mypy>=2.0.0"] enable-by-default = false @@ -291,7 +292,7 @@ opt_level = "3" # Maximum optimization (0-3) allow_dirty = true commit = false commit_args = "--no-verify" -current_version = "0.47.0" +current_version = "0.48.0" ignore_missing_files = false ignore_missing_version = false message = "chore(release): bump to v{new_version}" diff --git a/uv.lock b/uv.lock index 7fe2fd8d2..aebb77ad6 100644 --- a/uv.lock +++ b/uv.lock @@ -6513,7 +6513,7 @@ wheels = [ [[package]] name = "sqlspec" -version = "0.47.0" +version = "0.48.0" source = { editable = "." } dependencies = [ { name = "mypy-extensions" }, From c2aec075942355ac964e7dd40cc3cdda8163cdb9 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 26 May 2026 22:24:43 +0000 Subject: [PATCH 08/10] revert(bigquery): restore unconditional CLUSTER BY on events store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous commit branched _table_clause() on BIGQUERY_EMULATOR_HOST to omit CLUSTER BY when running against goccy/bigquery-emulator. That downgraded production BigQuery DDL to work around an unofficial emulator's gap. Production BigQuery supports CLUSTER BY on the events queue table and benefits from clustering on (channel, status, available_at) for polling queries. The emulator's lack of support is a downstream limitation; emulator-only tests should skip or xfail rather than reshape product behavior. Reverts the events store DDL branch and the is_emulator_active_from_env helper extraction. detect_emulator() goes back to the inline env-var check. The job_timeout_ms wiring on config.py remains — it is unrelated to the CLUSTER BY question and addresses the dead TypedDict field that was declared but never read. --- sqlspec/adapters/bigquery/core.py | 13 +----- sqlspec/adapters/bigquery/events/store.py | 9 +--- .../test_bigquery/test_events_store.py | 44 ------------------- 3 files changed, 3 insertions(+), 63 deletions(-) delete mode 100644 tests/unit/adapters/test_bigquery/test_events_store.py diff --git a/sqlspec/adapters/bigquery/core.py b/sqlspec/adapters/bigquery/core.py index 273e90abb..bc267778a 100644 --- a/sqlspec/adapters/bigquery/core.py +++ b/sqlspec/adapters/bigquery/core.py @@ -60,7 +60,6 @@ "detect_emulator", "driver_profile", "extract_insert_table", - "is_emulator_active_from_env", "is_simple_insert", "normalize_script_rowcount", "resolve_column_names", @@ -336,18 +335,10 @@ def _inline_bigquery_literals( return str(transformed_expression.sql(dialect="bigquery")) -def is_emulator_active_from_env() -> bool: - """Return True if a BigQuery emulator endpoint is set via environment. - - Cheap env-only check for code paths that do not have a connection - handle yet (e.g. events store DDL builders constructed from config). - """ - return bool(os.getenv("BIGQUERY_EMULATOR_HOST") or os.getenv("BIGQUERY_EMULATOR_HOST_HTTP")) - - def detect_emulator(connection: "BigQueryConnection") -> bool: """Detect whether the BigQuery client targets an emulator endpoint.""" - if is_emulator_active_from_env(): + emulator_host = os.getenv("BIGQUERY_EMULATOR_HOST") or os.getenv("BIGQUERY_EMULATOR_HOST_HTTP") + if emulator_host: return True try: diff --git a/sqlspec/adapters/bigquery/events/store.py b/sqlspec/adapters/bigquery/events/store.py index ff868a224..dcaa64874 100644 --- a/sqlspec/adapters/bigquery/events/store.py +++ b/sqlspec/adapters/bigquery/events/store.py @@ -13,7 +13,6 @@ """ from sqlspec.adapters.bigquery.config import BigQueryConfig -from sqlspec.adapters.bigquery.core import is_emulator_active_from_env from sqlspec.extensions.events import BaseEventQueueStore __all__ = ("BigQueryEventQueueStore",) @@ -75,13 +74,7 @@ def _timestamp_default(self) -> str: return "CURRENT_TIMESTAMP()" def _table_clause(self) -> str: - """Return BigQuery CLUSTER BY clause for query optimization. - - Omitted when running against the BigQuery emulator, which rejects - CLUSTER BY on the events table. - """ - if is_emulator_active_from_env(): - return "" + """Return BigQuery CLUSTER BY clause for query optimization.""" return " CLUSTER BY channel, status, available_at" def _build_create_table_sql(self) -> str: diff --git a/tests/unit/adapters/test_bigquery/test_events_store.py b/tests/unit/adapters/test_bigquery/test_events_store.py deleted file mode 100644 index ee05e6227..000000000 --- a/tests/unit/adapters/test_bigquery/test_events_store.py +++ /dev/null @@ -1,44 +0,0 @@ -"""Unit tests for BigQuery event store DDL emulator branching (regression for #473).""" - -import pytest - -from sqlspec.adapters.bigquery.config import BigQueryConfig -from sqlspec.adapters.bigquery.core import is_emulator_active_from_env -from sqlspec.adapters.bigquery.events import BigQueryEventQueueStore - - -def _make_store() -> BigQueryEventQueueStore: - cfg = BigQueryConfig( - connection_config={"project": "test", "dataset_id": "evt"}, - extension_config={"events": {"queue_table": "q"}}, - ) - return BigQueryEventQueueStore(cfg) - - -def test_is_emulator_active_from_env_reads_both_vars(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("BIGQUERY_EMULATOR_HOST", raising=False) - monkeypatch.delenv("BIGQUERY_EMULATOR_HOST_HTTP", raising=False) - assert is_emulator_active_from_env() is False - - monkeypatch.setenv("BIGQUERY_EMULATOR_HOST", "localhost:9050") - assert is_emulator_active_from_env() is True - - monkeypatch.delenv("BIGQUERY_EMULATOR_HOST") - monkeypatch.setenv("BIGQUERY_EMULATOR_HOST_HTTP", "http://localhost:9050") - assert is_emulator_active_from_env() is True - - -def test_events_store_omits_cluster_by_against_emulator(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setenv("BIGQUERY_EMULATOR_HOST", "localhost:9050") - store = _make_store() - ddl = store._build_create_table_sql() - assert "CLUSTER BY" not in ddl - assert ddl.rstrip().endswith(")") - - -def test_events_store_keeps_cluster_by_without_emulator(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.delenv("BIGQUERY_EMULATOR_HOST", raising=False) - monkeypatch.delenv("BIGQUERY_EMULATOR_HOST_HTTP", raising=False) - store = _make_store() - ddl = store._build_create_table_sql() - assert "CLUSTER BY channel, status, available_at" in ddl From 4bcb4844ab413aa5e715bc642320a757436ac099 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 26 May 2026 22:47:21 +0000 Subject: [PATCH 09/10] chore(bigquery): drop emulator detection and the retry carve-out detect_emulator()'s only production consumer was build_retry(), which returned None (no retry policy) when running against the BigQuery emulator. That is an emulator-specific product-behavior fork on the same footing as the just-reverted CLUSTER BY skip. Continues the cleanup direction set by 6de55be5 ("remove emulator check for simple inserts in BigQuery driver") and c9695de7 (the bulk load skip it eventually removed). build_retry() now always returns the production Retry policy. BigQueryDriver loses the _using_emulator slot. Retries that misbehave against the emulator should surface as test failures and those tests should pytest.skip / pytest.xfail at the call site, same pattern as test_explain.py and test_exceptions.py already use. --- sqlspec/adapters/bigquery/core.py | 27 +-------------------------- sqlspec/adapters/bigquery/driver.py | 5 +---- 2 files changed, 2 insertions(+), 30 deletions(-) diff --git a/sqlspec/adapters/bigquery/core.py b/sqlspec/adapters/bigquery/core.py index bc267778a..20bc30dd9 100644 --- a/sqlspec/adapters/bigquery/core.py +++ b/sqlspec/adapters/bigquery/core.py @@ -3,7 +3,6 @@ import datetime import importlib import io -import os from decimal import Decimal from typing import TYPE_CHECKING, Any, cast @@ -57,7 +56,6 @@ "create_mapped_exception", "create_parameters", "default_statement_config", - "detect_emulator", "driver_profile", "extract_insert_table", "is_simple_insert", @@ -335,27 +333,6 @@ def _inline_bigquery_literals( return str(transformed_expression.sql(dialect="bigquery")) -def detect_emulator(connection: "BigQueryConnection") -> bool: - """Detect whether the BigQuery client targets an emulator endpoint.""" - emulator_host = os.getenv("BIGQUERY_EMULATOR_HOST") or os.getenv("BIGQUERY_EMULATOR_HOST_HTTP") - if emulator_host: - return True - - try: - inner_connection = cast("Any", connection)._connection - except AttributeError: - inner_connection = None - if inner_connection is None: - return False - try: - api_base_url = inner_connection.API_BASE_URL - except AttributeError: - api_base_url = "" - if not api_base_url: - return False - return "googleapis.com" not in api_base_url - - def _should_retry_bigquery_job(exception: Exception) -> bool: """Return True when a BigQuery job exception is safe to retry.""" if not isinstance(exception, GoogleCloudError): @@ -381,10 +358,8 @@ def _should_retry_bigquery_job(exception: Exception) -> bool: return False -def build_retry(deadline: float, using_emulator: bool) -> "Retry | None": +def build_retry(deadline: float) -> "Retry": """Build retry policy for job restarts based on error reason codes.""" - if using_emulator: - return None return Retry(predicate=_should_retry_bigquery_job, deadline=deadline) diff --git a/sqlspec/adapters/bigquery/driver.py b/sqlspec/adapters/bigquery/driver.py index fa2772dac..4aea8bcad 100644 --- a/sqlspec/adapters/bigquery/driver.py +++ b/sqlspec/adapters/bigquery/driver.py @@ -20,7 +20,6 @@ collect_rows, create_mapped_exception, default_statement_config, - detect_emulator, driver_profile, is_simple_insert, normalize_script_rowcount, @@ -103,7 +102,6 @@ class BigQueryDriver(SyncDriverAdapterBase): "_json_serializer", "_literal_inliner", "_type_converter", - "_using_emulator", ) dialect = "bigquery" @@ -132,9 +130,8 @@ def __init__( self._default_query_job_config: QueryJobConfig | None = (driver_features or {}).get("default_query_job_config") self._data_dictionary: BigQueryDataDictionary | None = None self._column_name_cache: dict[int, tuple[Any, list[str]]] = {} - self._using_emulator = detect_emulator(connection) self._job_retry_deadline = float(features.get("job_retry_deadline", 60.0)) - self._job_retry = build_retry(self._job_retry_deadline, self._using_emulator) + self._job_retry = build_retry(self._job_retry_deadline) # ───────────────────────────────────────────────────────────────────────────── # CORE DISPATCH METHODS From 222bd125d79e22caa0be03ec7b933fdd77e8832c Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 26 May 2026 23:24:49 +0000 Subject: [PATCH 10/10] test(spanner): use read sessions in read-only assertions --- tests/integration/adapters/spanner/test_exceptions.py | 4 ++-- tests/integration/adapters/spanner/test_execute_many.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/integration/adapters/spanner/test_exceptions.py b/tests/integration/adapters/spanner/test_exceptions.py index 811a26974..86f5a77a7 100644 --- a/tests/integration/adapters/spanner/test_exceptions.py +++ b/tests/integration/adapters/spanner/test_exceptions.py @@ -17,7 +17,7 @@ def test_dml_in_read_only_session(spanner_config: SpannerSyncConfig, test_users_table: str) -> None: """Test that DML in read-only session raises SQLConversionError.""" - with spanner_config.provide_session() as session: + with spanner_config.provide_read_session() as session: with pytest.raises(SQLConversionError, match="Cannot execute DML"): session.execute( f"INSERT INTO {test_users_table} (id, name, email, age) VALUES (@id, @name, @email, @age)", @@ -79,7 +79,7 @@ def test_execute_many_in_read_only_session(spanner_config: SpannerSyncConfig, te {"id": str(uuid4()), "name": "User 2", "email": "u2@example.com", "age": 25}, ] - with spanner_config.provide_session() as session: + with spanner_config.provide_read_session() as session: with pytest.raises(SQLConversionError, match="execute_many requires"): session.execute_many( f"INSERT INTO {test_users_table} (id, name, email, age) VALUES (@id, @name, @email, @age)", parameters diff --git a/tests/integration/adapters/spanner/test_execute_many.py b/tests/integration/adapters/spanner/test_execute_many.py index 9bf29deb3..9a055ecef 100644 --- a/tests/integration/adapters/spanner/test_execute_many.py +++ b/tests/integration/adapters/spanner/test_execute_many.py @@ -153,7 +153,7 @@ def test_execute_many_requires_write_session(spanner_config: SpannerSyncConfig, """Test that execute_many fails on read-only Snapshot.""" parameters = [{"id": str(uuid4()), "name": "Fail", "email": "fail@example.com", "age": 30}] - with spanner_config.provide_session() as session: + with spanner_config.provide_read_session() as session: with pytest.raises(SQLConversionError, match="execute_many requires"): session.execute_many( f"INSERT INTO {test_users_table} (id, name, email, age) VALUES (@id, @name, @email, @age)", parameters