From 28347acc5ebe00970c2f30b40fc8aeb9e89f734d Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:03:59 +0000 Subject: [PATCH 1/7] feat(core): add TypeCoercionCapabilities type and config ClassVar (#780) --- pyproject.toml | 1 + sqlspec/config.py | 9 +++ sqlspec/core/__init__.py | 2 + sqlspec/core/capabilities.py | 67 ++++++++++++++++++++ tests/unit/core/test_capabilities.py | 95 ++++++++++++++++++++++++++++ 5 files changed, 174 insertions(+) create mode 100644 sqlspec/core/capabilities.py create mode 100644 tests/unit/core/test_capabilities.py diff --git a/pyproject.toml b/pyproject.toml index bb1aad4cc..1f0939245 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -250,6 +250,7 @@ exclude = [ include = [ "sqlspec/base.py", # SQLSpec registry/session manager "sqlspec/core/**/*.py", # Core module + "sqlspec/core/capabilities.py", # Introspectable type coercion capabilities "sqlspec/builder/**/*.py", # Builder module "sqlspec/exceptions.py", # Exceptions module "sqlspec/loader.py", # Loader module diff --git a/sqlspec/config.py b/sqlspec/config.py index 7b9892a15..430ad9db9 100644 --- a/sqlspec/config.py +++ b/sqlspec/config.py @@ -19,6 +19,7 @@ from typing_extensions import NotRequired, TypedDict +from sqlspec.core.capabilities import TypeCoercionCapabilities from sqlspec.core.config_runtime import ( build_default_statement_config, close_async_pool, @@ -54,6 +55,7 @@ "AsyncDatabaseConfig", "ConfigT", "ConnectionT", + "DatabaseConfigBase", "DatabaseConfigProtocol", "DriverT", "EventsConfig", @@ -75,6 +77,7 @@ "StarletteConfig", "SyncConfigT", "SyncDatabaseConfig", + "TypeCoercionCapabilities", "validate_migration_config_keys", ) @@ -959,6 +962,9 @@ class DatabaseConfigProtocol(ABC, Generic[ConnectionT, PoolT, DriverT]): supports_reliable_rowcount: "ClassVar[bool]" = True default_storage_profile: "ClassVar[str | None]" = None storage_partition_strategies: "ClassVar[tuple[str, ...]]" = ("fixed",) + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="native" + ) bind_key: "str | None" statement_config: "StatementConfig" connection_config: "dict[str, Any]" @@ -1591,6 +1597,9 @@ def _provide_session_impl( ) +DatabaseConfigBase: TypeAlias = DatabaseConfigProtocol + + class _SyncMigrationMixin: """Shared sync migration convenience methods.""" diff --git a/sqlspec/core/__init__.py b/sqlspec/core/__init__.py index b7be2f8b8..9d2199c4c 100644 --- a/sqlspec/core/__init__.py +++ b/sqlspec/core/__init__.py @@ -109,6 +109,7 @@ reset_stats_only, update_cache_config, ) +from sqlspec.core.capabilities import TypeCoercionCapabilities from sqlspec.core.compiler import ( CompiledSQL, OperationProfile, @@ -301,6 +302,7 @@ "StatementFilter", "StatementResult", "StatementStack", + "TypeCoercionCapabilities", "TypedParameter", "apply_filter", "apply_limit", diff --git a/sqlspec/core/capabilities.py b/sqlspec/core/capabilities.py new file mode 100644 index 000000000..785c481a9 --- /dev/null +++ b/sqlspec/core/capabilities.py @@ -0,0 +1,67 @@ +"""Type coercion and serialization capabilities.""" + +from typing import Any, Literal + +from mypy_extensions import mypyc_attr + +__all__ = ("TypeCoercionCapabilities",) + + +@mypyc_attr(allow_interpreted_subclasses=False) +class TypeCoercionCapabilities: + """Type coercion capabilities supported by a database adapter.""" + + __slots__ = ("datetime_binding", "json_columns_decoded", "timestamp_precision", "uuid_binding") + + datetime_binding: Literal["native", "iso_text", "naive_utc"] + timestamp_precision: Literal["microsecond", "millisecond", "second"] + json_columns_decoded: bool + uuid_binding: Literal["native", "text"] + + def __init__( + self, + datetime_binding: Literal["native", "iso_text", "naive_utc"], + timestamp_precision: Literal["microsecond", "millisecond", "second"], + json_columns_decoded: bool, + uuid_binding: Literal["native", "text"], + ) -> None: + super().__setattr__("datetime_binding", datetime_binding) + super().__setattr__("timestamp_precision", timestamp_precision) + super().__setattr__("json_columns_decoded", json_columns_decoded) + super().__setattr__("uuid_binding", uuid_binding) + + def __setattr__(self, name: str, value: Any) -> None: + msg = f"{type(self).__name__} is immutable" + raise AttributeError(msg) + + def __delattr__(self, name: str) -> None: + msg = f"{type(self).__name__} is immutable" + raise AttributeError(msg) + + def __eq__(self, other: object) -> bool: + if not isinstance(other, TypeCoercionCapabilities): + return False + return ( + self.datetime_binding == other.datetime_binding + and self.timestamp_precision == other.timestamp_precision + and self.json_columns_decoded == other.json_columns_decoded + and self.uuid_binding == other.uuid_binding + ) + + def __hash__(self) -> int: + return hash((self.datetime_binding, self.timestamp_precision, self.json_columns_decoded, self.uuid_binding)) + + def __repr__(self) -> str: + return ( + f"TypeCoercionCapabilities(" + f"datetime_binding={self.datetime_binding!r}, " + f"timestamp_precision={self.timestamp_precision!r}, " + f"json_columns_decoded={self.json_columns_decoded!r}, " + f"uuid_binding={self.uuid_binding!r})" + ) + + def __reduce__(self) -> tuple[Any, ...]: + return ( + TypeCoercionCapabilities, + (self.datetime_binding, self.timestamp_precision, self.json_columns_decoded, self.uuid_binding), + ) diff --git a/tests/unit/core/test_capabilities.py b/tests/unit/core/test_capabilities.py new file mode 100644 index 000000000..34d86c2dd --- /dev/null +++ b/tests/unit/core/test_capabilities.py @@ -0,0 +1,95 @@ +"""Tests for TypeCoercionCapabilities and configuration integration.""" + +import copy +import pickle + +import pytest + +from sqlspec.config import DatabaseConfigBase, DatabaseConfigProtocol +from sqlspec.core.capabilities import TypeCoercionCapabilities + + +def test_capabilities_construction() -> None: + """Verify capabilities object initializes with given values.""" + cap = TypeCoercionCapabilities( + datetime_binding="iso_text", timestamp_precision="millisecond", json_columns_decoded=True, uuid_binding="text" + ) + assert cap.datetime_binding == "iso_text" + assert cap.timestamp_precision == "millisecond" + assert cap.json_columns_decoded is True + assert cap.uuid_binding == "text" + + +def test_capabilities_frozen_and_slots() -> None: + """Verify capabilities is a slots class and immutable.""" + cap = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="native" + ) + assert not hasattr(cap, "__dict__") + + with pytest.raises(AttributeError): + cap.datetime_binding = "iso_text" # type: ignore[misc] + + with pytest.raises(AttributeError): + cap.new_field = "test" # type: ignore[attr-defined] + + with pytest.raises(AttributeError): + del cap.datetime_binding # type: ignore[misc] + + +def test_capabilities_equality_and_hash() -> None: + """Verify equality, inequality, and hashing behavior.""" + cap1 = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="native" + ) + cap2 = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="native" + ) + cap3 = TypeCoercionCapabilities( + datetime_binding="naive_utc", + timestamp_precision="microsecond", + json_columns_decoded=False, + uuid_binding="native", + ) + + assert cap1 == cap2 + assert cap1 != cap3 + assert cap1 != "not_a_capability" + assert hash(cap1) == hash(cap2) + assert len({cap1, cap2, cap3}) == 2 + + +def test_capabilities_repr() -> None: + """Verify repr representation matches expected format.""" + cap = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="native" + ) + expected = ( + "TypeCoercionCapabilities(" + "datetime_binding='native', " + "timestamp_precision='microsecond', " + "json_columns_decoded=False, " + "uuid_binding='native')" + ) + assert repr(cap) == expected + + +def test_capabilities_copy_and_pickle() -> None: + """Verify capabilities object survives copy and pickle roundtrips.""" + cap = TypeCoercionCapabilities( + datetime_binding="iso_text", timestamp_precision="second", json_columns_decoded=True, uuid_binding="text" + ) + copied = copy.copy(cap) + assert copied == cap + pickled = pickle.loads(pickle.dumps(cap)) + assert pickled == cap + + +def test_database_config_default_capabilities() -> None: + """Verify default type coercion capabilities on base config protocol.""" + default_cap = DatabaseConfigProtocol.type_coercion_capabilities + assert default_cap.datetime_binding == "native" + assert default_cap.timestamp_precision == "microsecond" + assert default_cap.json_columns_decoded is False + assert default_cap.uuid_binding == "native" + assert DatabaseConfigBase.type_coercion_capabilities == default_cap From bd91847d6a92c7ee16dc02e85a37df42a574df88 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:16:05 +0000 Subject: [PATCH 2/7] feat(adapters): declare TypeCoercionCapabilities on all adapter configs (#780) --- docs/changelog.rst | 6 + docs/reference/adapters/index.rst | 80 ++++++++++++ sqlspec/adapters/adbc/config.py | 4 + sqlspec/adapters/aiomysql/config.py | 4 + sqlspec/adapters/aiosqlite/config.py | 4 + sqlspec/adapters/arrow_odbc/config.py | 4 + sqlspec/adapters/asyncmy/config.py | 4 + sqlspec/adapters/asyncpg/config.py | 4 + sqlspec/adapters/bigquery/config.py | 4 + sqlspec/adapters/cockroach_asyncpg/config.py | 4 + sqlspec/adapters/cockroach_psycopg/config.py | 7 + sqlspec/adapters/duckdb/config.py | 4 + sqlspec/adapters/mssql_python/config.py | 4 + sqlspec/adapters/mysqlconnector/config.py | 7 + sqlspec/adapters/oracledb/config.py | 7 + sqlspec/adapters/psqlpy/config.py | 4 + sqlspec/adapters/psycopg/config.py | 7 + sqlspec/adapters/pymssql/config.py | 4 + sqlspec/adapters/pymysql/config.py | 4 + sqlspec/adapters/spanner/config.py | 4 + sqlspec/adapters/sqlite/config.py | 4 + .../duckdb/test_type_coercion_capabilities.py | 43 +++++++ .../test_type_coercion_capabilities.py | 120 ++++++++++++++++++ .../sqlite/test_type_coercion_capabilities.py | 77 +++++++++++ .../adapters/test_adapter_implementations.py | 58 +++++++++ 25 files changed, 472 insertions(+) create mode 100644 tests/integration/adapters/duckdb/test_type_coercion_capabilities.py create mode 100644 tests/integration/adapters/postgres/test_type_coercion_capabilities.py create mode 100644 tests/integration/adapters/sqlite/test_type_coercion_capabilities.py diff --git a/docs/changelog.rst b/docs/changelog.rst index d177ab66b..1d79f9d06 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -50,6 +50,12 @@ v0.63.0 - Transactions, table fixtures, SQL fragments, storage, and kwargs param the explicit ``native_json=True`` override remains available. (`#781 `_) +* Expose :class:`~sqlspec.core.TypeCoercionCapabilities` on all adapter configuration classes + as a ``type_coercion_capabilities`` ClassVar, declaring each adapter's datetime binding mode + (``native``, ``iso_text``, or ``naive_utc``), timestamp precision (``microsecond``, ``millisecond``, or ``second``), + JSON column decoding behavior, and UUID binding mode (``native`` or ``text``). + (`#780 `_) + * Sync and async drivers provide :meth:`~sqlspec.driver.SyncDriverAdapterBase.transaction`, a context manager that begins a transaction, commits when the block succeeds, and rolls back and re-raises when it fails; a failed commit is followed by a rollback attempt. A block entered while diff --git a/docs/reference/adapters/index.rst b/docs/reference/adapters/index.rst index 3021e0c9e..7b5e2dd2e 100644 --- a/docs/reference/adapters/index.rst +++ b/docs/reference/adapters/index.rst @@ -139,6 +139,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - Arrow Support - Native Pipelines - Reliable rowcount + - Datetime Binding + - Timestamp Precision + - JSON Decoded + - UUID Binding * - asyncpg - - Yes @@ -146,6 +150,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - Yes - Yes - Yes + - Native + - Microsecond + - Yes + - Native * - psycopg - Yes - Yes @@ -153,6 +161,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - Yes - Yes - Yes + - Native + - Microsecond + - Yes + - Native * - psqlpy - - Yes @@ -160,6 +172,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - Yes - - Yes + - Native + - Microsecond + - + - Native * - sqlite - Yes - @@ -167,6 +183,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - - - Yes + - ISO Text + - Microsecond + - + - Text * - aiosqlite - - Yes @@ -174,6 +194,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - - - Yes + - ISO Text + - Microsecond + - + - Text * - duckdb - Yes - @@ -181,6 +205,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - Yes - - Yes + - ISO Text + - Microsecond + - + - Text * - oracledb - Yes - Yes @@ -188,6 +216,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - - Yes - Yes + - Native + - Microsecond + - Yes + - Native * - mysql-connector - Yes - Yes @@ -195,6 +227,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - - - Yes + - Native + - Microsecond + - + - Text * - pymysql - Yes - @@ -202,6 +238,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - - - Yes + - Native + - Microsecond + - + - Text * - asyncmy - - Yes @@ -209,6 +249,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - - - Yes + - Native + - Microsecond + - + - Text * - aiomysql - - Yes @@ -216,6 +260,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - - - Yes + - Native + - Microsecond + - + - Text * - bigquery - Yes - @@ -223,6 +271,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - Yes - - Yes + - Native + - Microsecond + - + - Text * - spanner - Yes - @@ -230,6 +282,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - - - Yes + - Native + - Microsecond + - Yes + - Text * - cockroach (asyncpg) - - Yes @@ -237,6 +293,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - Yes - Yes - Yes + - Native + - Microsecond + - Yes + - Native * - cockroach (psycopg) - Yes - Yes @@ -244,6 +304,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - Yes - Yes - Yes + - Native + - Microsecond + - Yes + - Native * - adbc - Yes - @@ -251,6 +315,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - Yes - - No + - Native + - Microsecond + - + - Native * - arrow_odbc - Yes - @@ -258,6 +326,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - Yes - - No + - Native + - Millisecond + - + - Text * - mssql_python - Yes - @@ -265,6 +337,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - Yes - - Yes + - Native + - Microsecond + - + - Native * - pymssql - Yes - @@ -272,6 +348,10 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - - - Yes + - Native + - Microsecond + - + - Text .. toctree:: :hidden: diff --git a/sqlspec/adapters/adbc/config.py b/sqlspec/adapters/adbc/config.py index b5c957244..d4ff0c5eb 100644 --- a/sqlspec/adapters/adbc/config.py +++ b/sqlspec/adapters/adbc/config.py @@ -22,6 +22,7 @@ from sqlspec.adapters.adbc.driver import AdbcDriver, AdbcExceptionHandler from sqlspec.config import ExtensionConfigs, NoPoolSyncConfig from sqlspec.core import StatementConfig +from sqlspec.core.capabilities import TypeCoercionCapabilities from sqlspec.driver._sync import SyncPoolConnectionContext, SyncPoolSessionFactory from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.events import EventRuntimeHints @@ -196,6 +197,9 @@ class AdbcConfig(NoPoolSyncConfig[AdbcConnection, AdbcDriver]): supports_native_parquet_import: "ClassVar[bool]" = True supports_reliable_rowcount: ClassVar[bool] = False storage_partition_strategies: "ClassVar[tuple[str, ...]]" = ("fixed", "rows_per_chunk") + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="native" + ) _connection_context_class: "ClassVar[type[AdbcConnectionContext]]" = AdbcConnectionContext _session_factory_class: "ClassVar[type[_AdbcSessionConnectionHandler]]" = _AdbcSessionConnectionHandler _session_context_class: "ClassVar[type[AdbcSessionContext]]" = AdbcSessionContext diff --git a/sqlspec/adapters/aiomysql/config.py b/sqlspec/adapters/aiomysql/config.py index e4ebdafc1..d5b6fdaa9 100644 --- a/sqlspec/adapters/aiomysql/config.py +++ b/sqlspec/adapters/aiomysql/config.py @@ -18,6 +18,7 @@ from sqlspec.adapters.aiomysql.core import apply_driver_features, default_statement_config from sqlspec.adapters.aiomysql.driver import AiomysqlDriver, AiomysqlExceptionHandler from sqlspec.config import AsyncDatabaseConfig, ExtensionConfigs +from sqlspec.core import TypeCoercionCapabilities from sqlspec.driver._async import AsyncPoolConnectionContext, AsyncPoolSessionFactory from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.events import EventRuntimeHints @@ -212,6 +213,9 @@ class AiomysqlConfig(AsyncDatabaseConfig[AiomysqlConnection, "AiomysqlPool", Aio supports_native_arrow_import: ClassVar[bool] = True supports_native_parquet_import: ClassVar[bool] = True supports_native_row_streaming: ClassVar[bool] = True + type_coercion_capabilities: ClassVar[TypeCoercionCapabilities] = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="text" + ) _connection_context_class: "ClassVar[type[AiomysqlConnectionContext]]" = AiomysqlConnectionContext _session_factory_class: "ClassVar[type[_AiomysqlSessionFactory]]" = _AiomysqlSessionFactory _session_context_class: "ClassVar[type[AiomysqlSessionContext]]" = AiomysqlSessionContext diff --git a/sqlspec/adapters/aiosqlite/config.py b/sqlspec/adapters/aiosqlite/config.py index 6e35fd12f..48154a9cb 100644 --- a/sqlspec/adapters/aiosqlite/config.py +++ b/sqlspec/adapters/aiosqlite/config.py @@ -23,6 +23,7 @@ ) from sqlspec.adapters.aiosqlite.type_converter import register_type_handlers from sqlspec.config import AsyncDatabaseConfig, ExtensionConfigs +from sqlspec.core.capabilities import TypeCoercionCapabilities from sqlspec.driver._async import AsyncPoolConnectionContext, AsyncPoolSessionFactory from sqlspec.exceptions import ImproperConfigurationError from sqlspec.utils.config_tools import normalize_connection_config @@ -251,6 +252,9 @@ class AiosqliteConfig(AsyncDatabaseConfig["AiosqliteConnection", AiosqliteConnec supports_native_parquet_export: "ClassVar[bool]" = True supports_native_parquet_import: "ClassVar[bool]" = True supports_native_row_streaming: "ClassVar[bool]" = True + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="iso_text", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="text" + ) _connection_context_class: "ClassVar[type[AiosqliteConnectionContext]]" = AiosqliteConnectionContext _session_factory_class: "ClassVar[type[_AiosqliteSessionFactory]]" = _AiosqliteSessionFactory _session_context_class: "ClassVar[type[AiosqliteSessionContext]]" = AiosqliteSessionContext diff --git a/sqlspec/adapters/arrow_odbc/config.py b/sqlspec/adapters/arrow_odbc/config.py index 9d7e3c1ad..58b849069 100644 --- a/sqlspec/adapters/arrow_odbc/config.py +++ b/sqlspec/adapters/arrow_odbc/config.py @@ -14,6 +14,7 @@ ) from sqlspec.adapters.arrow_odbc.driver import ArrowOdbcDriver from sqlspec.config import ExtensionConfigs, NoPoolSyncConfig +from sqlspec.core.capabilities import TypeCoercionCapabilities from sqlspec.driver._sync import SyncPoolConnectionContext, SyncPoolSessionFactory from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.events import EventRuntimeHints @@ -138,6 +139,9 @@ class ArrowOdbcConfig(NoPoolSyncConfig[ArrowOdbcConnection, ArrowOdbcDriver]): supports_native_parquet_export: "ClassVar[bool]" = False supports_native_parquet_import: "ClassVar[bool]" = False supports_reliable_rowcount: "ClassVar[bool]" = False + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="millisecond", json_columns_decoded=False, uuid_binding="text" + ) _connection_context_class: "ClassVar[type[ArrowOdbcConnectionContext]]" = ArrowOdbcConnectionContext _session_factory_class: "ClassVar[type[_ArrowOdbcSessionConnectionHandler]]" = _ArrowOdbcSessionConnectionHandler _session_context_class: "ClassVar[type[ArrowOdbcSessionContext]]" = ArrowOdbcSessionContext diff --git a/sqlspec/adapters/asyncmy/config.py b/sqlspec/adapters/asyncmy/config.py index 429c4fdf9..bb5f438a4 100644 --- a/sqlspec/adapters/asyncmy/config.py +++ b/sqlspec/adapters/asyncmy/config.py @@ -19,6 +19,7 @@ from sqlspec.adapters.asyncmy.core import apply_driver_features, default_statement_config from sqlspec.adapters.asyncmy.driver import AsyncmyDriver, AsyncmyExceptionHandler from sqlspec.config import AsyncDatabaseConfig, ExtensionConfigs +from sqlspec.core import TypeCoercionCapabilities from sqlspec.driver._async import AsyncPoolConnectionContext, AsyncPoolSessionFactory from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.events import EventRuntimeHints @@ -248,6 +249,9 @@ class AsyncmyConfig(AsyncDatabaseConfig[AsyncmyConnection, "AsyncmyPool", Asyncm supports_native_arrow_import: ClassVar[bool] = True supports_native_parquet_import: ClassVar[bool] = True supports_native_row_streaming: ClassVar[bool] = True + type_coercion_capabilities: ClassVar[TypeCoercionCapabilities] = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="text" + ) _connection_context_class: "ClassVar[type[AsyncmyConnectionContext]]" = AsyncmyConnectionContext _session_factory_class: "ClassVar[type[_AsyncmySessionFactory]]" = _AsyncmySessionFactory _session_context_class: "ClassVar[type[AsyncmySessionContext]]" = AsyncmySessionContext diff --git a/sqlspec/adapters/asyncpg/config.py b/sqlspec/adapters/asyncpg/config.py index ee0740d8f..b0c0c6c14 100644 --- a/sqlspec/adapters/asyncpg/config.py +++ b/sqlspec/adapters/asyncpg/config.py @@ -29,6 +29,7 @@ ) from sqlspec.adapters.asyncpg.driver import AsyncpgDriver, AsyncpgExceptionHandler from sqlspec.config import AsyncDatabaseConfig, ExtensionConfigs +from sqlspec.core.capabilities import TypeCoercionCapabilities from sqlspec.driver._async import AsyncPoolConnectionContext, AsyncPoolSessionFactory from sqlspec.exceptions import ImproperConfigurationError, MissingDependencyError from sqlspec.extensions.events import EventRuntimeHints @@ -276,6 +277,9 @@ class AsyncpgConfig(AsyncDatabaseConfig[AsyncpgConnection, "Pool[Record]", Async supports_native_parquet_export: "ClassVar[bool]" = True supports_native_parquet_import: "ClassVar[bool]" = True supports_native_row_streaming: "ClassVar[bool]" = True + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=True, uuid_binding="native" + ) _connection_context_class: "ClassVar[type[AsyncpgConnectionContext]]" = AsyncpgConnectionContext _session_factory_class: "ClassVar[type[_AsyncpgSessionFactory]]" = _AsyncpgSessionFactory _session_context_class: "ClassVar[type[AsyncpgSessionContext]]" = AsyncpgSessionContext diff --git a/sqlspec/adapters/bigquery/config.py b/sqlspec/adapters/bigquery/config.py index a3a7bbd11..ce10d5a74 100644 --- a/sqlspec/adapters/bigquery/config.py +++ b/sqlspec/adapters/bigquery/config.py @@ -9,6 +9,7 @@ from sqlspec.adapters.bigquery.core import apply_driver_features, default_statement_config from sqlspec.adapters.bigquery.driver import BigQueryDriver, BigQueryExceptionHandler from sqlspec.config import ExtensionConfigs, NoPoolSyncConfig +from sqlspec.core import TypeCoercionCapabilities from sqlspec.driver._sync import SyncPoolConnectionContext, SyncPoolSessionFactory from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.events import EventRuntimeHints @@ -178,6 +179,9 @@ class BigQueryConfig(NoPoolSyncConfig[BigQueryConnection, BigQueryDriver]): supports_arrow_streaming: ClassVar[bool] = True supports_native_row_streaming: ClassVar[bool] = True supports_native_parquet_export: ClassVar[bool] = True + type_coercion_capabilities: ClassVar[TypeCoercionCapabilities] = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="text" + ) _connection_context_class: "ClassVar[type[BigQueryConnectionContext]]" = BigQueryConnectionContext _session_factory_class: "ClassVar[type[_BigQuerySessionConnectionHandler]]" = _BigQuerySessionConnectionHandler _session_context_class: "ClassVar[type[BigQuerySessionContext]]" = BigQuerySessionContext diff --git a/sqlspec/adapters/cockroach_asyncpg/config.py b/sqlspec/adapters/cockroach_asyncpg/config.py index 6e1212d13..51b1eeb9a 100644 --- a/sqlspec/adapters/cockroach_asyncpg/config.py +++ b/sqlspec/adapters/cockroach_asyncpg/config.py @@ -21,6 +21,7 @@ ) from sqlspec.adapters.cockroach_asyncpg.driver import CockroachAsyncpgDriver, CockroachAsyncpgExceptionHandler from sqlspec.config import AsyncDatabaseConfig, ExtensionConfigs +from sqlspec.core.capabilities import TypeCoercionCapabilities from sqlspec.driver._async import AsyncPoolConnectionContext, AsyncPoolSessionFactory from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.events import EventRuntimeHints @@ -192,6 +193,9 @@ class CockroachAsyncpgConfig( supports_native_parquet_export: "ClassVar[bool]" = True supports_native_parquet_import: "ClassVar[bool]" = True supports_native_row_streaming: "ClassVar[bool]" = True + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=True, uuid_binding="native" + ) _connection_context_class: "ClassVar[type[CockroachAsyncpgConnectionContext]]" = CockroachAsyncpgConnectionContext _session_factory_class: "ClassVar[type[_CockroachAsyncpgSessionFactory]]" = _CockroachAsyncpgSessionFactory _session_context_class: "ClassVar[type[CockroachAsyncpgSessionContext]]" = CockroachAsyncpgSessionContext diff --git a/sqlspec/adapters/cockroach_psycopg/config.py b/sqlspec/adapters/cockroach_psycopg/config.py index d97343107..866e96a6d 100644 --- a/sqlspec/adapters/cockroach_psycopg/config.py +++ b/sqlspec/adapters/cockroach_psycopg/config.py @@ -21,6 +21,7 @@ ) from sqlspec.adapters.psycopg.core import resolve_runtime_statement_config from sqlspec.config import AsyncDatabaseConfig, ExtensionConfigs, SyncDatabaseConfig +from sqlspec.core.capabilities import TypeCoercionCapabilities from sqlspec.driver._async import AsyncPoolConnectionContext, AsyncPoolSessionFactory from sqlspec.driver._sync import SyncPoolConnectionContext, SyncPoolSessionFactory from sqlspec.exceptions import ImproperConfigurationError @@ -226,6 +227,9 @@ class CockroachPsycopgSyncConfig( supports_native_parquet_export: "ClassVar[bool]" = True supports_native_parquet_import: "ClassVar[bool]" = True supports_native_row_streaming: "ClassVar[bool]" = True + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=True, uuid_binding="native" + ) _connection_context_class: "ClassVar[type[CockroachPsycopgSyncConnectionContext]]" = ( CockroachPsycopgSyncConnectionContext ) @@ -438,6 +442,9 @@ class CockroachPsycopgAsyncConfig( supports_native_parquet_export: "ClassVar[bool]" = True supports_native_parquet_import: "ClassVar[bool]" = True supports_native_row_streaming: "ClassVar[bool]" = True + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=True, uuid_binding="native" + ) _connection_context_class: "ClassVar[type[CockroachPsycopgAsyncConnectionContext]]" = ( CockroachPsycopgAsyncConnectionContext ) diff --git a/sqlspec/adapters/duckdb/config.py b/sqlspec/adapters/duckdb/config.py index 035bb616b..bb051259d 100644 --- a/sqlspec/adapters/duckdb/config.py +++ b/sqlspec/adapters/duckdb/config.py @@ -15,6 +15,7 @@ from sqlspec.adapters.duckdb.driver import DuckDBDriver, DuckDBExceptionHandler from sqlspec.adapters.duckdb.pool import DuckDBConnectionPool from sqlspec.config import ExtensionConfigs, SyncDatabaseConfig +from sqlspec.core.capabilities import TypeCoercionCapabilities from sqlspec.driver._sync import SyncPoolConnectionContext, SyncPoolSessionFactory from sqlspec.extensions.events import EventRuntimeHints from sqlspec.utils.config_tools import normalize_connection_config @@ -242,6 +243,9 @@ class DuckDBConfig(SyncDatabaseConfig[DuckDBConnection, DuckDBConnectionPool, Du supports_native_parquet_export: "ClassVar[bool]" = True supports_native_parquet_import: "ClassVar[bool]" = True storage_partition_strategies: "ClassVar[tuple[str, ...]]" = ("fixed", "rows_per_chunk", "manifest") + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="iso_text", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="text" + ) _connection_context_class: "ClassVar[type[DuckDBConnectionContext]]" = DuckDBConnectionContext _session_factory_class: "ClassVar[type[_DuckDBSessionConnectionHandler]]" = _DuckDBSessionConnectionHandler _session_context_class: "ClassVar[type[DuckDBSessionContext]]" = DuckDBSessionContext diff --git a/sqlspec/adapters/mssql_python/config.py b/sqlspec/adapters/mssql_python/config.py index 6484d86de..4e5580afd 100644 --- a/sqlspec/adapters/mssql_python/config.py +++ b/sqlspec/adapters/mssql_python/config.py @@ -10,6 +10,7 @@ from sqlspec.adapters.mssql_python.migrations import MssqlPythonSyncMigrationTracker from sqlspec.adapters.mssql_python.pool import MssqlPythonConnectionPool from sqlspec.config import ExtensionConfigs, SyncDatabaseConfig +from sqlspec.core import TypeCoercionCapabilities from sqlspec.driver._sync import SyncPoolConnectionContext, SyncPoolSessionFactory from sqlspec.utils.config_tools import normalize_connection_config from sqlspec.utils.serializers import from_json, to_json @@ -174,6 +175,9 @@ class MssqlPythonConfig(SyncDatabaseConfig[MssqlPythonConnection, MssqlPythonCon supports_native_row_streaming: "ClassVar[bool]" = True supports_native_parquet_export: "ClassVar[bool]" = False supports_native_parquet_import: "ClassVar[bool]" = False + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="native" + ) _connection_context_class: "ClassVar[type[MssqlPythonConnectionContext]]" = MssqlPythonConnectionContext _session_factory_class: "ClassVar[type[_MssqlPythonSyncSessionConnectionHandler]]" = ( _MssqlPythonSyncSessionConnectionHandler diff --git a/sqlspec/adapters/mysqlconnector/config.py b/sqlspec/adapters/mysqlconnector/config.py index 82310d7e4..ad1dfecfc 100644 --- a/sqlspec/adapters/mysqlconnector/config.py +++ b/sqlspec/adapters/mysqlconnector/config.py @@ -26,6 +26,7 @@ MysqlConnectorSyncExceptionHandler, ) from sqlspec.config import ExtensionConfigs, NoPoolAsyncConfig, SyncDatabaseConfig +from sqlspec.core import TypeCoercionCapabilities from sqlspec.driver._async import AsyncPoolConnectionContext, AsyncPoolSessionFactory from sqlspec.driver._sync import SyncPoolConnectionContext, SyncPoolSessionFactory from sqlspec.exceptions import ImproperConfigurationError @@ -288,6 +289,9 @@ class MysqlConnectorSyncConfig( supports_native_arrow_import: ClassVar[bool] = True supports_native_parquet_import: ClassVar[bool] = True supports_native_row_streaming: ClassVar[bool] = True + type_coercion_capabilities: ClassVar[TypeCoercionCapabilities] = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="text" + ) _connection_context_class: "ClassVar[type[MysqlConnectorSyncConnectionContext]]" = ( MysqlConnectorSyncConnectionContext ) @@ -407,6 +411,9 @@ class MysqlConnectorAsyncConfig(NoPoolAsyncConfig[MysqlConnectorAsyncConnection, supports_native_arrow_import: ClassVar[bool] = True supports_native_parquet_import: ClassVar[bool] = True supports_native_row_streaming: ClassVar[bool] = True + type_coercion_capabilities: ClassVar[TypeCoercionCapabilities] = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="text" + ) _connection_context_class: "ClassVar[type[MysqlConnectorAsyncConnectionContext]]" = ( MysqlConnectorAsyncConnectionContext ) diff --git a/sqlspec/adapters/oracledb/config.py b/sqlspec/adapters/oracledb/config.py index 518a0ad32..baf9544ec 100644 --- a/sqlspec/adapters/oracledb/config.py +++ b/sqlspec/adapters/oracledb/config.py @@ -32,6 +32,7 @@ ) from sqlspec.adapters.oracledb.migrations import OracleAsyncMigrationTracker, OracleSyncMigrationTracker from sqlspec.config import AsyncDatabaseConfig, ExtensionConfigs, SyncDatabaseConfig +from sqlspec.core.capabilities import TypeCoercionCapabilities from sqlspec.driver._async import AsyncPoolConnectionContext, AsyncPoolSessionFactory from sqlspec.driver._sync import SyncPoolConnectionContext, SyncPoolSessionFactory from sqlspec.extensions.events import EventRuntimeHints @@ -287,6 +288,9 @@ class OracleSyncConfig(SyncDatabaseConfig[OracleSyncConnection, "OracleSyncConne supports_native_parquet_export: ClassVar[bool] = True supports_native_parquet_import: ClassVar[bool] = True supports_native_row_streaming: ClassVar[bool] = True + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=True, uuid_binding="native" + ) _connection_context_class: "ClassVar[type[OracleSyncConnectionContext]]" = OracleSyncConnectionContext _session_factory_class: "ClassVar[type[_OracleSyncSessionConnectionHandler]]" = _OracleSyncSessionConnectionHandler _session_context_class: "ClassVar[type[OracleSyncSessionContext]]" = OracleSyncSessionContext @@ -487,6 +491,9 @@ class OracleAsyncConfig(AsyncDatabaseConfig[OracleAsyncConnection, "OracleAsyncC supports_native_parquet_export: ClassVar[bool] = True supports_native_parquet_import: ClassVar[bool] = True supports_native_row_streaming: ClassVar[bool] = True + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=True, uuid_binding="native" + ) _connection_context_class: "ClassVar[type[OracleAsyncConnectionContext]]" = OracleAsyncConnectionContext _session_factory_class: "ClassVar[type[_OracleAsyncSessionConnectionHandler]]" = ( _OracleAsyncSessionConnectionHandler diff --git a/sqlspec/adapters/psqlpy/config.py b/sqlspec/adapters/psqlpy/config.py index 772cbbba8..1dc34191b 100644 --- a/sqlspec/adapters/psqlpy/config.py +++ b/sqlspec/adapters/psqlpy/config.py @@ -17,6 +17,7 @@ ) from sqlspec.adapters.psqlpy.driver import PsqlpyDriver, PsqlpyExceptionHandler from sqlspec.config import AsyncDatabaseConfig, ExtensionConfigs +from sqlspec.core.capabilities import TypeCoercionCapabilities from sqlspec.driver._async import AsyncPoolConnectionContext, AsyncPoolSessionFactory from sqlspec.extensions.events import EventRuntimeHints from sqlspec.utils.config_tools import normalize_connection_config @@ -198,6 +199,9 @@ class PsqlpyConfig(AsyncDatabaseConfig[PsqlpyConnection, "ConnectionPool", Psqlp supports_native_parquet_export: ClassVar[bool] = True supports_native_parquet_import: ClassVar[bool] = True supports_native_row_streaming: ClassVar[bool] = True + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="native" + ) _connection_context_class: "ClassVar[type[PsqlpyConnectionContext]]" = PsqlpyConnectionContext _session_factory_class: "ClassVar[type[_PsqlpySessionFactory]]" = _PsqlpySessionFactory _session_context_class: "ClassVar[type[PsqlpySessionContext]]" = PsqlpySessionContext diff --git a/sqlspec/adapters/psycopg/config.py b/sqlspec/adapters/psycopg/config.py index 006599aa1..14529adce 100644 --- a/sqlspec/adapters/psycopg/config.py +++ b/sqlspec/adapters/psycopg/config.py @@ -31,6 +31,7 @@ ) from sqlspec.adapters.psycopg.type_converter import register_pgvector_async, register_pgvector_sync from sqlspec.config import AsyncDatabaseConfig, ExtensionConfigs, SyncDatabaseConfig +from sqlspec.core.capabilities import TypeCoercionCapabilities from sqlspec.driver._async import AsyncPoolConnectionContext, AsyncPoolSessionFactory from sqlspec.driver._sync import SyncPoolConnectionContext, SyncPoolSessionFactory from sqlspec.exceptions import ImproperConfigurationError, MissingDependencyError @@ -272,6 +273,9 @@ class PsycopgSyncConfig(SyncDatabaseConfig[PsycopgSyncConnection, ConnectionPool supports_native_parquet_export: "ClassVar[bool]" = True supports_native_parquet_import: "ClassVar[bool]" = True supports_native_row_streaming: "ClassVar[bool]" = True + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=True, uuid_binding="native" + ) _connection_context_class: "ClassVar[type[PsycopgSyncConnectionContext]]" = PsycopgSyncConnectionContext _session_factory_class: "ClassVar[type[_PsycopgSyncSessionConnectionHandler]]" = ( _PsycopgSyncSessionConnectionHandler @@ -616,6 +620,9 @@ class PsycopgAsyncConfig(AsyncDatabaseConfig[PsycopgAsyncConnection, AsyncConnec supports_native_parquet_export: ClassVar[bool] = True supports_native_parquet_import: ClassVar[bool] = True supports_native_row_streaming: ClassVar[bool] = True + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=True, uuid_binding="native" + ) _connection_context_class: "ClassVar[type[PsycopgAsyncConnectionContext]]" = PsycopgAsyncConnectionContext _session_factory_class: "ClassVar[type[_PsycopgAsyncSessionConnectionHandler]]" = ( _PsycopgAsyncSessionConnectionHandler diff --git a/sqlspec/adapters/pymssql/config.py b/sqlspec/adapters/pymssql/config.py index d6b6d559d..03a33f3d0 100644 --- a/sqlspec/adapters/pymssql/config.py +++ b/sqlspec/adapters/pymssql/config.py @@ -11,6 +11,7 @@ from sqlspec.adapters.pymssql.migrations import PymssqlSyncMigrationTracker from sqlspec.adapters.pymssql.pool import PymssqlConnectionPool from sqlspec.config import ExtensionConfigs, SyncDatabaseConfig +from sqlspec.core import TypeCoercionCapabilities from sqlspec.driver._sync import SyncPoolConnectionContext, SyncPoolSessionFactory from sqlspec.extensions.events import EventRuntimeHints from sqlspec.utils.config_tools import normalize_connection_config @@ -98,6 +99,9 @@ class PymssqlConfig(SyncDatabaseConfig[PymssqlConnection, PymssqlConnectionPool, supports_native_parquet_export: "ClassVar[bool]" = False supports_native_parquet_import: "ClassVar[bool]" = False supports_native_row_streaming: "ClassVar[bool]" = True + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="text" + ) _connection_context_class: "ClassVar[type[PymssqlConnectionContext]]" = PymssqlConnectionContext _session_factory_class: "ClassVar[type[_PymssqlSessionConnectionHandler]]" = _PymssqlSessionConnectionHandler _session_context_class: "ClassVar[type[PymssqlSessionContext]]" = PymssqlSessionContext diff --git a/sqlspec/adapters/pymysql/config.py b/sqlspec/adapters/pymysql/config.py index 226e9f4e6..0150086cd 100644 --- a/sqlspec/adapters/pymysql/config.py +++ b/sqlspec/adapters/pymysql/config.py @@ -11,6 +11,7 @@ from sqlspec.adapters.pymysql.driver import PyMysqlDriver, PyMysqlExceptionHandler from sqlspec.adapters.pymysql.pool import PyMysqlConnectionPool from sqlspec.config import ExtensionConfigs, SyncDatabaseConfig +from sqlspec.core import TypeCoercionCapabilities from sqlspec.driver._sync import SyncPoolConnectionContext, SyncPoolSessionFactory from sqlspec.exceptions import ImproperConfigurationError, MissingDependencyError from sqlspec.extensions.events import EventRuntimeHints @@ -228,6 +229,9 @@ class PyMysqlConfig(SyncDatabaseConfig[PyMysqlConnection, PyMysqlConnectionPool, supports_native_parquet_export: "ClassVar[bool]" = True supports_native_parquet_import: "ClassVar[bool]" = True supports_native_row_streaming: "ClassVar[bool]" = True + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="text" + ) _connection_context_class: "ClassVar[type[PyMysqlConnectionContext]]" = PyMysqlConnectionContext _session_factory_class: "ClassVar[type[_PyMysqlSessionConnectionHandler]]" = _PyMysqlSessionConnectionHandler _session_context_class: "ClassVar[type[PyMysqlSessionContext]]" = PyMysqlSessionContext diff --git a/sqlspec/adapters/spanner/config.py b/sqlspec/adapters/spanner/config.py index 9631345f9..6240a0845 100644 --- a/sqlspec/adapters/spanner/config.py +++ b/sqlspec/adapters/spanner/config.py @@ -8,6 +8,7 @@ from sqlspec.adapters.spanner.core import apply_driver_features, default_statement_config from sqlspec.adapters.spanner.driver import SpannerSessionContext, SpannerSyncDriver from sqlspec.config import SyncDatabaseConfig +from sqlspec.core import TypeCoercionCapabilities from sqlspec.driver._sync import SyncPoolConnectionContext, SyncPoolSessionFactory from sqlspec.exceptions import ImproperConfigurationError from sqlspec.extensions.events import EventRuntimeHints @@ -255,6 +256,9 @@ class SpannerSyncConfig(SyncDatabaseConfig["SpannerConnection", "AbstractSession supports_native_arrow_import: ClassVar[bool] = True supports_native_parquet_export: ClassVar[bool] = False supports_native_parquet_import: ClassVar[bool] = False + type_coercion_capabilities: ClassVar[TypeCoercionCapabilities] = TypeCoercionCapabilities( + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=True, uuid_binding="text" + ) _connection_context_class: "ClassVar[type[SpannerConnectionContext]]" = SpannerConnectionContext _session_factory_class: "ClassVar[type[_SpannerSessionConnectionHandler]]" = _SpannerSessionConnectionHandler _session_context_class: "ClassVar[type[SpannerSessionContext]]" = SpannerSessionContext diff --git a/sqlspec/adapters/sqlite/config.py b/sqlspec/adapters/sqlite/config.py index e40207db7..79fd55b0d 100644 --- a/sqlspec/adapters/sqlite/config.py +++ b/sqlspec/adapters/sqlite/config.py @@ -18,6 +18,7 @@ from sqlspec.adapters.sqlite.pool import SqliteConnectionPool from sqlspec.adapters.sqlite.type_converter import register_type_handlers from sqlspec.config import ExtensionConfigs, SyncDatabaseConfig +from sqlspec.core.capabilities import TypeCoercionCapabilities from sqlspec.driver._sync import SyncPoolConnectionContext, SyncPoolSessionFactory from sqlspec.exceptions import ImproperConfigurationError from sqlspec.utils.logging import get_logger @@ -191,6 +192,9 @@ class SqliteConfig(SyncDatabaseConfig[SqliteConnection, SqliteConnectionPool, Sq supports_native_parquet_export: "ClassVar[bool]" = True supports_native_parquet_import: "ClassVar[bool]" = True supports_native_row_streaming: "ClassVar[bool]" = True + type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( + datetime_binding="iso_text", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="text" + ) _connection_context_class: "ClassVar[type[SqliteConnectionContext]]" = SqliteConnectionContext _session_factory_class: "ClassVar[type[_SqliteSessionConnectionHandler]]" = _SqliteSessionConnectionHandler _session_context_class: "ClassVar[type[SqliteSessionContext]]" = SqliteSessionContext diff --git a/tests/integration/adapters/duckdb/test_type_coercion_capabilities.py b/tests/integration/adapters/duckdb/test_type_coercion_capabilities.py new file mode 100644 index 000000000..a426614db --- /dev/null +++ b/tests/integration/adapters/duckdb/test_type_coercion_capabilities.py @@ -0,0 +1,43 @@ +"""Integration test verifying TypeCoercionCapabilities against observed DuckDB behavior.""" + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest + +from sqlspec.adapters.duckdb import DuckDBConfig + +pytestmark = pytest.mark.xdist_group("duckdb") + + +def test_duckdb_type_coercion_capabilities_observed() -> None: + """Verify DuckDBConfig declared capabilities match observed runtime behavior.""" + config = DuckDBConfig(connection_config={"database": ":memory:"}) + caps = config.type_coercion_capabilities + assert caps.datetime_binding == "iso_text" + assert caps.timestamp_precision == "microsecond" + assert caps.json_columns_decoded is False + assert caps.uuid_binding == "text" + + test_dt = datetime(2024, 1, 15, 12, 30, 45, 123456, tzinfo=timezone.utc) + test_json = {"key": "value", "count": 42} + test_uuid = uuid4() + + with config.provide_session() as driver: + driver.execute_script( + "CREATE TABLE test_coercion (id INTEGER PRIMARY KEY, ts TIMESTAMPTZ, payload JSON, uid VARCHAR);" + ) + driver.execute( + "INSERT INTO test_coercion (id, ts, payload, uid) VALUES (?, ?, ?, ?)", [1, test_dt, test_json, test_uuid] + ) + row = driver.select_one("SELECT ts, payload, uid FROM test_coercion WHERE id = ?", [1]) + assert row is not None + + raw_ts, raw_payload, raw_uid = row["ts"], row["payload"], row["uid"] + assert isinstance(raw_payload, str) == (not caps.json_columns_decoded) + assert isinstance(raw_uid, str) == (caps.uuid_binding == "text") + if isinstance(raw_ts, datetime): + assert raw_ts.microsecond == test_dt.microsecond + elif isinstance(raw_ts, str): + parsed_dt = datetime.fromisoformat(raw_ts) + assert parsed_dt.microsecond == test_dt.microsecond diff --git a/tests/integration/adapters/postgres/test_type_coercion_capabilities.py b/tests/integration/adapters/postgres/test_type_coercion_capabilities.py new file mode 100644 index 000000000..94ffc7884 --- /dev/null +++ b/tests/integration/adapters/postgres/test_type_coercion_capabilities.py @@ -0,0 +1,120 @@ +"""Integration test verifying TypeCoercionCapabilities against observed PostgreSQL behavior.""" + +from datetime import datetime, timezone +from typing import TYPE_CHECKING +from uuid import uuid4 + +import pytest + +if TYPE_CHECKING: + from sqlspec.adapters.asyncpg import AsyncpgConfig + from sqlspec.adapters.psycopg import PsycopgAsyncConfig, PsycopgSyncConfig + +pytestmark = pytest.mark.xdist_group("postgres") + +SETUP_SQL = """ +DROP TABLE IF EXISTS test_coercion CASCADE; +CREATE TABLE test_coercion ( + id SERIAL PRIMARY KEY, + ts TIMESTAMPTZ, + payload JSONB, + uid UUID +); +""" +TEARDOWN_SQL = "DROP TABLE IF EXISTS test_coercion CASCADE;" + + +async def test_asyncpg_type_coercion_capabilities_observed(asyncpg_config: "AsyncpgConfig") -> None: + """Verify AsyncpgConfig declared capabilities match observed runtime behavior.""" + caps = asyncpg_config.type_coercion_capabilities + assert caps.datetime_binding == "native" + assert caps.timestamp_precision == "microsecond" + assert caps.json_columns_decoded is True + assert caps.uuid_binding == "native" + + test_dt = datetime(2024, 1, 15, 12, 30, 45, 123456, tzinfo=timezone.utc) + test_json = {"key": "value", "count": 42} + test_uuid = uuid4() + + async with asyncpg_config.provide_session() as driver: + await driver.execute_script(SETUP_SQL) + try: + await driver.execute( + "INSERT INTO test_coercion (ts, payload, uid) VALUES (:ts, :payload, :uid)", + {"ts": test_dt, "payload": test_json, "uid": test_uuid}, + ) + row = await driver.select_one("SELECT ts, payload, uid FROM test_coercion LIMIT 1") + assert row is not None + + raw_ts, raw_payload, raw_uid = row["ts"], row["payload"], row["uid"] + assert isinstance(raw_ts, datetime) + assert raw_ts.microsecond == test_dt.microsecond + assert isinstance(raw_payload, dict) == caps.json_columns_decoded + assert raw_payload == test_json + assert raw_uid == test_uuid + finally: + await driver.execute_script(TEARDOWN_SQL) + + +def test_psycopg_sync_type_coercion_capabilities_observed(psycopg_sync_config: "PsycopgSyncConfig") -> None: + """Verify PsycopgSyncConfig declared capabilities match observed runtime behavior.""" + caps = psycopg_sync_config.type_coercion_capabilities + assert caps.datetime_binding == "native" + assert caps.timestamp_precision == "microsecond" + assert caps.json_columns_decoded is True + assert caps.uuid_binding == "native" + + test_dt = datetime(2024, 1, 15, 12, 30, 45, 123456, tzinfo=timezone.utc) + test_json = {"key": "value", "count": 42} + test_uuid = uuid4() + + with psycopg_sync_config.provide_session() as driver: + driver.execute_script(SETUP_SQL) + try: + driver.execute( + "INSERT INTO test_coercion (ts, payload, uid) VALUES (%(ts)s, %(payload)s, %(uid)s)", + {"ts": test_dt, "payload": test_json, "uid": test_uuid}, + ) + row = driver.select_one("SELECT ts, payload, uid FROM test_coercion LIMIT 1") + assert row is not None + + raw_ts, raw_payload, raw_uid = row["ts"], row["payload"], row["uid"] + assert isinstance(raw_ts, datetime) + assert raw_ts.microsecond == test_dt.microsecond + assert isinstance(raw_payload, dict) == caps.json_columns_decoded + assert raw_payload == test_json + assert raw_uid == test_uuid + finally: + driver.execute_script(TEARDOWN_SQL) + + +async def test_psycopg_async_type_coercion_capabilities_observed(psycopg_async_config: "PsycopgAsyncConfig") -> None: + """Verify PsycopgAsyncConfig declared capabilities match observed runtime behavior.""" + caps = psycopg_async_config.type_coercion_capabilities + assert caps.datetime_binding == "native" + assert caps.timestamp_precision == "microsecond" + assert caps.json_columns_decoded is True + assert caps.uuid_binding == "native" + + test_dt = datetime(2024, 1, 15, 12, 30, 45, 123456, tzinfo=timezone.utc) + test_json = {"key": "value", "count": 42} + test_uuid = uuid4() + + async with psycopg_async_config.provide_session() as driver: + await driver.execute_script(SETUP_SQL) + try: + await driver.execute( + "INSERT INTO test_coercion (ts, payload, uid) VALUES (%(ts)s, %(payload)s, %(uid)s)", + {"ts": test_dt, "payload": test_json, "uid": test_uuid}, + ) + row = await driver.select_one("SELECT ts, payload, uid FROM test_coercion LIMIT 1") + assert row is not None + + raw_ts, raw_payload, raw_uid = row["ts"], row["payload"], row["uid"] + assert isinstance(raw_ts, datetime) + assert raw_ts.microsecond == test_dt.microsecond + assert isinstance(raw_payload, dict) == caps.json_columns_decoded + assert raw_payload == test_json + assert raw_uid == test_uuid + finally: + await driver.execute_script(TEARDOWN_SQL) diff --git a/tests/integration/adapters/sqlite/test_type_coercion_capabilities.py b/tests/integration/adapters/sqlite/test_type_coercion_capabilities.py new file mode 100644 index 000000000..a131dc6e7 --- /dev/null +++ b/tests/integration/adapters/sqlite/test_type_coercion_capabilities.py @@ -0,0 +1,77 @@ +"""Integration test verifying TypeCoercionCapabilities against observed SQLite behavior.""" + +from datetime import datetime, timezone +from uuid import uuid4 + +import pytest + +from sqlspec.adapters.aiosqlite import AiosqliteConfig +from sqlspec.adapters.sqlite import SqliteConfig + +pytestmark = pytest.mark.xdist_group("sqlite") + + +def test_sqlite_type_coercion_capabilities_observed() -> None: + """Verify SqliteConfig declared capabilities match observed runtime behavior.""" + config = SqliteConfig(connection_config={"database": ":memory:"}) + caps = config.type_coercion_capabilities + assert caps.datetime_binding == "iso_text" + assert caps.timestamp_precision == "microsecond" + assert caps.json_columns_decoded is False + assert caps.uuid_binding == "text" + + test_dt = datetime(2024, 1, 15, 12, 30, 45, 123456, tzinfo=timezone.utc) + test_json = {"key": "value", "count": 42} + test_uuid = uuid4() + + with config.provide_session() as driver: + driver.execute_script( + "CREATE TABLE test_coercion (id INTEGER PRIMARY KEY, ts TIMESTAMP, payload JSON, uid TEXT);" + ) + driver.execute( + "INSERT INTO test_coercion (id, ts, payload, uid) VALUES (:id, :ts, :payload, :uid)", + {"id": 1, "ts": test_dt, "payload": test_json, "uid": test_uuid}, + ) + row = driver.select_one("SELECT ts, payload, uid FROM test_coercion WHERE id = :id", {"id": 1}) + assert row is not None + + raw_ts, raw_payload, raw_uid = row["ts"], row["payload"], row["uid"] + assert isinstance(raw_payload, str) == (not caps.json_columns_decoded) + assert isinstance(raw_uid, str) == (caps.uuid_binding == "text") + if caps.datetime_binding == "iso_text": + assert isinstance(raw_ts, str) + parsed_dt = datetime.fromisoformat(raw_ts) + assert parsed_dt.microsecond == test_dt.microsecond + + +async def test_aiosqlite_type_coercion_capabilities_observed() -> None: + """Verify AiosqliteConfig declared capabilities match observed runtime behavior.""" + config = AiosqliteConfig(connection_config={"database": ":memory:"}) + caps = config.type_coercion_capabilities + assert caps.datetime_binding == "iso_text" + assert caps.timestamp_precision == "microsecond" + assert caps.json_columns_decoded is False + assert caps.uuid_binding == "text" + + test_dt = datetime(2024, 1, 15, 12, 30, 45, 123456, tzinfo=timezone.utc) + test_json = {"key": "value", "count": 42} + test_uuid = uuid4() + + async with config.provide_session() as driver: + await driver.execute_script( + "CREATE TABLE test_coercion (id INTEGER PRIMARY KEY, ts TIMESTAMP, payload JSON, uid TEXT);" + ) + await driver.execute( + "INSERT INTO test_coercion (id, ts, payload, uid) VALUES (:id, :ts, :payload, :uid)", + {"id": 1, "ts": test_dt, "payload": test_json, "uid": test_uuid}, + ) + row = await driver.select_one("SELECT ts, payload, uid FROM test_coercion WHERE id = :id", {"id": 1}) + assert row is not None + + raw_ts, raw_payload, raw_uid = row["ts"], row["payload"], row["uid"] + assert isinstance(raw_payload, str) == (not caps.json_columns_decoded) + assert isinstance(raw_uid, str) == (caps.uuid_binding == "text") + if caps.datetime_binding == "iso_text": + assert isinstance(raw_ts, str) + parsed_dt = datetime.fromisoformat(raw_ts) + assert parsed_dt.microsecond == test_dt.microsecond diff --git a/tests/unit/adapters/test_adapter_implementations.py b/tests/unit/adapters/test_adapter_implementations.py index 716c6a308..021e6d56a 100644 --- a/tests/unit/adapters/test_adapter_implementations.py +++ b/tests/unit/adapters/test_adapter_implementations.py @@ -5,13 +5,16 @@ import inspect import operator import sqlite3 +import types from pathlib import Path from typing import Any import pytest from sqlspec.adapters.sqlite.driver import SqliteDriver +from sqlspec.config import DatabaseConfigProtocol from sqlspec.core import SQL, ParameterStyle, ParameterStyleConfig, SQLResult, StatementConfig +from sqlspec.core.capabilities import TypeCoercionCapabilities from sqlspec.driver import ExecutionResult from sqlspec.exceptions import SQLParsingError, SQLSpecError @@ -641,3 +644,58 @@ def test_sql_result_building() -> None: assert script_sql_result.successful_statements == 1 finally: connection.close() + + +ADAPTER_CONFIG_MODULES = ( + "sqlspec.adapters.adbc.config", + "sqlspec.adapters.aiomysql.config", + "sqlspec.adapters.aiosqlite.config", + "sqlspec.adapters.arrow_odbc.config", + "sqlspec.adapters.asyncmy.config", + "sqlspec.adapters.asyncpg.config", + "sqlspec.adapters.bigquery.config", + "sqlspec.adapters.cockroach_asyncpg.config", + "sqlspec.adapters.cockroach_psycopg.config", + "sqlspec.adapters.duckdb.config", + "sqlspec.adapters.mssql_python.config", + "sqlspec.adapters.mysqlconnector.config", + "sqlspec.adapters.oracledb.config", + "sqlspec.adapters.psqlpy.config", + "sqlspec.adapters.psycopg.config", + "sqlspec.adapters.pymssql.config", + "sqlspec.adapters.pymysql.config", + "sqlspec.adapters.spanner.config", + "sqlspec.adapters.sqlite.config", +) + + +def test_every_config_declares_type_coercion_capabilities() -> None: + """Assert every concrete config class defines type_coercion_capabilities and it is not the base default object.""" + concrete_configs: list[type[DatabaseConfigProtocol[Any, Any, Any]]] = [] + + for module_name in ADAPTER_CONFIG_MODULES: + mod = importlib.import_module(module_name) + for _, obj in inspect.getmembers(mod): + if ( + isinstance(obj, type) + and not isinstance(obj, (types.GenericAlias, getattr(types, "UnionType", ()))) + and issubclass(obj, DatabaseConfigProtocol) + and obj is not DatabaseConfigProtocol + and not inspect.isabstract(obj) + and obj.__module__ == module_name + ): + concrete_configs.append(obj) + + assert len(concrete_configs) == 23 + + for config_cls in concrete_configs: + assert "type_coercion_capabilities" in config_cls.__dict__, ( + f"{config_cls.__name__} does not explicitly declare type_coercion_capabilities" + ) + caps = config_cls.type_coercion_capabilities + assert isinstance(caps, TypeCoercionCapabilities), ( + f"{config_cls.__name__}.type_coercion_capabilities is not TypeCoercionCapabilities" + ) + assert caps is not DatabaseConfigProtocol.type_coercion_capabilities, ( + f"{config_cls.__name__}.type_coercion_capabilities is the base default object" + ) From 1f938e0e537dea3d7dd2133b3e2855298f72e722 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:44:08 +0000 Subject: [PATCH 3/7] fix: align coercion metadata with adapter binding behavior --- docs/reference/adapters/index.rst | 10 ++- sqlspec/adapters/arrow_odbc/config.py | 2 +- sqlspec/config.py | 4 - .../duckdb/test_type_coercion_capabilities.py | 39 +++++----- .../sqlite/test_type_coercion_capabilities.py | 74 ++++++++++--------- tests/unit/core/test_capabilities.py | 20 ++++- 6 files changed, 88 insertions(+), 61 deletions(-) diff --git a/docs/reference/adapters/index.rst b/docs/reference/adapters/index.rst index 7b5e2dd2e..05334dda5 100644 --- a/docs/reference/adapters/index.rst +++ b/docs/reference/adapters/index.rst @@ -129,6 +129,12 @@ the session when deciding whether to verify a modifying statement with a follow- query. The flag describes affected-row reporting for individual DML statements; SELECT, scripts, and batch operations retain their adapter-specific count semantics. +Type coercion capabilities describe the default adapter parameter and result paths. +Custom statement coercions, driver feature overrides, and database column types can +change these behaviors. Timestamp precision describes the adapter binding path; a +lower-precision database column can still truncate the value. ``native`` binding +means the parameter pipeline preserves the Python value for the driver. + .. list-table:: :header-rows: 1 @@ -326,8 +332,8 @@ SELECT, scripts, and batch operations retain their adapter-specific count semant - Yes - - No - - Native - - Millisecond + - ISO Text + - Microsecond - - Text * - mssql_python diff --git a/sqlspec/adapters/arrow_odbc/config.py b/sqlspec/adapters/arrow_odbc/config.py index 58b849069..a09a62e4e 100644 --- a/sqlspec/adapters/arrow_odbc/config.py +++ b/sqlspec/adapters/arrow_odbc/config.py @@ -140,7 +140,7 @@ class ArrowOdbcConfig(NoPoolSyncConfig[ArrowOdbcConnection, ArrowOdbcDriver]): supports_native_parquet_import: "ClassVar[bool]" = False supports_reliable_rowcount: "ClassVar[bool]" = False type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( - datetime_binding="native", timestamp_precision="millisecond", json_columns_decoded=False, uuid_binding="text" + datetime_binding="iso_text", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="text" ) _connection_context_class: "ClassVar[type[ArrowOdbcConnectionContext]]" = ArrowOdbcConnectionContext _session_factory_class: "ClassVar[type[_ArrowOdbcSessionConnectionHandler]]" = _ArrowOdbcSessionConnectionHandler diff --git a/sqlspec/config.py b/sqlspec/config.py index 430ad9db9..3de76a9eb 100644 --- a/sqlspec/config.py +++ b/sqlspec/config.py @@ -55,7 +55,6 @@ "AsyncDatabaseConfig", "ConfigT", "ConnectionT", - "DatabaseConfigBase", "DatabaseConfigProtocol", "DriverT", "EventsConfig", @@ -1597,9 +1596,6 @@ def _provide_session_impl( ) -DatabaseConfigBase: TypeAlias = DatabaseConfigProtocol - - class _SyncMigrationMixin: """Shared sync migration convenience methods.""" diff --git a/tests/integration/adapters/duckdb/test_type_coercion_capabilities.py b/tests/integration/adapters/duckdb/test_type_coercion_capabilities.py index a426614db..fa8ca7b39 100644 --- a/tests/integration/adapters/duckdb/test_type_coercion_capabilities.py +++ b/tests/integration/adapters/duckdb/test_type_coercion_capabilities.py @@ -23,21 +23,24 @@ def test_duckdb_type_coercion_capabilities_observed() -> None: test_json = {"key": "value", "count": 42} test_uuid = uuid4() - with config.provide_session() as driver: - driver.execute_script( - "CREATE TABLE test_coercion (id INTEGER PRIMARY KEY, ts TIMESTAMPTZ, payload JSON, uid VARCHAR);" - ) - driver.execute( - "INSERT INTO test_coercion (id, ts, payload, uid) VALUES (?, ?, ?, ?)", [1, test_dt, test_json, test_uuid] - ) - row = driver.select_one("SELECT ts, payload, uid FROM test_coercion WHERE id = ?", [1]) - assert row is not None - - raw_ts, raw_payload, raw_uid = row["ts"], row["payload"], row["uid"] - assert isinstance(raw_payload, str) == (not caps.json_columns_decoded) - assert isinstance(raw_uid, str) == (caps.uuid_binding == "text") - if isinstance(raw_ts, datetime): - assert raw_ts.microsecond == test_dt.microsecond - elif isinstance(raw_ts, str): - parsed_dt = datetime.fromisoformat(raw_ts) - assert parsed_dt.microsecond == test_dt.microsecond + try: + with config.provide_session() as driver: + driver.execute_script( + "CREATE TABLE test_coercion (id INTEGER PRIMARY KEY, ts TIMESTAMPTZ, payload JSON, uid VARCHAR);" + ) + driver.execute( + "INSERT INTO test_coercion (id, ts, payload, uid) VALUES (?, ?, ?, ?)", [1, test_dt, test_json, test_uuid] + ) + row = driver.select_one("SELECT ts, payload, uid FROM test_coercion WHERE id = ?", [1]) + assert row is not None + + raw_ts, raw_payload, raw_uid = row["ts"], row["payload"], row["uid"] + assert isinstance(raw_payload, str) == (not caps.json_columns_decoded) + assert isinstance(raw_uid, str) == (caps.uuid_binding == "text") + if isinstance(raw_ts, datetime): + assert raw_ts.microsecond == test_dt.microsecond + elif isinstance(raw_ts, str): + parsed_dt = datetime.fromisoformat(raw_ts) + assert parsed_dt.microsecond == test_dt.microsecond + finally: + config.close_pool() diff --git a/tests/integration/adapters/sqlite/test_type_coercion_capabilities.py b/tests/integration/adapters/sqlite/test_type_coercion_capabilities.py index a131dc6e7..b4c4e5e4d 100644 --- a/tests/integration/adapters/sqlite/test_type_coercion_capabilities.py +++ b/tests/integration/adapters/sqlite/test_type_coercion_capabilities.py @@ -24,24 +24,27 @@ def test_sqlite_type_coercion_capabilities_observed() -> None: test_json = {"key": "value", "count": 42} test_uuid = uuid4() - with config.provide_session() as driver: - driver.execute_script( - "CREATE TABLE test_coercion (id INTEGER PRIMARY KEY, ts TIMESTAMP, payload JSON, uid TEXT);" - ) - driver.execute( - "INSERT INTO test_coercion (id, ts, payload, uid) VALUES (:id, :ts, :payload, :uid)", - {"id": 1, "ts": test_dt, "payload": test_json, "uid": test_uuid}, - ) - row = driver.select_one("SELECT ts, payload, uid FROM test_coercion WHERE id = :id", {"id": 1}) - assert row is not None + try: + with config.provide_session() as driver: + driver.execute_script( + "CREATE TABLE test_coercion (id INTEGER PRIMARY KEY, ts TIMESTAMP, payload JSON, uid TEXT);" + ) + driver.execute( + "INSERT INTO test_coercion (id, ts, payload, uid) VALUES (:id, :ts, :payload, :uid)", + {"id": 1, "ts": test_dt, "payload": test_json, "uid": test_uuid}, + ) + row = driver.select_one("SELECT ts, payload, uid FROM test_coercion WHERE id = :id", {"id": 1}) + assert row is not None - raw_ts, raw_payload, raw_uid = row["ts"], row["payload"], row["uid"] - assert isinstance(raw_payload, str) == (not caps.json_columns_decoded) - assert isinstance(raw_uid, str) == (caps.uuid_binding == "text") - if caps.datetime_binding == "iso_text": - assert isinstance(raw_ts, str) - parsed_dt = datetime.fromisoformat(raw_ts) - assert parsed_dt.microsecond == test_dt.microsecond + raw_ts, raw_payload, raw_uid = row["ts"], row["payload"], row["uid"] + assert isinstance(raw_payload, str) == (not caps.json_columns_decoded) + assert isinstance(raw_uid, str) == (caps.uuid_binding == "text") + if caps.datetime_binding == "iso_text": + assert isinstance(raw_ts, str) + parsed_dt = datetime.fromisoformat(raw_ts) + assert parsed_dt.microsecond == test_dt.microsecond + finally: + config.close_pool() async def test_aiosqlite_type_coercion_capabilities_observed() -> None: @@ -57,21 +60,24 @@ async def test_aiosqlite_type_coercion_capabilities_observed() -> None: test_json = {"key": "value", "count": 42} test_uuid = uuid4() - async with config.provide_session() as driver: - await driver.execute_script( - "CREATE TABLE test_coercion (id INTEGER PRIMARY KEY, ts TIMESTAMP, payload JSON, uid TEXT);" - ) - await driver.execute( - "INSERT INTO test_coercion (id, ts, payload, uid) VALUES (:id, :ts, :payload, :uid)", - {"id": 1, "ts": test_dt, "payload": test_json, "uid": test_uuid}, - ) - row = await driver.select_one("SELECT ts, payload, uid FROM test_coercion WHERE id = :id", {"id": 1}) - assert row is not None + try: + async with config.provide_session() as driver: + await driver.execute_script( + "CREATE TABLE test_coercion (id INTEGER PRIMARY KEY, ts TIMESTAMP, payload JSON, uid TEXT);" + ) + await driver.execute( + "INSERT INTO test_coercion (id, ts, payload, uid) VALUES (:id, :ts, :payload, :uid)", + {"id": 1, "ts": test_dt, "payload": test_json, "uid": test_uuid}, + ) + row = await driver.select_one("SELECT ts, payload, uid FROM test_coercion WHERE id = :id", {"id": 1}) + assert row is not None - raw_ts, raw_payload, raw_uid = row["ts"], row["payload"], row["uid"] - assert isinstance(raw_payload, str) == (not caps.json_columns_decoded) - assert isinstance(raw_uid, str) == (caps.uuid_binding == "text") - if caps.datetime_binding == "iso_text": - assert isinstance(raw_ts, str) - parsed_dt = datetime.fromisoformat(raw_ts) - assert parsed_dt.microsecond == test_dt.microsecond + raw_ts, raw_payload, raw_uid = row["ts"], row["payload"], row["uid"] + assert isinstance(raw_payload, str) == (not caps.json_columns_decoded) + assert isinstance(raw_uid, str) == (caps.uuid_binding == "text") + if caps.datetime_binding == "iso_text": + assert isinstance(raw_ts, str) + parsed_dt = datetime.fromisoformat(raw_ts) + assert parsed_dt.microsecond == test_dt.microsecond + finally: + await config.close_pool() diff --git a/tests/unit/core/test_capabilities.py b/tests/unit/core/test_capabilities.py index 34d86c2dd..c270d8b79 100644 --- a/tests/unit/core/test_capabilities.py +++ b/tests/unit/core/test_capabilities.py @@ -5,7 +5,7 @@ import pytest -from sqlspec.config import DatabaseConfigBase, DatabaseConfigProtocol +from sqlspec.config import DatabaseConfigProtocol from sqlspec.core.capabilities import TypeCoercionCapabilities @@ -92,4 +92,20 @@ def test_database_config_default_capabilities() -> None: assert default_cap.timestamp_precision == "microsecond" assert default_cap.json_columns_decoded is False assert default_cap.uuid_binding == "native" - assert DatabaseConfigBase.type_coercion_capabilities == default_cap + + +def test_arrow_odbc_datetime_binding_preserves_microseconds() -> None: + """The declared policy matches the final ODBC parameter boundary.""" + from datetime import datetime, timezone + + from sqlspec.adapters.arrow_odbc.config import ArrowOdbcConfig + from sqlspec.adapters.arrow_odbc.driver import _odbc_parameters + + value = datetime(2024, 1, 15, 12, 30, 45, 123456, tzinfo=timezone.utc) + capabilities = ArrowOdbcConfig.type_coercion_capabilities + assert capabilities.datetime_binding == "iso_text" + assert capabilities.timestamp_precision == "microsecond" + parameters = _odbc_parameters([value]) + assert parameters is not None + assert isinstance(parameters[0], str) + assert datetime.fromisoformat(parameters[0]) == value From 10c6a63547456f79a328db048ed3d720f938e03d Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:46:01 +0000 Subject: [PATCH 4/7] fix: declare native JSON decoding for psqlpy and BigQuery --- docs/reference/adapters/index.rst | 4 ++-- sqlspec/adapters/bigquery/config.py | 2 +- sqlspec/adapters/psqlpy/config.py | 2 +- .../postgres/test_type_coercion_capabilities.py | 13 +++++++++++++ tests/unit/core/test_capabilities.py | 16 ++++++++++++++++ 5 files changed, 33 insertions(+), 4 deletions(-) diff --git a/docs/reference/adapters/index.rst b/docs/reference/adapters/index.rst index 05334dda5..7ca3628fd 100644 --- a/docs/reference/adapters/index.rst +++ b/docs/reference/adapters/index.rst @@ -180,7 +180,7 @@ means the parameter pipeline preserves the Python value for the driver. - Yes - Native - Microsecond - - + - Yes - Native * - sqlite - Yes @@ -279,7 +279,7 @@ means the parameter pipeline preserves the Python value for the driver. - Yes - Native - Microsecond - - + - Yes - Text * - spanner - Yes diff --git a/sqlspec/adapters/bigquery/config.py b/sqlspec/adapters/bigquery/config.py index ce10d5a74..ff85b522d 100644 --- a/sqlspec/adapters/bigquery/config.py +++ b/sqlspec/adapters/bigquery/config.py @@ -180,7 +180,7 @@ class BigQueryConfig(NoPoolSyncConfig[BigQueryConnection, BigQueryDriver]): supports_native_row_streaming: ClassVar[bool] = True supports_native_parquet_export: ClassVar[bool] = True type_coercion_capabilities: ClassVar[TypeCoercionCapabilities] = TypeCoercionCapabilities( - datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="text" + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=True, uuid_binding="text" ) _connection_context_class: "ClassVar[type[BigQueryConnectionContext]]" = BigQueryConnectionContext _session_factory_class: "ClassVar[type[_BigQuerySessionConnectionHandler]]" = _BigQuerySessionConnectionHandler diff --git a/sqlspec/adapters/psqlpy/config.py b/sqlspec/adapters/psqlpy/config.py index 1dc34191b..30966be60 100644 --- a/sqlspec/adapters/psqlpy/config.py +++ b/sqlspec/adapters/psqlpy/config.py @@ -200,7 +200,7 @@ class PsqlpyConfig(AsyncDatabaseConfig[PsqlpyConnection, "ConnectionPool", Psqlp supports_native_parquet_import: ClassVar[bool] = True supports_native_row_streaming: ClassVar[bool] = True type_coercion_capabilities: "ClassVar[TypeCoercionCapabilities]" = TypeCoercionCapabilities( - datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=False, uuid_binding="native" + datetime_binding="native", timestamp_precision="microsecond", json_columns_decoded=True, uuid_binding="native" ) _connection_context_class: "ClassVar[type[PsqlpyConnectionContext]]" = PsqlpyConnectionContext _session_factory_class: "ClassVar[type[_PsqlpySessionFactory]]" = _PsqlpySessionFactory diff --git a/tests/integration/adapters/postgres/test_type_coercion_capabilities.py b/tests/integration/adapters/postgres/test_type_coercion_capabilities.py index 94ffc7884..cb04e96f4 100644 --- a/tests/integration/adapters/postgres/test_type_coercion_capabilities.py +++ b/tests/integration/adapters/postgres/test_type_coercion_capabilities.py @@ -8,6 +8,7 @@ if TYPE_CHECKING: from sqlspec.adapters.asyncpg import AsyncpgConfig + from sqlspec.adapters.psqlpy import PsqlpyConfig from sqlspec.adapters.psycopg import PsycopgAsyncConfig, PsycopgSyncConfig pytestmark = pytest.mark.xdist_group("postgres") @@ -118,3 +119,15 @@ async def test_psycopg_async_type_coercion_capabilities_observed(psycopg_async_c assert raw_uid == test_uuid finally: await driver.execute_script(TEARDOWN_SQL) + + +async def test_psqlpy_json_columns_match_capabilities(psqlpy_config: "PsqlpyConfig") -> None: + """Native JSON and JSONB results are already decoded by psqlpy.""" + assert psqlpy_config.type_coercion_capabilities.json_columns_decoded is True + async with psqlpy_config.provide_session() as driver: + row = await driver.select_one( + "SELECT $1::json AS payload, $2::jsonb AS binary_payload", + ({"key": "value"}, {"count": 42}), + ) + assert row["payload"] == {"key": "value"} + assert row["binary_payload"] == {"count": 42} diff --git a/tests/unit/core/test_capabilities.py b/tests/unit/core/test_capabilities.py index c270d8b79..6239a9f94 100644 --- a/tests/unit/core/test_capabilities.py +++ b/tests/unit/core/test_capabilities.py @@ -109,3 +109,19 @@ def test_arrow_odbc_datetime_binding_preserves_microseconds() -> None: assert parameters is not None assert isinstance(parameters[0], str) assert datetime.fromisoformat(parameters[0]) == value + + +def test_bigquery_json_results_match_capabilities() -> None: + """The BigQuery SDK decodes JSON before SQLSpec collects result rows.""" + from google.cloud.bigquery import SchemaField + from google.cloud.bigquery._helpers import _row_tuple_from_json + + from sqlspec.adapters.bigquery import BigQueryConfig + from sqlspec.adapters.bigquery.core import collect_rows + + schema = [SchemaField("payload", "JSON")] + row = _row_tuple_from_json({"f": [{"v": '{"key": "value"}'}]}, schema) + rows, columns = collect_rows([row], schema) + assert BigQueryConfig.type_coercion_capabilities.json_columns_decoded is True + assert columns == ["payload"] + assert rows[0][0] == {"key": "value"} From 14685352f09932b139f96ad88cbb910d89c81e42 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 16:52:59 +0000 Subject: [PATCH 5/7] test: satisfy formatting and typing for coercion regressions --- .../adapters/duckdb/test_type_coercion_capabilities.py | 3 ++- .../adapters/postgres/test_type_coercion_capabilities.py | 3 +-- tests/unit/core/test_capabilities.py | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/integration/adapters/duckdb/test_type_coercion_capabilities.py b/tests/integration/adapters/duckdb/test_type_coercion_capabilities.py index fa8ca7b39..a283cbbdf 100644 --- a/tests/integration/adapters/duckdb/test_type_coercion_capabilities.py +++ b/tests/integration/adapters/duckdb/test_type_coercion_capabilities.py @@ -29,7 +29,8 @@ def test_duckdb_type_coercion_capabilities_observed() -> None: "CREATE TABLE test_coercion (id INTEGER PRIMARY KEY, ts TIMESTAMPTZ, payload JSON, uid VARCHAR);" ) driver.execute( - "INSERT INTO test_coercion (id, ts, payload, uid) VALUES (?, ?, ?, ?)", [1, test_dt, test_json, test_uuid] + "INSERT INTO test_coercion (id, ts, payload, uid) VALUES (?, ?, ?, ?)", + [1, test_dt, test_json, test_uuid], ) row = driver.select_one("SELECT ts, payload, uid FROM test_coercion WHERE id = ?", [1]) assert row is not None diff --git a/tests/integration/adapters/postgres/test_type_coercion_capabilities.py b/tests/integration/adapters/postgres/test_type_coercion_capabilities.py index cb04e96f4..7b4ac9435 100644 --- a/tests/integration/adapters/postgres/test_type_coercion_capabilities.py +++ b/tests/integration/adapters/postgres/test_type_coercion_capabilities.py @@ -126,8 +126,7 @@ async def test_psqlpy_json_columns_match_capabilities(psqlpy_config: "PsqlpyConf assert psqlpy_config.type_coercion_capabilities.json_columns_decoded is True async with psqlpy_config.provide_session() as driver: row = await driver.select_one( - "SELECT $1::json AS payload, $2::jsonb AS binary_payload", - ({"key": "value"}, {"count": 42}), + "SELECT $1::json AS payload, $2::jsonb AS binary_payload", ({"key": "value"}, {"count": 42}) ) assert row["payload"] == {"key": "value"} assert row["binary_payload"] == {"count": 42} diff --git a/tests/unit/core/test_capabilities.py b/tests/unit/core/test_capabilities.py index 6239a9f94..54374beb6 100644 --- a/tests/unit/core/test_capabilities.py +++ b/tests/unit/core/test_capabilities.py @@ -120,7 +120,7 @@ def test_bigquery_json_results_match_capabilities() -> None: from sqlspec.adapters.bigquery.core import collect_rows schema = [SchemaField("payload", "JSON")] - row = _row_tuple_from_json({"f": [{"v": '{"key": "value"}'}]}, schema) + row = _row_tuple_from_json({"f": [{"v": '{"key": "value"}'}]}, schema) # type: ignore[no-untyped-call] rows, columns = collect_rows([row], schema) assert BigQueryConfig.type_coercion_capabilities.json_columns_decoded is True assert columns == ["payload"] From 20ab8857a920864006e8afc0f6a92076bcfe4f09 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 18:16:06 +0000 Subject: [PATCH 6/7] test(postgres): unpack positional arguments in psqlpy coercion test --- .../adapters/postgres/test_type_coercion_capabilities.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/adapters/postgres/test_type_coercion_capabilities.py b/tests/integration/adapters/postgres/test_type_coercion_capabilities.py index 7b4ac9435..fb8bfd93f 100644 --- a/tests/integration/adapters/postgres/test_type_coercion_capabilities.py +++ b/tests/integration/adapters/postgres/test_type_coercion_capabilities.py @@ -126,7 +126,7 @@ async def test_psqlpy_json_columns_match_capabilities(psqlpy_config: "PsqlpyConf assert psqlpy_config.type_coercion_capabilities.json_columns_decoded is True async with psqlpy_config.provide_session() as driver: row = await driver.select_one( - "SELECT $1::json AS payload, $2::jsonb AS binary_payload", ({"key": "value"}, {"count": 42}) + "SELECT $1::json AS payload, $2::jsonb AS binary_payload", {"key": "value"}, {"count": 42} ) assert row["payload"] == {"key": "value"} assert row["binary_payload"] == {"count": 42} From 0fcf71a64c5c5f7f4cae2602b30737f708b3201e Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Sun, 13 Sep 2026 19:22:14 +0000 Subject: [PATCH 7/7] fix: preserve nested values during parameter validation --- docs/changelog.rst | 3 +++ sqlspec/core/parameters/_alignment.py | 10 +--------- sqlspec/core/parameters/_processor.py | 4 +--- sqlspec/core/parameters/_transformers.py | 5 ++--- .../test_type_coercion_capabilities.py | 14 ++++++++++++- tests/unit/core/test_compiler.py | 14 ++++++------- tests/unit/core/test_parameters.py | 20 +++++++++++++++++++ 7 files changed, 47 insertions(+), 23 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1d79f9d06..b12fef729 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -214,6 +214,9 @@ v0.63.0 - Transactions, table fixtures, SQL fragments, storage, and kwargs param **Fixed:** +* Preserve JSON objects and arrays as individual query parameters after placeholder conversion, + instead of reinterpreting them as batches during parameter validation. + * SQLite and aiosqlite adapters map primary key constraint violations (extended error code 1555 and ``SQLITE_CONSTRAINT_PRIMARYKEY``) to ``UniqueViolationError``. (`#775 `_) diff --git a/sqlspec/core/parameters/_alignment.py b/sqlspec/core/parameters/_alignment.py index a5cc006b6..280edbb21 100644 --- a/sqlspec/core/parameters/_alignment.py +++ b/sqlspec/core/parameters/_alignment.py @@ -117,9 +117,7 @@ def validate_parameter_alignment( if profile.total_count == 0: return - effective_is_many = is_many or looks_like_execute_many(parameters) - - if effective_is_many: + if is_many: if parameters is None: if profile.total_count == 0: return @@ -184,12 +182,6 @@ def _collect_actual_identifiers(parameters: Any) -> "tuple[set[tuple[str, int | if isinstance(parameters, Mapping): mapping_identifiers = {normalize_parameter_key(key) for key in parameters} return mapping_identifiers, len(parameters) - if looks_like_execute_many(parameters): - aggregated_identifiers: set[tuple[str, int | str]] = set() - for entry in parameters: - entry_identifiers, _ = _collect_actual_identifiers(entry) - aggregated_identifiers.update(entry_identifiers) - return aggregated_identifiers, len(aggregated_identifiers) if _is_sequence_like(parameters): identifiers = {("index", cast("int | str", index)) for index in range(len(parameters))} return identifiers, len(parameters) diff --git a/sqlspec/core/parameters/_processor.py b/sqlspec/core/parameters/_processor.py index 821ac429d..e8a6568df 100644 --- a/sqlspec/core/parameters/_processor.py +++ b/sqlspec/core/parameters/_processor.py @@ -6,7 +6,6 @@ from mypy_extensions import mypyc_attr -from sqlspec.core.parameters._alignment import looks_like_execute_many from sqlspec.core.parameters._converter import ParameterConverter from sqlspec.core.parameters._types import ( _NAMED_STYLE_VALUES, @@ -727,8 +726,7 @@ def _needs_mapping_normalization( if has_named_placeholders: return False - looks_many = is_many or looks_like_execute_many(payload) - if not looks_many: + if not is_many: return False # Fast type dispatch for common types diff --git a/sqlspec/core/parameters/_transformers.py b/sqlspec/core/parameters/_transformers.py index ffc4f6f4c..7b96cc24e 100644 --- a/sqlspec/core/parameters/_transformers.py +++ b/sqlspec/core/parameters/_transformers.py @@ -10,7 +10,6 @@ import sqlspec.exceptions from sqlspec.core.parameters._alignment import ( collect_null_parameter_ordinals, - looks_like_execute_many, normalize_parameter_key, validate_parameter_alignment, ) @@ -236,7 +235,7 @@ def replace_null_parameters_with_literals( return expression, _as_concrete_payload(parameters) return expression, None - if is_many or looks_like_execute_many(parameters): + if is_many: if isinstance(parameters, (dict, list, tuple)): return expression, _as_concrete_payload(parameters) return expression, None @@ -244,7 +243,7 @@ def replace_null_parameters_with_literals( if parameter_profile is None: msg = "replace_null_parameters_with_literals() requires parameter_profile for non-empty parameters" raise sqlspec.exceptions.SQLSpecError(msg) - validate_parameter_alignment(parameter_profile, parameters) + validate_parameter_alignment(parameter_profile, parameters, is_many=is_many) null_positions = collect_null_parameter_ordinals(parameters, parameter_profile) if not null_positions: diff --git a/tests/integration/adapters/postgres/test_type_coercion_capabilities.py b/tests/integration/adapters/postgres/test_type_coercion_capabilities.py index fb8bfd93f..fbd49bb3a 100644 --- a/tests/integration/adapters/postgres/test_type_coercion_capabilities.py +++ b/tests/integration/adapters/postgres/test_type_coercion_capabilities.py @@ -126,7 +126,19 @@ async def test_psqlpy_json_columns_match_capabilities(psqlpy_config: "PsqlpyConf assert psqlpy_config.type_coercion_capabilities.json_columns_decoded is True async with psqlpy_config.provide_session() as driver: row = await driver.select_one( - "SELECT $1::json AS payload, $2::jsonb AS binary_payload", {"key": "value"}, {"count": 42} + "SELECT :payload::json AS payload, :binary_payload::jsonb AS binary_payload", + {"payload": {"key": "value"}, "binary_payload": {"count": 42}}, ) assert row["payload"] == {"key": "value"} assert row["binary_payload"] == {"count": 42} + + +async def test_psqlpy_array_parameters_are_not_batches(psqlpy_config: "PsqlpyConfig") -> None: + """Array-valued named binds remain individual parameters after compilation.""" + async with psqlpy_config.provide_session() as driver: + row = await driver.select_one( + "SELECT :first::int[] AS first_values, :second::int[] AS second_values", + {"first": [1, 2, 3], "second": [4, 5]}, + ) + assert row["first_values"] == [1, 2, 3] + assert row["second_values"] == [4, 5] diff --git a/tests/unit/core/test_compiler.py b/tests/unit/core/test_compiler.py index a8b2a32fd..fd27d2879 100644 --- a/tests/unit/core/test_compiler.py +++ b/tests/unit/core/test_compiler.py @@ -1145,21 +1145,21 @@ def compile_sql(sql_id: int) -> None: @pytest.mark.parametrize( - "sql,parameters,expected_supports_many", + "sql,parameters,is_many,expected_supports_many", [ - ("SELECT * FROM users WHERE id = ?", [123], True), - ("INSERT INTO users (name) VALUES (?)", [["john"], ["jane"]], True), - ("UPDATE users SET name = ? WHERE id = ?", [("new", 1), ("other", 2)], True), - ("SELECT * FROM users", None, False), + ("SELECT * FROM users WHERE id = ?", [123], False, True), + ("INSERT INTO users (name) VALUES (?)", [["john"], ["jane"]], True, True), + ("UPDATE users SET name = ? WHERE id = ?", [("new", 1), ("other", 2)], True, True), + ("SELECT * FROM users", None, False, False), ], ) def test_execute_many_detection( - basic_statement_config: "StatementConfig", sql: str, parameters: Any, expected_supports_many: bool + basic_statement_config: "StatementConfig", sql: str, parameters: Any, is_many: bool, expected_supports_many: bool ) -> None: """Test detection of execute_many scenarios.""" processor = SQLProcessor(basic_statement_config) - result = processor.compile(sql, parameters) + result = processor.compile(sql, parameters, is_many=is_many) assert result.supports_many == expected_supports_many diff --git a/tests/unit/core/test_parameters.py b/tests/unit/core/test_parameters.py index 4052f5b3a..958f7b9c7 100644 --- a/tests/unit/core/test_parameters.py +++ b/tests/unit/core/test_parameters.py @@ -2782,3 +2782,23 @@ def test_parameter_pipeline_executes_on_sqlite_duckdb_and_reuses_warm_cache(proc duck_res = duck_conn.execute(res_duck.sql, res_duck.parameters).fetchone() assert duck_res == ("charlie", 30, "charlie") duck_conn.close() + + +@pytest.mark.parametrize("parameters", [({"key": "value"}, {"count": 42}), ([1, 2, 3], [4, 5])]) +def test_alignment_preserves_nested_single_statement_values(parameters: tuple[object, object]) -> None: + """JSON objects and arrays count as values, including within explicit batches.""" + profile = ParameterProfile(ParameterValidator().extract_parameters("SELECT $1, $2")) + _alignment_module.validate_parameter_alignment(profile, parameters, is_many=False) + _alignment_module.validate_parameter_alignment(profile, [parameters, parameters], is_many=True) + with pytest.raises(SQLSpecError, match="Parameter count mismatch"): + _alignment_module.validate_parameter_alignment(profile, [parameters, parameters[:1]], is_many=True) + + +def test_process_positional_json_values_preserves_objects(processor: ParameterProcessor) -> None: + """A positional JSON object must not be normalized as an execute_many row.""" + config = ParameterStyleConfig( + default_parameter_style=ParameterStyle.NUMERIC, default_execution_parameter_style=ParameterStyle.NUMERIC + ) + parameters = ({"key": "value"}, {"count": 42}) + result = processor.process("SELECT $1, $2", parameters, config, is_many=False) + assert result.parameters == parameters