diff --git a/docs/changelog.rst b/docs/changelog.rst index 30bab08a6..7bdc92abe 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,8 +9,19 @@ important operational fixes. Recent Updates ============== -Unreleased - Compiled async exception handling ------------------------------------------------------------------------------- +Unreleased +------------------------------------------------------------------------------- + +**Changed:** + +* Explicit database cancellation now raises + :class:`~sqlspec.exceptions.OperationCancelledError`, while elapsed timeouts + and deadlines continue to raise + :class:`~sqlspec.exceptions.QueryTimeoutError`. The two exceptions are + siblings under :class:`~sqlspec.exceptions.OperationalError`. Applications + that used ``QueryTimeoutError`` for both outcomes should catch both exception + types, or catch ``OperationalError`` when they do not need to distinguish + cancellation from timeout. **Fixed:** diff --git a/docs/reference/adapters/adbc.rst b/docs/reference/adapters/adbc.rst index 7301d879f..e0dcc7392 100644 --- a/docs/reference/adapters/adbc.rst +++ b/docs/reference/adapters/adbc.rst @@ -279,7 +279,7 @@ SQLSpec does not define a portable SQL statistics contract. It wraps ``adbc_get_statistics`` directly; unsupported drivers raise :exc:`sqlspec.exceptions.OperationalError`. -In the replacement data dictionary, ADBC statistics are also exposed through the +In the data dictionary, ADBC statistics are also exposed through the opt-in system metadata namespace as transport metadata. This does not make ADBC a lossless DDL or dependency source; dialect query packs remain canonical for DDL-grade metadata. diff --git a/docs/reference/driver.rst b/docs/reference/driver.rst index a3bf08e2a..6d9ae5565 100644 --- a/docs/reference/driver.rst +++ b/docs/reference/driver.rst @@ -40,7 +40,7 @@ Asynchronous Driver Data Dictionary =============== -The shared data dictionary base classes define the replacement metadata +The shared data dictionary base classes define the metadata contract used by adapter-local dictionaries. User-facing examples and the support matrix live in :doc:`../usage/data_dictionary`. In short: diff --git a/docs/reference/exceptions.rst b/docs/reference/exceptions.rst index aefd4abd8..c368e52a1 100644 --- a/docs/reference/exceptions.rst +++ b/docs/reference/exceptions.rst @@ -131,6 +131,18 @@ Execution :members: :show-inheritance: +.. autoclass:: OperationCancelledError + :members: + :show-inheritance: + +``OperationCancelledError`` and ``QueryTimeoutError`` are sibling operational +errors. Catch ``OperationCancelledError`` for explicit caller or operator +cancellation, and ``QueryTimeoutError`` for elapsed statement timeouts and +deadlines. ADBC ``CANCELLED`` and ``TIMEOUT`` statuses follow this distinction. +Callers that previously caught ``QueryTimeoutError`` for cancellation should +catch both exceptions during migration, or catch ``OperationalError`` when the +distinction is not relevant. + .. autoclass:: DataError :members: :show-inheritance: @@ -223,6 +235,7 @@ Inheritance Tree +-- DataError +-- OperationalError | +-- QueryTimeoutError + | +-- OperationCancelledError +-- StackExecutionError +-- StorageOperationFailedError | +-- FileNotFoundInStorageError diff --git a/sqlspec/adapters/adbc/core.py b/sqlspec/adapters/adbc/core.py index 66167fece..e51205d6f 100644 --- a/sqlspec/adapters/adbc/core.py +++ b/sqlspec/adapters/adbc/core.py @@ -32,12 +32,15 @@ ImproperConfigurationError, IntegrityError, NotNullViolationError, + OperationalError, + OperationCancelledError, PermissionDeniedError, QueryTimeoutError, SerializationConflictError, SQLParsingError, SQLSpecError, UniqueViolationError, + _classify_timeout_or_cancellation, map_sqlstate_to_exception, ) from sqlspec.typing import PGVECTOR_INSTALLED, Empty @@ -538,6 +541,13 @@ def prepare_postgres_parameters( ) -> Any: """Prepare Postgres parameters with cast-aware coercion.""" postgres_compatible = normalize_postgres_empty_parameters(dialect, parameters) + converter = get_adbc_type_converter(dialect) + if isinstance(postgres_compatible, (list, tuple)) and hasattr(converter, "convert_sequence"): + seq = [ + converter.convert_sequence(item) if isinstance(item, (list, tuple)) else item + for item in postgres_compatible + ] + postgres_compatible = tuple(seq) if isinstance(postgres_compatible, tuple) else seq if not parameter_casts: return postgres_compatible return prepare_parameters_with_casts( @@ -565,6 +575,12 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec A SQLSpec exception that wraps the original error """ del logger + status_code = getattr(error, "status_code", None) + status_name = getattr(status_code, "name", str(status_code)).upper() + if status_name == "TIMEOUT": + return _create_adbc_error(error, QueryTimeoutError, "query timeout") + if status_name == "CANCELLED": + return _create_adbc_error(error, OperationCancelledError, "operation cancelled") sqlstate_attr = error.sqlstate if has_sqlstate(error) else None sqlstate = sqlstate_attr if sqlstate_attr is not None else None @@ -585,9 +601,9 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec if sqlstate == "40001": return _create_adbc_error(error, SerializationConflictError, "serialization failure") - # Query timeout/cancellation if sqlstate == "57014": - return _create_adbc_error(error, QueryTimeoutError, "query canceled") + termination_class = _classify_timeout_or_cancellation(str(error)) or OperationalError + return _create_adbc_error(error, termination_class, "query terminated") # Permission errors if sqlstate == "42501": @@ -625,9 +641,8 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec if "serialization" in error_msg or "concurrent update" in error_msg: return _create_adbc_error(error, DeadlockError, "serialization failure") - # Timeout/cancellation patterns - if "timeout" in error_msg or "cancel" in error_msg or "interrupt" in error_msg: - return _create_adbc_error(error, QueryTimeoutError, "query timeout") + if message_class := _classify_timeout_or_cancellation(error_msg): + return _create_adbc_error(error, message_class, "query terminated") # Permission patterns if "permission" in error_msg or "denied" in error_msg or "unauthorized" in error_msg: @@ -1226,6 +1241,20 @@ def _prepare_parameter_sequence_with_casts( result.append(param) elif isinstance(param, dict): result.append(converter.convert_dict(param)) + elif isinstance(param, (list, tuple)): + if type_map and dispatcher is not None: + exact_converter = type_map.get(type(param)) + if exact_converter is not None: + param = exact_converter(param) + else: + converter_func = dispatcher.get(param) + if converter_func is not None: + param = converter_func(param) + elif hasattr(converter, "convert_sequence"): + param = converter.convert_sequence(param) + elif hasattr(converter, "convert_sequence"): + param = converter.convert_sequence(param) + result.append(param) else: if type_map and dispatcher is not None: exact_converter = type_map.get(type(param)) diff --git a/sqlspec/adapters/adbc/type_converter.py b/sqlspec/adapters/adbc/type_converter.py index e392690c2..2c96742a8 100644 --- a/sqlspec/adapters/adbc/type_converter.py +++ b/sqlspec/adapters/adbc/type_converter.py @@ -38,6 +38,23 @@ def convert_dict(self, value: "dict[str, Any]") -> Any: return to_json(value) return value + def convert_sequence(self, value: "list[Any] | tuple[Any, ...]") -> "list[Any]": + """Convert sequence/array parameter values with dialect awareness. + + Preserves None elements within sequence parameters instead of converting + them to empty strings for PostgreSQL-family dialects. + + Args: + value: Sequence to convert. + + Returns: + Converted list parameter appropriate for the dialect. + """ + items = list(value) + if self.dialect in {"postgres", "postgresql", "pgvector", "paradedb"}: + return [item if item is not None else None for item in items] + return items + def get_adbc_type_converter(dialect: str) -> ADBCOutputConverter: """Factory function to create dialect-specific ADBC type converter. diff --git a/sqlspec/adapters/aiomysql/data_dictionary.py b/sqlspec/adapters/aiomysql/data_dictionary.py index ae4c3e21c..16da24f4c 100644 --- a/sqlspec/adapters/aiomysql/data_dictionary.py +++ b/sqlspec/adapters/aiomysql/data_dictionary.py @@ -80,7 +80,7 @@ async def get_feature_flag(self, driver: "AiomysqlDriver", feature: str) -> bool async def get_metadata_capabilities( self, driver: "AiomysqlDriver", domains: "Sequence[str] | None" = None ) -> "MetadataCapabilityProfile": - """Get replacement data-dictionary capability profile.""" + """Get data-dictionary capability profile.""" engine_version = await self._get_engine_version(driver) dialect = engine_version.engine_family if engine_version is not None else type(self).dialect requested_domains = None if domains is None else tuple(domains) diff --git a/sqlspec/adapters/aiosqlite/core.py b/sqlspec/adapters/aiosqlite/core.py index 08470bc82..6e6b25891 100644 --- a/sqlspec/adapters/aiosqlite/core.py +++ b/sqlspec/adapters/aiosqlite/core.py @@ -21,8 +21,8 @@ IntegrityError, NotNullViolationError, OperationalError, + OperationCancelledError, PermissionDeniedError, - QueryTimeoutError, SQLParsingError, SQLSpecError, UniqueViolationError, @@ -360,9 +360,9 @@ def create_mapped_exception(error: BaseException, *, logger: Any | None = None) # Query interruption (timeout-like behavior) if error_code == SQLITE_INTERRUPT_CODE or error_name == "SQLITE_INTERRUPT": - return _create_aiosqlite_error(error, error_code, QueryTimeoutError, "query interrupted") + return _create_aiosqlite_error(error, error_code, OperationCancelledError, "query interrupted") if "interrupt" in error_msg: - return _create_aiosqlite_error(error, error_code or 0, QueryTimeoutError, "query interrupted") + return _create_aiosqlite_error(error, error_code or 0, OperationCancelledError, "query interrupted") # Permission errors if error_code == SQLITE_PERM_CODE or error_name == "SQLITE_PERM": diff --git a/sqlspec/adapters/asyncmy/data_dictionary.py b/sqlspec/adapters/asyncmy/data_dictionary.py index a2b8aa107..424cce72a 100644 --- a/sqlspec/adapters/asyncmy/data_dictionary.py +++ b/sqlspec/adapters/asyncmy/data_dictionary.py @@ -81,7 +81,7 @@ async def get_feature_flag(self, driver: "AsyncmyDriver", feature: str) -> bool: async def get_metadata_capabilities( self, driver: "AsyncmyDriver", domains: "Sequence[str] | None" = None ) -> "MetadataCapabilityProfile": - """Get replacement data-dictionary capability profile.""" + """Get data-dictionary capability profile.""" engine_version = await self._get_engine_version(driver) dialect = engine_version.engine_family if engine_version is not None else type(self).dialect requested_domains = None if domains is None else tuple(domains) diff --git a/sqlspec/adapters/asyncpg/core.py b/sqlspec/adapters/asyncpg/core.py index 68c7034bb..bdb7449ff 100644 --- a/sqlspec/adapters/asyncpg/core.py +++ b/sqlspec/adapters/asyncpg/core.py @@ -21,12 +21,14 @@ ForeignKeyViolationError, IntegrityError, NotNullViolationError, + OperationalError, + OperationCancelledError, PermissionDeniedError, - QueryTimeoutError, SerializationConflictError, SQLParsingError, SQLSpecError, UniqueViolationError, + _classify_timeout_or_cancellation, map_sqlstate_to_exception, ) from sqlspec.typing import PGVECTOR_INSTALLED @@ -302,7 +304,7 @@ def resolve_many_rowcount(parameter_sets: Any, *, fallback_count: "int | None" = asyncpg.exceptions.SerializationError, ("40001", SerializationConflictError, "serialization failure") ) _EXCEPTION_MAPPING_DISPATCHER.register( - asyncpg.exceptions.QueryCanceledError, ("57014", QueryTimeoutError, "query canceled") + asyncpg.exceptions.QueryCanceledError, ("57014", OperationCancelledError, "query canceled") ) _EXCEPTION_MAPPING_DISPATCHER.register( asyncpg.exceptions.InsufficientPrivilegeError, ("42501", PermissionDeniedError, "insufficient privilege") @@ -347,12 +349,18 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec mapped_error = _EXCEPTION_MAPPING_DISPATCHER.get(error) if mapped_error is not None: error_code, error_class, description = mapped_error + if error_code == "57014": + termination_class = _classify_timeout_or_cancellation(str(error)) or OperationalError + return _create_postgres_error(error, error_code, termination_class, description) return _create_postgres_error(error, error_code, error_class, description) # Priority 2: Fall back to SQLSTATE code mapping using centralized utility sqlstate_attr = error.sqlstate if has_sqlstate(error) else None sqlstate_code: str | None = sqlstate_attr if sqlstate_attr is not None else None if sqlstate_code: + if sqlstate_code == "57014": + termination_class = _classify_timeout_or_cancellation(str(error)) or OperationalError + return _create_postgres_error(error, sqlstate_code, termination_class, "query terminated") exc_class = map_sqlstate_to_exception(sqlstate_code) if exc_class: return _create_postgres_error(error, sqlstate_code, exc_class, "database error") diff --git a/sqlspec/adapters/asyncpg/data_dictionary.py b/sqlspec/adapters/asyncpg/data_dictionary.py index fb9fe591d..28711819f 100644 --- a/sqlspec/adapters/asyncpg/data_dictionary.py +++ b/sqlspec/adapters/asyncpg/data_dictionary.py @@ -174,7 +174,7 @@ class AsyncpgDataDictionary(AsyncDataDictionaryBase): async def get_metadata_capabilities( self, driver: "AsyncpgDriver", domains: Sequence[str] | None = None ) -> MetadataCapabilityProfile: - """Get PostgreSQL replacement data-dictionary capability profile.""" + """Get PostgreSQL data-dictionary capability profile.""" return _postgres_metadata_profile(type(self).__name__, domains) async def get_system_metadata_capabilities( diff --git a/sqlspec/adapters/bigquery/core.py b/sqlspec/adapters/bigquery/core.py index 47368f81d..9188677b8 100644 --- a/sqlspec/adapters/bigquery/core.py +++ b/sqlspec/adapters/bigquery/core.py @@ -9,6 +9,7 @@ from urllib.parse import urlparse import sqlglot +from google.api_core import exceptions as api_exceptions from sqlglot import exp from sqlspec.core import ( @@ -23,13 +24,14 @@ DataError, NotFoundError, OperationalError, + OperationCancelledError, PermissionDeniedError, - QueryTimeoutError, SQLParsingError, SQLSpecError, StorageCapabilityError, StorageOperationFailedError, UniqueViolationError, + _classify_timeout_or_cancellation, ) from sqlspec.utils.logging import get_logger from sqlspec.utils.serializers import to_json @@ -688,7 +690,7 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec Mapped Statuses: * UniqueViolationError: HTTP 409 (Conflict) or "already exists" in message * NotFoundError: HTTP 404 (Not Found) or "not found" in message - * QueryTimeoutError: "timeout", "deadline exceeded", or "cancelled" in message + * QueryTimeoutError or OperationCancelledError for terminated queries * SQLParsingError / DataError / SQLSpecError: HTTP 400 (Bad Request) * PermissionDeniedError: HTTP 403 (Forbidden) or "access denied" / "permission denied" in message * OperationalError: HTTP 500+ (Server error) @@ -713,8 +715,11 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec if status_code == HTTP_NOT_FOUND or "not found" in error_msg: return _create_bigquery_error(error, status_code, NotFoundError, "resource not found") - if "timeout" in error_msg or "deadline exceeded" in error_msg or "cancelled" in error_msg: - return _create_bigquery_error(error, status_code, QueryTimeoutError, "query timeout or cancelled") + if isinstance(error, api_exceptions.Cancelled): + return _create_bigquery_error(error, status_code, OperationCancelledError, "query cancelled") + + if error_class := _classify_timeout_or_cancellation(error_msg): + return _create_bigquery_error(error, status_code, error_class, "query terminated") if status_code == HTTP_BAD_REQUEST: if "syntax" in error_msg or "invalid query" in error_msg: diff --git a/sqlspec/adapters/bigquery/data_dictionary.py b/sqlspec/adapters/bigquery/data_dictionary.py index ec81606a0..0bc206b5d 100644 --- a/sqlspec/adapters/bigquery/data_dictionary.py +++ b/sqlspec/adapters/bigquery/data_dictionary.py @@ -262,7 +262,7 @@ def get_foreign_keys( def get_metadata_capabilities( self, driver: Any, domains: "Sequence[str] | None" = None ) -> "MetadataCapabilityProfile": - """Get BigQuery replacement data-dictionary capability profile.""" + """Get BigQuery data-dictionary capability profile.""" _ = driver requested_domains = tuple(domains) if domains is not None else _DEFAULT_METADATA_DOMAINS capabilities = tuple(_bigquery_capability_for_domain(domain) for domain in requested_domains) diff --git a/sqlspec/adapters/cockroach_asyncpg/data_dictionary.py b/sqlspec/adapters/cockroach_asyncpg/data_dictionary.py index de0984808..c3dc856db 100644 --- a/sqlspec/adapters/cockroach_asyncpg/data_dictionary.py +++ b/sqlspec/adapters/cockroach_asyncpg/data_dictionary.py @@ -160,7 +160,7 @@ class CockroachAsyncpgDataDictionary(AsyncDataDictionaryBase): async def get_metadata_capabilities( self, driver: "CockroachAsyncpgDriver", domains: Sequence[str] | None = None ) -> MetadataCapabilityProfile: - """Get CockroachDB replacement data-dictionary capability profile.""" + """Get CockroachDB data-dictionary capability profile.""" return _cockroach_metadata_profile(type(self).__name__, domains) async def get_system_metadata_capabilities( diff --git a/sqlspec/adapters/cockroach_psycopg/data_dictionary.py b/sqlspec/adapters/cockroach_psycopg/data_dictionary.py index 67cadb914..8448c2c8b 100644 --- a/sqlspec/adapters/cockroach_psycopg/data_dictionary.py +++ b/sqlspec/adapters/cockroach_psycopg/data_dictionary.py @@ -166,7 +166,7 @@ def __init__(self) -> None: def get_metadata_capabilities( self, driver: "CockroachPsycopgSyncDriver", domains: Sequence[str] | None = None ) -> MetadataCapabilityProfile: - """Get CockroachDB replacement data-dictionary capability profile.""" + """Get CockroachDB data-dictionary capability profile.""" return _cockroach_metadata_profile(type(self).__name__, domains) def get_system_metadata_capabilities( @@ -401,7 +401,7 @@ def __init__(self) -> None: async def get_metadata_capabilities( self, driver: "CockroachPsycopgAsyncDriver", domains: Sequence[str] | None = None ) -> MetadataCapabilityProfile: - """Get CockroachDB replacement data-dictionary capability profile.""" + """Get CockroachDB data-dictionary capability profile.""" return _cockroach_metadata_profile(type(self).__name__, domains) async def get_system_metadata_capabilities( diff --git a/sqlspec/adapters/duckdb/core.py b/sqlspec/adapters/duckdb/core.py index 35183a3c5..1267ec3dc 100644 --- a/sqlspec/adapters/duckdb/core.py +++ b/sqlspec/adapters/duckdb/core.py @@ -14,8 +14,8 @@ NotFoundError, NotNullViolationError, OperationalError, + OperationCancelledError, PermissionDeniedError, - QueryTimeoutError, SQLParsingError, SQLSpecError, UniqueViolationError, @@ -229,7 +229,7 @@ def _register_duckdb_exception_mappings() -> None: ("ParserException", (SQLParsingError, "SQL parsing error")), ("BinderException", (SQLParsingError, "SQL parsing error")), ("PermissionException", (PermissionDeniedError, "permission denied")), - ("InterruptException", (QueryTimeoutError, "query interrupted")), + ("InterruptException", (OperationCancelledError, "query interrupted")), ("IOException", (OperationalError, "operational error")), ("ConversionException", (DataError, "data error")), ) @@ -312,7 +312,7 @@ def create_mapped_exception(error: "BaseException", *, logger: Any | None = None if "permissionexception" in exc_name: return _create_duckdb_error(error, PermissionDeniedError, "permission denied") if "interruptexception" in exc_name: - return _create_duckdb_error(error, QueryTimeoutError, "query interrupted") + return _create_duckdb_error(error, OperationCancelledError, "query interrupted") if "ioexception" in exc_name: return _create_duckdb_error(error, OperationalError, "operational error") if "conversionexception" in exc_name: @@ -322,7 +322,7 @@ def create_mapped_exception(error: "BaseException", *, logger: Any | None = None if "permission denied" in error_msg or "access denied" in error_msg: return _create_duckdb_error(error, PermissionDeniedError, "permission denied") if "interrupt" in error_msg or "cancel" in error_msg: - return _create_duckdb_error(error, QueryTimeoutError, "query canceled") + return _create_duckdb_error(error, OperationCancelledError, "query canceled") if "type mismatch" in error_msg: return _create_duckdb_error(error, DataError, "data error") diff --git a/sqlspec/adapters/mssql_python/data_dictionary.py b/sqlspec/adapters/mssql_python/data_dictionary.py index 1f909f36d..c03088ec6 100644 --- a/sqlspec/adapters/mssql_python/data_dictionary.py +++ b/sqlspec/adapters/mssql_python/data_dictionary.py @@ -146,7 +146,7 @@ def __init__(self) -> None: def get_metadata_capabilities( self, driver: "MssqlPythonDriver", domains: "Sequence[str] | None" = None ) -> "MetadataCapabilityProfile": - """Get SQL Server replacement data-dictionary capability profile.""" + """Get SQL Server data-dictionary capability profile.""" return build_mssql_metadata_capability_profile(type(self).__name__, domains) def get_system_metadata_capabilities( diff --git a/sqlspec/adapters/mysqlconnector/data_dictionary.py b/sqlspec/adapters/mysqlconnector/data_dictionary.py index 97e7f6a2d..06e64b6d7 100644 --- a/sqlspec/adapters/mysqlconnector/data_dictionary.py +++ b/sqlspec/adapters/mysqlconnector/data_dictionary.py @@ -81,7 +81,7 @@ def get_feature_flag(self, driver: "MysqlConnectorSyncDriver", feature: str) -> def get_metadata_capabilities( self, driver: "MysqlConnectorSyncDriver", domains: "Sequence[str] | None" = None ) -> "MetadataCapabilityProfile": - """Get replacement data-dictionary capability profile.""" + """Get data-dictionary capability profile.""" engine_version = self._get_engine_version(driver) dialect = engine_version.engine_family if engine_version is not None else type(self).dialect requested_domains = None if domains is None else tuple(domains) @@ -342,7 +342,7 @@ async def get_feature_flag(self, driver: "MysqlConnectorAsyncDriver", feature: s async def get_metadata_capabilities( self, driver: "MysqlConnectorAsyncDriver", domains: "Sequence[str] | None" = None ) -> "MetadataCapabilityProfile": - """Get replacement data-dictionary capability profile.""" + """Get data-dictionary capability profile.""" engine_version = await self._get_engine_version(driver) dialect = engine_version.engine_family if engine_version is not None else type(self).dialect requested_domains = None if domains is None else tuple(domains) diff --git a/sqlspec/adapters/oracledb/core.py b/sqlspec/adapters/oracledb/core.py index c2aa9bb28..864c81cd0 100644 --- a/sqlspec/adapters/oracledb/core.py +++ b/sqlspec/adapters/oracledb/core.py @@ -30,8 +30,8 @@ IntegrityError, NotNullViolationError, OperationalError, + OperationCancelledError, PermissionDeniedError, - QueryTimeoutError, SQLParsingError, SQLSpecError, TransactionError, @@ -128,7 +128,7 @@ 60: (DeadlockError, "deadlock detected"), 8176: (TransactionError, "consistent read failure"), # Query timeout/cancellation - 1013: (QueryTimeoutError, "user requested cancel"), + 1013: (OperationCancelledError, "user requested cancel"), # Data errors 1722: (DataError, "invalid number"), 1858: (DataError, "invalid character"), diff --git a/sqlspec/adapters/oracledb/data_dictionary.py b/sqlspec/adapters/oracledb/data_dictionary.py index 3a46412b3..932ab5db8 100644 --- a/sqlspec/adapters/oracledb/data_dictionary.py +++ b/sqlspec/adapters/oracledb/data_dictionary.py @@ -274,7 +274,7 @@ def get_metadata_capabilities( include_diagnostics: bool = False, acknowledge_diagnostics_license: bool = False, ) -> MetadataCapabilityProfile: - """Report Oracle replacement metadata capabilities and scope gates.""" + """Report Oracle metadata capabilities and scope gates.""" _ = driver return _oracle_capability_profile( @@ -670,7 +670,7 @@ async def get_metadata_capabilities( include_diagnostics: bool = False, acknowledge_diagnostics_license: bool = False, ) -> MetadataCapabilityProfile: - """Report Oracle replacement metadata capabilities and scope gates.""" + """Report Oracle metadata capabilities and scope gates.""" _ = driver return _oracle_capability_profile( diff --git a/sqlspec/adapters/psqlpy/core.py b/sqlspec/adapters/psqlpy/core.py index 4ff27fd2f..17668ae9b 100644 --- a/sqlspec/adapters/psqlpy/core.py +++ b/sqlspec/adapters/psqlpy/core.py @@ -37,11 +37,11 @@ NotNullViolationError, OperationalError, PermissionDeniedError, - QueryTimeoutError, SerializationConflictError, SQLParsingError, SQLSpecError, UniqueViolationError, + _classify_timeout_or_cancellation, ) from sqlspec.typing import PGVECTOR_INSTALLED, Empty from sqlspec.utils.dispatch import TypeDispatcher @@ -416,7 +416,7 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec Mapped Exceptions: * Integrity constraint violations (UniqueViolationError, ForeignKeyViolationError, etc.) * Transaction/serialization errors (DeadlockError, SerializationConflictError) - * QueryTimeoutError: cancellations, timeouts + * QueryTimeoutError and OperationCancelledError * PermissionDeniedError: permission denied, authentication failed * ConnectionTimeoutError / DatabaseConnectionError: connection errors * SQLParsingError: syntax/parse errors @@ -447,8 +447,8 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec if "serialization failure" in error_msg or "could not serialize" in error_msg: return _create_postgres_error(error, SerializationConflictError, "serialization failure") - if "cancel" in error_msg or "timeout" in error_msg or "statement timeout" in error_msg: - return _create_postgres_error(error, QueryTimeoutError, "query canceled or timed out") + if error_class := _classify_timeout_or_cancellation(error_msg): + return _create_postgres_error(error, error_class, "query terminated") if "permission denied" in error_msg or "insufficient privilege" in error_msg: return _create_postgres_error(error, PermissionDeniedError, "permission denied") diff --git a/sqlspec/adapters/psqlpy/data_dictionary.py b/sqlspec/adapters/psqlpy/data_dictionary.py index 1168dc79c..c10a5feaf 100644 --- a/sqlspec/adapters/psqlpy/data_dictionary.py +++ b/sqlspec/adapters/psqlpy/data_dictionary.py @@ -180,7 +180,7 @@ def __init__(self) -> None: async def get_metadata_capabilities( self, driver: "PsqlpyDriver", domains: Sequence[str] | None = None ) -> MetadataCapabilityProfile: - """Get PostgreSQL replacement data-dictionary capability profile.""" + """Get PostgreSQL data-dictionary capability profile.""" return _postgres_metadata_profile(type(self).__name__, domains) async def get_system_metadata_capabilities( diff --git a/sqlspec/adapters/psycopg/core.py b/sqlspec/adapters/psycopg/core.py index 22937abc7..059a815d7 100644 --- a/sqlspec/adapters/psycopg/core.py +++ b/sqlspec/adapters/psycopg/core.py @@ -28,12 +28,14 @@ ForeignKeyViolationError, IntegrityError, NotNullViolationError, + OperationalError, + OperationCancelledError, PermissionDeniedError, - QueryTimeoutError, SerializationConflictError, SQLParsingError, SQLSpecError, UniqueViolationError, + _classify_timeout_or_cancellation, map_sqlstate_to_exception, ) from sqlspec.typing import PGVECTOR_INSTALLED @@ -542,7 +544,7 @@ def _register_exception_mappings() -> None: pg_errors.IntegrityError: ("23000", IntegrityError, "integrity constraint violation"), pg_errors.DeadlockDetected: ("40P01", DeadlockError, "deadlock detected"), pg_errors.SerializationFailure: ("40001", SerializationConflictError, "serialization failure"), - pg_errors.QueryCanceled: ("57014", QueryTimeoutError, "query canceled"), + pg_errors.QueryCanceled: ("57014", OperationCancelledError, "query canceled"), pg_errors.InsufficientPrivilege: ("42501", PermissionDeniedError, "insufficient privilege"), pg_errors.SyntaxError: ("42601", SQLParsingError, "SQL syntax error"), }) @@ -598,12 +600,18 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec mapped_error = _resolve_exception_mapping(error_type) if mapped_error is not None: mapped_error_code, error_class, description = mapped_error + if mapped_error_code == "57014": + termination_class = _classify_timeout_or_cancellation(str(error)) or OperationalError + return _create_postgres_error(error, mapped_error_code, termination_class, description) return _create_postgres_error(error, mapped_error_code, error_class, description) # Priority 2: Fall back to SQLSTATE code mapping using centralized utility sqlstate_attr = error.sqlstate if has_sqlstate(error) else None error_code = sqlstate_attr if sqlstate_attr is not None else None if error_code: + if error_code == "57014": + termination_class = _classify_timeout_or_cancellation(str(error)) or OperationalError + return _create_postgres_error(error, error_code, termination_class, "query terminated") exc_class = map_sqlstate_to_exception(error_code) if exc_class: return _create_postgres_error(error, error_code, exc_class, "database error") diff --git a/sqlspec/adapters/psycopg/data_dictionary.py b/sqlspec/adapters/psycopg/data_dictionary.py index bb6bcaf28..b7d70823c 100644 --- a/sqlspec/adapters/psycopg/data_dictionary.py +++ b/sqlspec/adapters/psycopg/data_dictionary.py @@ -180,7 +180,7 @@ def __init__(self) -> None: def get_metadata_capabilities( self, driver: "PsycopgSyncDriver", domains: Sequence[str] | None = None ) -> MetadataCapabilityProfile: - """Get PostgreSQL replacement data-dictionary capability profile.""" + """Get PostgreSQL data-dictionary capability profile.""" return _postgres_metadata_profile(type(self).__name__, domains) def get_system_metadata_capabilities( @@ -457,7 +457,7 @@ def __init__(self) -> None: async def get_metadata_capabilities( self, driver: "PsycopgAsyncDriver", domains: Sequence[str] | None = None ) -> MetadataCapabilityProfile: - """Get PostgreSQL replacement data-dictionary capability profile.""" + """Get PostgreSQL data-dictionary capability profile.""" return _postgres_metadata_profile(type(self).__name__, domains) async def get_system_metadata_capabilities( diff --git a/sqlspec/adapters/pymssql/data_dictionary.py b/sqlspec/adapters/pymssql/data_dictionary.py index 8c0e617db..3807c5c93 100644 --- a/sqlspec/adapters/pymssql/data_dictionary.py +++ b/sqlspec/adapters/pymssql/data_dictionary.py @@ -146,7 +146,7 @@ def __init__(self) -> None: def get_metadata_capabilities( self, driver: "PymssqlDriver", domains: "Sequence[str] | None" = None ) -> "MetadataCapabilityProfile": - """Get SQL Server replacement data-dictionary capability profile.""" + """Get SQL Server data-dictionary capability profile.""" return build_mssql_metadata_capability_profile(type(self).__name__, domains) def get_system_metadata_capabilities( diff --git a/sqlspec/adapters/pymysql/data_dictionary.py b/sqlspec/adapters/pymysql/data_dictionary.py index 8da700308..09228e16c 100644 --- a/sqlspec/adapters/pymysql/data_dictionary.py +++ b/sqlspec/adapters/pymysql/data_dictionary.py @@ -81,7 +81,7 @@ def get_feature_flag(self, driver: "PyMysqlDriver", feature: str) -> bool: def get_metadata_capabilities( self, driver: "PyMysqlDriver", domains: "Sequence[str] | None" = None ) -> "MetadataCapabilityProfile": - """Get replacement data-dictionary capability profile.""" + """Get data-dictionary capability profile.""" engine_version = self._get_engine_version(driver) dialect = engine_version.engine_family if engine_version is not None else type(self).dialect requested_domains = None if domains is None else tuple(domains) diff --git a/sqlspec/adapters/spanner/core.py b/sqlspec/adapters/spanner/core.py index 6173a6817..12e34cdbb 100644 --- a/sqlspec/adapters/spanner/core.py +++ b/sqlspec/adapters/spanner/core.py @@ -13,6 +13,7 @@ DeadlockError, NotFoundError, OperationalError, + OperationCancelledError, PermissionDeniedError, QueryTimeoutError, SQLParsingError, @@ -178,11 +179,14 @@ def coerce_params( params: "dict[str, Any] | list[Any] | tuple[Any, ...] | None", *, json_serializer: "Callable[[Any], str] | None" = None, + enable_uuid_conversion: bool = True, ) -> "dict[str, Any] | None": """Coerce Python types to Spanner-compatible formats.""" if not isinstance(params, dict): return None - return coerce_params_for_spanner(params, json_serializer=json_serializer) + return coerce_params_for_spanner( + params, json_serializer=json_serializer, enable_uuid_conversion=enable_uuid_conversion + ) def collect_rows( @@ -281,7 +285,7 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec # Query timeout/cancellation if isinstance(error, api_exceptions.Cancelled): - return _create_spanner_error(error, QueryTimeoutError, "operation cancelled") + return _create_spanner_error(error, OperationCancelledError, "operation cancelled") if isinstance(error, api_exceptions.DeadlineExceeded): return _create_spanner_error(error, QueryTimeoutError, "deadline exceeded") diff --git a/sqlspec/adapters/spanner/data_dictionary.py b/sqlspec/adapters/spanner/data_dictionary.py index 5bd4452e4..ea3d9b6eb 100644 --- a/sqlspec/adapters/spanner/data_dictionary.py +++ b/sqlspec/adapters/spanner/data_dictionary.py @@ -165,7 +165,7 @@ def get_foreign_keys( def get_metadata_capabilities( self, driver: Any, domains: "Sequence[str] | None" = None, *, mode: str | None = None ) -> "MetadataCapabilityProfile": - """Get Spanner replacement data-dictionary capability profile.""" + """Get Spanner data-dictionary capability profile.""" _ = driver requested_domains = tuple(domains) if domains is not None else _DEFAULT_METADATA_DOMAINS normalized_mode = _normalize_spanner_metadata_mode(mode) diff --git a/sqlspec/adapters/spanner/driver.py b/sqlspec/adapters/spanner/driver.py index 79dad809d..36d455b97 100644 --- a/sqlspec/adapters/spanner/driver.py +++ b/sqlspec/adapters/spanner/driver.py @@ -583,7 +583,11 @@ def _connection_in_transaction(self) -> bool: return False def _coerce_params(self, params: "dict[str, Any] | list[Any] | tuple[Any, ...] | None") -> "dict[str, Any] | None": - return coerce_params(params, json_serializer=self.driver_features.get("json_serializer")) + return coerce_params( + params, + json_serializer=self.driver_features.get("json_serializer"), + enable_uuid_conversion=self.driver_features.get("enable_uuid_conversion", True), + ) def _infer_param_types(self, params: "dict[str, Any] | list[Any] | tuple[Any, ...] | None") -> "dict[str, Any]": return infer_param_types(params) diff --git a/sqlspec/adapters/spanner/type_converter.py b/sqlspec/adapters/spanner/type_converter.py index 478726261..161f90e7b 100644 --- a/sqlspec/adapters/spanner/type_converter.py +++ b/sqlspec/adapters/spanner/type_converter.py @@ -168,12 +168,14 @@ def spanner_json(value: Any) -> Any: def coerce_params_for_spanner( - params: "dict[str, Any] | None", json_serializer: "Callable[[Any], str] | None" = None + params: "dict[str, Any] | None", + json_serializer: "Callable[[Any], str] | None" = None, + enable_uuid_conversion: bool = True, ) -> "dict[str, Any] | None": """Coerce Python types to Spanner-compatible formats. Handles: - - UUID → base64-encoded bytes + - UUID → 36-character string (when enable_uuid_conversion is active) - bytes → base64-encoded bytes - datetime timezone awareness - dict → JsonObject for JSON columns @@ -182,6 +184,7 @@ def coerce_params_for_spanner( Args: params: Parameter dictionary or None. json_serializer: Optional JSON serializer (unused for JSON dicts). + enable_uuid_conversion: Enable automatic UUID string conversion. Returns: Coerced parameter dictionary or None. @@ -197,9 +200,11 @@ def coerce_params_for_spanner( value = value.value changed = True if isinstance(value, _UUID_TYPES): - std_uuid = value if isinstance(value, UUID) else uuid_from_bytes(value.bytes) - coerced[key] = bytes_to_spanner(uuid_to_spanner(std_uuid)) - changed = True + if enable_uuid_conversion: + coerced[key] = str(value) + changed = True + else: + coerced[key] = value elif isinstance(value, bytes): coerced[key] = bytes_to_spanner(value) changed = True diff --git a/sqlspec/adapters/sqlite/core.py b/sqlspec/adapters/sqlite/core.py index c8b8cce3b..8f5d45ff8 100644 --- a/sqlspec/adapters/sqlite/core.py +++ b/sqlspec/adapters/sqlite/core.py @@ -22,8 +22,8 @@ IntegrityError, NotNullViolationError, OperationalError, + OperationCancelledError, PermissionDeniedError, - QueryTimeoutError, SQLParsingError, SQLSpecError, UniqueViolationError, @@ -351,9 +351,9 @@ def create_mapped_exception(error: BaseException, *, logger: Any | None = None) # Query interruption (timeout-like behavior) if error_code == SQLITE_INTERRUPT_CODE or error_name == "SQLITE_INTERRUPT": - return _create_sqlite_error(error, error_code, QueryTimeoutError, "query interrupted") + return _create_sqlite_error(error, error_code, OperationCancelledError, "query interrupted") if "interrupt" in error_msg: - return _create_sqlite_error(error, error_code or 0, QueryTimeoutError, "query interrupted") + return _create_sqlite_error(error, error_code or 0, OperationCancelledError, "query interrupted") # Permission errors if error_code == SQLITE_PERM_CODE or error_name == "SQLITE_PERM": diff --git a/sqlspec/data_dictionary/dialects/mssql.py b/sqlspec/data_dictionary/dialects/mssql.py index cb00d5f42..6bed21041 100644 --- a/sqlspec/data_dictionary/dialects/mssql.py +++ b/sqlspec/data_dictionary/dialects/mssql.py @@ -261,7 +261,7 @@ def list_mssql_available_features(config: "DialectConfig | None" = None) -> list def build_mssql_metadata_capability_profile( adapter: str | None, domains: "Sequence[str] | None" = None ) -> MetadataCapabilityProfile: - """Build SQL Server replacement data-dictionary capability metadata.""" + """Build SQL Server data-dictionary capability metadata.""" requested_domains = tuple(domains) if domains is not None else MSSQL_REPLACEMENT_DOMAINS capabilities: list[MetadataCapability] = [] for domain in requested_domains: diff --git a/sqlspec/data_dictionary/dialects/mysql.py b/sqlspec/data_dictionary/dialects/mysql.py index a5b248ac7..c1c0214d0 100644 --- a/sqlspec/data_dictionary/dialects/mysql.py +++ b/sqlspec/data_dictionary/dialects/mysql.py @@ -187,7 +187,7 @@ def parse_mysql_engine_version(version_text: str) -> MySQLEngineVersion | None: def build_mysql_metadata_capability_profile( dialect: str, adapter: str | None, domains: "tuple[str, ...] | None" = None ) -> MetadataCapabilityProfile: - """Build the replacement metadata capability profile for MySQL-family adapters.""" + """Build the metadata capability profile for MySQL-family adapters.""" requested_domains = MYSQL_METADATA_DOMAINS if domains is None else domains capabilities = tuple(_mysql_metadata_capability(domain) for domain in requested_domains) return MetadataCapabilityProfile(dialect=dialect, adapter=adapter, capabilities=capabilities) diff --git a/sqlspec/driver/_async.py b/sqlspec/driver/_async.py index b770f73b5..6ad284f65 100644 --- a/sqlspec/driver/_async.py +++ b/sqlspec/driver/_async.py @@ -1922,7 +1922,7 @@ def __init__(self) -> None: async def get_metadata_capabilities( self, driver: Any, domains: "Sequence[str] | None" = None ) -> "MetadataCapabilityProfile": - """Get replacement data-dictionary capability profile. + """Get data-dictionary capability profile. Args: driver: Async database driver instance. diff --git a/sqlspec/driver/_sync.py b/sqlspec/driver/_sync.py index 97ce30afe..207d5dbb3 100644 --- a/sqlspec/driver/_sync.py +++ b/sqlspec/driver/_sync.py @@ -1834,7 +1834,7 @@ def __init__(self) -> None: def get_metadata_capabilities( self, driver: Any, domains: "Sequence[str] | None" = None ) -> "MetadataCapabilityProfile": - """Get replacement data-dictionary capability profile. + """Get data-dictionary capability profile. Args: driver: Sync database driver instance. diff --git a/sqlspec/exceptions.py b/sqlspec/exceptions.py index 04885033a..8bd27d88d 100644 --- a/sqlspec/exceptions.py +++ b/sqlspec/exceptions.py @@ -21,6 +21,7 @@ "MultipleResultsFoundError", "NotFoundError", "NotNullViolationError", + "OperationCancelledError", "OperationalError", "OutOfOrderMigrationError", "PermissionDeniedError", @@ -262,16 +263,18 @@ class OperationalError(SQLSpecError): class QueryTimeoutError(OperationalError): - """Query execution timed out or was canceled. + """Query execution exceeded a timeout or deadline. Raised when: - - Statement timeout exceeded (SQLSTATE 57014) - - Query canceled by user/operator - Lock wait timeout exceeded (MySQL 1205) - - Oracle user requested cancel (ORA-01013) + - Statement or service deadline exceeded """ +class OperationCancelledError(OperationalError): + """Database operation was explicitly cancelled by a caller or operator.""" + + class StorageOperationFailedError(SQLSpecError): """Raised when a storage backend operation fails.""" @@ -393,7 +396,7 @@ class SquashValidationError(MigrationError): "23514": CheckViolationError, "40001": SerializationConflictError, "40P01": DeadlockError, - "57014": QueryTimeoutError, + "57014": OperationalError, # Class-level matches (2 characters) - broader categories "02": NotFoundError, "08": DatabaseConnectionError, @@ -440,6 +443,17 @@ def map_sqlstate_to_exception(sqlstate: str | None) -> type[SQLSpecError] | None return None +def _classify_timeout_or_cancellation(message: str) -> "type[OperationalError] | None": + error_msg = message.lower() + timeout_markers = ("timeout", "timed out", "deadline exceeded", "deadline_exceeded") + if any(marker in error_msg for marker in timeout_markers): + return QueryTimeoutError + cancellation_markers = ("cancel", "interrupt") + if any(marker in error_msg for marker in cancellation_markers): + return OperationCancelledError + return None + + @contextmanager def wrap_exceptions( wrap_exceptions: bool = True, suppress: "type[Exception] | tuple[type[Exception], ...] | None" = None diff --git a/tests/integration/adapters/_shared/suite_metadata_contract.py b/tests/integration/adapters/_shared/suite_metadata_contract.py index a538a02f3..88142fcb4 100644 --- a/tests/integration/adapters/_shared/suite_metadata_contract.py +++ b/tests/integration/adapters/_shared/suite_metadata_contract.py @@ -59,7 +59,7 @@ def test_sync_data_dictionary_contract(sync_capability_driver_case: DriverCaseCo "sync_capability_driver_case", sync_driver_params_with("supports_data_dictionary"), indirect=True ) def test_sync_data_dictionary_capability_contract(sync_capability_driver_case: DriverCaseContext) -> None: - """Sync data dictionaries expose truthful replacement metadata capability tiers.""" + """Sync data dictionaries expose truthful metadata capability tiers.""" case = sync_capability_driver_case.case assert isinstance(case.supports_data_dictionary_core, bool) @@ -82,7 +82,7 @@ async def test_async_data_dictionary_contract(async_capability_driver_case: Driv "async_capability_driver_case", async_driver_params_with("supports_data_dictionary"), indirect=True ) async def test_async_data_dictionary_capability_contract(async_capability_driver_case: DriverCaseContext) -> None: - """Async data dictionaries expose truthful replacement metadata capability tiers.""" + """Async data dictionaries expose truthful metadata capability tiers.""" case = async_capability_driver_case.case assert isinstance(case.supports_data_dictionary_core, bool) diff --git a/tests/integration/adapters/duckdb/adbc/test_driver.py b/tests/integration/adapters/duckdb/adbc/test_driver.py index e7a6e0153..431dea3af 100644 --- a/tests/integration/adapters/duckdb/adbc/test_driver.py +++ b/tests/integration/adapters/duckdb/adbc/test_driver.py @@ -1,8 +1,11 @@ """DuckDB-backed ADBC driver residuals.""" +import threading + import pytest from sqlspec.adapters.adbc import AdbcDriver +from sqlspec.exceptions import OperationCancelledError, QueryTimeoutError from tests.integration.adapters._shared.adbc_backends import duckdb_session, test_duckdb_specific_features from tests.integration.adapters._shared.adbc_connection import test_duckdb_connection @@ -24,3 +27,17 @@ def test_duckdb_uuid_schema_bypasses_opaque_uuid_decoding(duckdb_session: AdbcDr and getattr(arrow_type, "type_name", None) == "uuid" ) assert rows == arrow_table.to_pylist() + + +@pytest.mark.xdist_group("duckdb") +@pytest.mark.adbc +def test_duckdb_adbc_explicit_cancel_maps_to_operation_cancelled(duckdb_session: AdbcDriver) -> None: + cancel = threading.Timer(0.05, duckdb_session.connection.adbc_cancel) + cancel.start() + try: + with pytest.raises(OperationCancelledError) as exc_info: + duckdb_session.select("SELECT sum(i * j) FROM range(1000000) t(i), range(1000000) u(j)") + finally: + cancel.cancel() + + assert not isinstance(exc_info.value, QueryTimeoutError) diff --git a/tests/unit/adapters/test_adbc/test_core.py b/tests/unit/adapters/test_adbc/test_core.py index 92a9d8e4d..e05f7eca5 100644 --- a/tests/unit/adapters/test_adbc/test_core.py +++ b/tests/unit/adapters/test_adbc/test_core.py @@ -17,7 +17,13 @@ resolve_column_names, resolve_many_rowcount, ) -from sqlspec.exceptions import DeadlockError, SerializationConflictError +from sqlspec.exceptions import ( + DeadlockError, + OperationalError, + OperationCancelledError, + QueryTimeoutError, + SerializationConflictError, +) def test_create_mapped_exception_maps_40001_to_serialization_conflict() -> None: @@ -39,6 +45,32 @@ def test_create_mapped_exception_still_maps_40p01_to_deadlock() -> None: assert isinstance(mapped, DeadlockError) +def test_create_mapped_exception_maps_cancelled_status_to_operation_cancelled() -> None: + error = DatabaseError("operation cancelled", status_code=AdbcStatusCode.CANCELLED) + + assert isinstance(adbc_core.create_mapped_exception(error), OperationCancelledError) + + +def test_create_mapped_exception_maps_timeout_status_to_query_timeout() -> None: + error = DatabaseError("operation timed out", status_code=AdbcStatusCode.TIMEOUT) + + assert isinstance(adbc_core.create_mapped_exception(error), QueryTimeoutError) + + +def test_create_mapped_exception_maps_ambiguous_57014_to_operational_error() -> None: + error = DatabaseError("query failed", status_code=AdbcStatusCode.UNKNOWN, sqlstate="57014") + + assert type(adbc_core.create_mapped_exception(error)) is OperationalError + + +def test_create_mapped_exception_prefers_timeout_marker_over_cancel_marker() -> None: + error = DatabaseError( + "canceling statement due to statement timeout", status_code=AdbcStatusCode.UNKNOWN, sqlstate="57014" + ) + + assert isinstance(adbc_core.create_mapped_exception(error), QueryTimeoutError) + + def test_prepare_postgres_parameters_fast_path_without_casts() -> None: statement_config = get_statement_config("postgres") diff --git a/tests/unit/adapters/test_adbc/test_text_array_binding.py b/tests/unit/adapters/test_adbc/test_text_array_binding.py new file mode 100644 index 000000000..9e1261796 --- /dev/null +++ b/tests/unit/adapters/test_adbc/test_text_array_binding.py @@ -0,0 +1,63 @@ +"""Unit tests for ADBC PostgreSQL text array None element preservation.""" + +from sqlspec.adapters.adbc.core import ( + _convert_array_for_postgres_adbc, + get_statement_config, + prepare_parameters_with_casts, + prepare_postgres_parameters, +) +from sqlspec.adapters.adbc.type_converter import get_adbc_type_converter + + +def test_convert_array_for_postgres_adbc_preserves_none() -> None: + """Test that _convert_array_for_postgres_adbc preserves None elements in lists and tuples.""" + input_list = ["alpha", None, "beta"] + result_list = _convert_array_for_postgres_adbc(input_list) + assert result_list == ["alpha", None, "beta"] + assert result_list[1] is None + + input_tuple = ("foo", None, "bar") + result_tuple = _convert_array_for_postgres_adbc(input_tuple) + assert result_tuple == ["foo", None, "bar"] + assert result_tuple[1] is None + + +def test_adbc_output_converter_convert_sequence_preserves_none() -> None: + """Test that ADBCOutputConverter.convert_sequence preserves None elements for postgres dialects.""" + converter = get_adbc_type_converter("postgres") + assert hasattr(converter, "convert_sequence") + + res_list = converter.convert_sequence(["a", None, "b"]) + assert res_list == ["a", None, "b"] + assert res_list[1] is None + + res_tuple = converter.convert_sequence(("x", None, "y")) + assert res_tuple == ["x", None, "y"] + assert res_tuple[1] is None + + +def test_prepare_parameters_with_casts_preserves_none_in_text_array() -> None: + """Test that parameter preparation with cast mapping preserves None elements in text arrays.""" + statement_config = get_statement_config("postgres") + params = [["hello", None, "world"]] + casts: dict[int, str] = {1: "TEXT[]"} + + prepared = prepare_parameters_with_casts( + params, casts, statement_config, dialect="postgres", json_serializer=lambda value: str(value) + ) + + assert prepared == [["hello", None, "world"]] + assert prepared[0][1] is None + + +def test_prepare_postgres_parameters_preserves_none_without_casts() -> None: + """Test that prepare_postgres_parameters preserves None in array parameters when no casts are present.""" + statement_config = get_statement_config("postgres") + params = [["first", None, "second"]] + + prepared = prepare_postgres_parameters( + params, {}, statement_config, dialect="postgres", json_serializer=lambda value: str(value) + ) + + assert prepared == [["first", None, "second"]] + assert prepared[0][1] is None diff --git a/tests/unit/adapters/test_aiosqlite/test_exception_mapping.py b/tests/unit/adapters/test_aiosqlite/test_exception_mapping.py index 25acce52b..164d63222 100644 --- a/tests/unit/adapters/test_aiosqlite/test_exception_mapping.py +++ b/tests/unit/adapters/test_aiosqlite/test_exception_mapping.py @@ -3,7 +3,7 @@ import sqlite3 from sqlspec.adapters.aiosqlite.core import create_mapped_exception -from sqlspec.exceptions import DeadlockError, PermissionDeniedError, QueryTimeoutError +from sqlspec.exceptions import DeadlockError, OperationCancelledError, PermissionDeniedError class _SqliteError(sqlite3.OperationalError): @@ -47,21 +47,21 @@ def test_busy_text_heuristic_maps_to_deadlock() -> None: assert isinstance(result, DeadlockError) -def test_interrupt_error_code_maps_to_query_timeout() -> None: +def test_interrupt_error_code_maps_to_operation_cancelled() -> None: err = _SqliteError("interrupted", 9, "SQLITE_INTERRUPT") result = create_mapped_exception(err) - assert isinstance(result, QueryTimeoutError) + assert isinstance(result, OperationCancelledError) assert result.__cause__ is err -def test_interrupt_error_name_maps_to_query_timeout() -> None: +def test_interrupt_error_name_maps_to_operation_cancelled() -> None: result = create_mapped_exception(_SqliteError("query was interrupted", None, "SQLITE_INTERRUPT")) - assert isinstance(result, QueryTimeoutError) + assert isinstance(result, OperationCancelledError) -def test_interrupt_text_heuristic_maps_to_query_timeout() -> None: +def test_interrupt_text_heuristic_maps_to_operation_cancelled() -> None: result = create_mapped_exception(sqlite3.OperationalError("query was interrupted by application")) - assert isinstance(result, QueryTimeoutError) + assert isinstance(result, OperationCancelledError) def test_perm_error_code_maps_to_permission_denied() -> None: diff --git a/tests/unit/adapters/test_asyncpg/test_type_handlers.py b/tests/unit/adapters/test_asyncpg/test_type_handlers.py index 5e1b2f058..6e13195ef 100644 --- a/tests/unit/adapters/test_asyncpg/test_type_handlers.py +++ b/tests/unit/adapters/test_asyncpg/test_type_handlers.py @@ -7,7 +7,7 @@ from sqlspec.adapters.asyncpg.config import register_json_codecs, register_pgvector_support from sqlspec.adapters.asyncpg.core import create_mapped_exception -from sqlspec.exceptions import PermissionDeniedError, UniqueViolationError +from sqlspec.exceptions import OperationCancelledError, PermissionDeniedError, QueryTimeoutError, UniqueViolationError from sqlspec.utils.serializers import from_json, to_json @@ -109,3 +109,15 @@ def test_create_mapped_exception_uses_registered_permission_dispatch() -> None: assert isinstance(result, PermissionDeniedError) assert result.__cause__ is error + + +def test_create_mapped_exception_distinguishes_user_cancel_from_statement_timeout() -> None: + cancelled = create_mapped_exception( + asyncpg.exceptions.QueryCanceledError("canceling statement due to user request") + ) + timed_out = create_mapped_exception( + asyncpg.exceptions.QueryCanceledError("canceling statement due to statement timeout") + ) + + assert isinstance(cancelled, OperationCancelledError) + assert isinstance(timed_out, QueryTimeoutError) diff --git a/tests/unit/adapters/test_duckdb/test_exception_mapping.py b/tests/unit/adapters/test_duckdb/test_exception_mapping.py index 4241fc0c8..9fb86889d 100644 --- a/tests/unit/adapters/test_duckdb/test_exception_mapping.py +++ b/tests/unit/adapters/test_duckdb/test_exception_mapping.py @@ -19,8 +19,8 @@ NotFoundError, NotNullViolationError, OperationalError, + OperationCancelledError, PermissionDeniedError, - QueryTimeoutError, SQLParsingError, SQLSpecError, UniqueViolationError, @@ -50,7 +50,7 @@ def _make_native(name: str, message: str) -> tuple[type[BaseException], BaseExce ("ParserException", "syntax error at line 1", SQLParsingError), ("BinderException", "column not found", SQLParsingError), ("PermissionException", "access denied to resource", PermissionDeniedError), - ("InterruptException", "query interrupted", QueryTimeoutError), + ("InterruptException", "query interrupted", OperationCancelledError), ("IOException", "disk read failure", OperationalError), ("ConversionException", "could not convert string", DataError), ], @@ -99,7 +99,14 @@ class _Generic(Exception): error: BaseException = _Generic("statement was canceled by user") mapped = create_mapped_exception(error) - assert isinstance(mapped, QueryTimeoutError) + assert isinstance(mapped, OperationCancelledError) + + +def test_create_mapped_exception_type_name_fallback_interrupt() -> None: + class InterruptException(Exception): + pass + + assert isinstance(create_mapped_exception(InterruptException("query stopped")), OperationCancelledError) def test_create_mapped_exception_substring_fallback_type_mismatch_message() -> None: diff --git a/tests/unit/adapters/test_postgres_data_dictionary.py b/tests/unit/adapters/test_postgres_data_dictionary.py index b03809ba3..c989d51fb 100644 --- a/tests/unit/adapters/test_postgres_data_dictionary.py +++ b/tests/unit/adapters/test_postgres_data_dictionary.py @@ -1,4 +1,4 @@ -"""PostgreSQL-family replacement data-dictionary adapter contracts.""" +"""PostgreSQL-family data-dictionary adapter contracts.""" from typing import Any, cast diff --git a/tests/unit/adapters/test_spanner/test_type_converter.py b/tests/unit/adapters/test_spanner/test_type_converter.py index 1fc62d289..63f8ac3fa 100644 --- a/tests/unit/adapters/test_spanner/test_type_converter.py +++ b/tests/unit/adapters/test_spanner/test_type_converter.py @@ -84,8 +84,8 @@ def test_coerce_params_copies_only_when_values_require_conversion() -> None: assert coerced is not params assert coerced is not None - assert coerced["stdlib_uuid"] == base64.b64encode(stdlib_uuid.bytes) - assert coerced["utils_uuid"] == base64.b64encode(stdlib_uuid.bytes) + assert coerced["stdlib_uuid"] == str(stdlib_uuid) + assert coerced["utils_uuid"] == str(stdlib_uuid) assert coerced["binary"] == base64.b64encode(binary) assert coerced["naive_timestamp"] == naive_timestamp.replace(tzinfo=timezone.utc) assert coerced["typed_timestamp"] is typed_timestamp @@ -104,3 +104,38 @@ def test_coerce_params_copies_only_when_values_require_conversion() -> None: assert params["tuple_array"] == ("alpha", "beta") assert params["json_array"] == [{"key": "value"}] assert params["plain_array"] is plain_array + + +def test_spanner_uuid_coercion_and_param_type_inference() -> None: + """Test Spanner UUID parameter coercion to string and param_types inference.""" + from google.cloud.spanner_v1 import param_types + + from sqlspec.adapters.spanner.type_converter import infer_spanner_param_types + + stdlib_uuid = UUID("550e8400-e29b-41d4-a716-446655440000") + utils_uuid = uuid_utils.UUID("550e8400-e29b-41d4-a716-446655440000") + + params = {"stdlib_id": stdlib_uuid, "utils_id": utils_uuid} + + coerced = coerce_params_for_spanner(params, enable_uuid_conversion=True) + assert coerced is not None + assert coerced["stdlib_id"] == "550e8400-e29b-41d4-a716-446655440000" + assert coerced["utils_id"] == "550e8400-e29b-41d4-a716-446655440000" + + inferred = infer_spanner_param_types(coerced) + assert inferred["stdlib_id"] == param_types.STRING + assert inferred["utils_id"] == param_types.STRING + + +def test_spanner_uuid_conversion_disabled() -> None: + """Test that enable_uuid_conversion=False preserves original UUID instances.""" + stdlib_uuid = UUID("550e8400-e29b-41d4-a716-446655440000") + utils_uuid = uuid_utils.UUID("550e8400-e29b-41d4-a716-446655440000") + + params = {"stdlib_id": stdlib_uuid, "utils_id": utils_uuid} + + coerced = coerce_params_for_spanner(params, enable_uuid_conversion=False) + assert coerced is not None + assert coerced is params + assert coerced["stdlib_id"] is stdlib_uuid + assert coerced["utils_id"] is utils_uuid diff --git a/tests/unit/data_dictionary/test_bigquery_spanner_metadata.py b/tests/unit/data_dictionary/test_bigquery_spanner_metadata.py index 2d0baef89..8fb4cf942 100644 --- a/tests/unit/data_dictionary/test_bigquery_spanner_metadata.py +++ b/tests/unit/data_dictionary/test_bigquery_spanner_metadata.py @@ -1,4 +1,4 @@ -"""Unit tests for BigQuery and Spanner replacement metadata packs.""" +"""Unit tests for BigQuery and Spanner metadata packs.""" from typing import Any, cast diff --git a/tests/unit/data_dictionary/test_metadata_models.py b/tests/unit/data_dictionary/test_metadata_models.py index 5f24409db..51802ffbe 100644 --- a/tests/unit/data_dictionary/test_metadata_models.py +++ b/tests/unit/data_dictionary/test_metadata_models.py @@ -1,4 +1,4 @@ -"""Tests for replacement data-dictionary metadata contracts.""" +"""Tests for data-dictionary metadata contracts.""" from typing import cast diff --git a/tests/unit/data_dictionary/test_mssql_metadata.py b/tests/unit/data_dictionary/test_mssql_metadata.py index 823366662..90cd863b9 100644 --- a/tests/unit/data_dictionary/test_mssql_metadata.py +++ b/tests/unit/data_dictionary/test_mssql_metadata.py @@ -1,4 +1,4 @@ -"""SQL Server replacement data-dictionary metadata tests.""" +"""SQL Server data-dictionary metadata tests.""" from typing import Any, cast diff --git a/tests/unit/data_dictionary/test_mysql_metadata.py b/tests/unit/data_dictionary/test_mysql_metadata.py index ea31fa088..e17ee40a7 100644 --- a/tests/unit/data_dictionary/test_mysql_metadata.py +++ b/tests/unit/data_dictionary/test_mysql_metadata.py @@ -1,4 +1,4 @@ -"""Unit tests for MySQL-family replacement data-dictionary metadata.""" +"""Unit tests for MySQL-family data-dictionary metadata.""" from importlib import import_module from typing import Any, cast diff --git a/tests/unit/data_dictionary/test_postgres_cockroach_query_packs.py b/tests/unit/data_dictionary/test_postgres_cockroach_query_packs.py index 0f4c5afc2..63eb83bd4 100644 --- a/tests/unit/data_dictionary/test_postgres_cockroach_query_packs.py +++ b/tests/unit/data_dictionary/test_postgres_cockroach_query_packs.py @@ -1,4 +1,4 @@ -"""PostgreSQL-family replacement data-dictionary query-pack contracts.""" +"""PostgreSQL-family data-dictionary query-pack contracts.""" import pytest diff --git a/tests/unit/data_dictionary/test_sqlite_duckdb_metadata.py b/tests/unit/data_dictionary/test_sqlite_duckdb_metadata.py index ffcfcffde..d397d94d8 100644 --- a/tests/unit/data_dictionary/test_sqlite_duckdb_metadata.py +++ b/tests/unit/data_dictionary/test_sqlite_duckdb_metadata.py @@ -1,4 +1,4 @@ -"""SQLite and DuckDB data-dictionary replacement metadata tests.""" +"""SQLite and DuckDB data-dictionary metadata tests.""" import sqlite3 from typing import cast diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 000000000..2dec4e1ed --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,68 @@ +"""Tests for shared SQLSpec exception contracts.""" + +import sqlite3 + +import asyncpg +from google.api_core import exceptions as api_exceptions +from psycopg import errors as pg_errors + +from sqlspec.adapters.asyncpg.core import create_mapped_exception as map_asyncpg_error +from sqlspec.adapters.bigquery.core import create_mapped_exception as map_bigquery_error +from sqlspec.adapters.psqlpy.core import create_mapped_exception as map_psqlpy_error +from sqlspec.adapters.psycopg.core import create_mapped_exception as map_psycopg_error +from sqlspec.adapters.spanner.core import create_mapped_exception as map_spanner_error +from sqlspec.adapters.sqlite.core import create_mapped_exception as map_sqlite_error +from sqlspec.exceptions import OperationalError, OperationCancelledError, QueryTimeoutError, map_sqlstate_to_exception + + +def test_cancellation_and_timeout_are_sibling_operational_errors() -> None: + assert issubclass(OperationCancelledError, OperationalError) + assert issubclass(QueryTimeoutError, OperationalError) + assert not issubclass(OperationCancelledError, QueryTimeoutError) + assert not issubclass(QueryTimeoutError, OperationCancelledError) + + +def test_ambiguous_query_cancelled_sqlstate_is_operational_error() -> None: + assert map_sqlstate_to_exception("57014") is OperationalError + + +def test_postgres_native_errors_distinguish_cancellation_timeout_and_ambiguity() -> None: + for mapper, error_type in ( + (map_asyncpg_error, asyncpg.exceptions.QueryCanceledError), + (map_psycopg_error, pg_errors.QueryCanceled), + ): + assert isinstance(mapper(error_type("canceling statement due to user request")), OperationCancelledError) + assert isinstance(mapper(error_type("canceling statement due to statement timeout")), QueryTimeoutError) + assert type(mapper(error_type("query terminated"))) is OperationalError + + +def test_postgres_sqlstate_fallback_distinguishes_timeout() -> None: + class _PostgresError(Exception): + sqlstate = "57014" + + for mapper in (map_asyncpg_error, map_psycopg_error): + assert isinstance(mapper(_PostgresError("canceling statement due to statement timeout")), QueryTimeoutError) + + +def test_message_only_postgres_errors_prefer_timeout_to_cancellation() -> None: + assert isinstance(map_psqlpy_error(Exception("query cancelled by user")), OperationCancelledError) + assert isinstance(map_psqlpy_error(Exception("canceling statement due to statement timeout")), QueryTimeoutError) + + +def test_sqlite_interrupt_maps_to_operation_cancelled() -> None: + assert isinstance(map_sqlite_error(sqlite3.OperationalError("query interrupted")), OperationCancelledError) + + error = sqlite3.OperationalError("interrupted") + error.sqlite_errorcode = 9 # type: ignore[attr-defined] + error.sqlite_errorname = "SQLITE_INTERRUPT" # type: ignore[attr-defined] + assert isinstance(map_sqlite_error(error), OperationCancelledError) + + +def test_bigquery_errors_distinguish_native_cancellation_and_timeout() -> None: + assert isinstance(map_bigquery_error(api_exceptions.Cancelled("cancelled")), OperationCancelledError) # type: ignore[no-untyped-call] + assert isinstance(map_bigquery_error(Exception("deadline exceeded")), QueryTimeoutError) + + +def test_spanner_errors_distinguish_native_cancellation_and_deadline() -> None: + assert isinstance(map_spanner_error(api_exceptions.Cancelled("cancelled")), OperationCancelledError) # type: ignore[no-untyped-call] + assert isinstance(map_spanner_error(api_exceptions.DeadlineExceeded("deadline exceeded")), QueryTimeoutError) # type: ignore[no-untyped-call]