From 5dd0306377dfdc822099b6740919049484baf8e5 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 13 Oct 2026 19:48:48 +0000 Subject: [PATCH 1/7] feat(adbc): add Oracle, Db2, SQL Server, GizmoSQL, and Quack driver support --- docs/extensions/adk/adapters.rst | 7 +- docs/extensions/adk/backends.rst | 35 +- docs/recipes/sql_server.rst | 6 +- docs/reference/adapters/adbc.rst | 630 ++++++++++-------- docs/reference/adapters/db2.rst | 5 + docs/reference/extensions/events.rst | 32 +- docs/usage/drivers_and_querying.rst | 13 +- .../frameworks/litestar/session_stores.rst | 15 +- sqlspec/adapters/adbc/adk/store.py | 559 ++++++++++++++-- sqlspec/adapters/adbc/config.py | 19 +- sqlspec/adapters/adbc/core.py | 557 +++++++++++++--- sqlspec/adapters/adbc/data_dictionary.py | 13 +- sqlspec/adapters/adbc/driver.py | 55 +- sqlspec/adapters/adbc/events/store.py | 103 ++- sqlspec/adapters/adbc/litestar/store.py | 123 +++- .../adapters/_shared/_driver_type_system.py | 1 + .../test_adbc_config_normalization.py | 132 ++++ .../test_adbc/test_adk_dialect_support.py | 189 +++++- tests/unit/adapters/test_adbc/test_core.py | 202 ++++++ .../test_adbc/test_data_dictionary.py | 13 + .../test_adbc/test_extension_stores.py | 102 +++ .../adapters/test_adbc/test_savepoints.py | 45 ++ 22 files changed, 2367 insertions(+), 489 deletions(-) create mode 100644 tests/unit/adapters/test_adbc/test_extension_stores.py diff --git a/docs/extensions/adk/adapters.rst b/docs/extensions/adk/adapters.rst index 87ea9f6fa..4aa9e6314 100644 --- a/docs/extensions/adk/adapters.rst +++ b/docs/extensions/adk/adapters.rst @@ -15,13 +15,14 @@ Use async adapters for best performance with ADK runners: - **MySQL/MariaDB**: ``aiomysql``, ``asyncmy`` - **SQLite**: ``aiosqlite`` (development and single-process) - **Oracle**: ``oracledb`` +- **IBM Db2**: ``db2`` (native ``ibm_db`` sync and async modes) - **DuckDB**: ``duckdb`` (analytics; reduced-scope for ADK) -- **ADBC**: ``adbc`` (Arrow-native portability; reduced-scope for ADK) +- **ADBC**: ``adbc`` (Arrow-native portability across PostgreSQL, SQLite, DuckDB, BigQuery, Snowflake, Oracle, IBM Db2, and SQL Server) - **Spanner**: ``spanner`` (Google Cloud, globally distributed; supports both async and sync modes) - **SQL Server**: ``mssql_python`` (native Arrow and BulkCopy), ``pymssql`` (FreeTDS), ``arrow_odbc`` (Arrow over Microsoft ODBC Driver 18) -Sync adapters (``psycopg`` sync mode, ``spanner`` sync mode, ``sqlite``, ``mysqlconnector``, -``pymysql``, ``mssql_python``, ``pymssql``, ``arrow_odbc``) work but require wrapping with ``anyio`` for async +Sync adapters (``psycopg`` sync mode, ``spanner`` sync mode, ``db2`` sync mode, ``sqlite``, ``mysqlconnector``, +``pymysql``, ``mssql_python``, ``pymssql``, ``arrow_odbc``, ``adbc``) work with ADK and use ``anyio`` worker-thread dispatch for async ADK runners. Each Adapter Provides diff --git a/docs/extensions/adk/backends.rst b/docs/extensions/adk/backends.rst index 70e84bec6..0194b176c 100644 --- a/docs/extensions/adk/backends.rst +++ b/docs/extensions/adk/backends.rst @@ -83,6 +83,11 @@ The table below classifies every backend by its ADK support level. - Full - Full - Oracle Database driver. + * - db2 + - Supported + - Full + - Basic + - IBM Db2 (LUW); ``POSSTR`` memory search. * - duckdb - Reduced-scope - Full @@ -92,7 +97,7 @@ The table below classifies every backend by its ADK support level. - Reduced-scope - Full - Basic - - Portability layer; native adapters provide optimized search. + - Arrow Database Connectivity across PostgreSQL, SQLite, DuckDB, BigQuery, Snowflake, Oracle, IBM Db2, and SQL Server. * - mssql_python - Supported - Full @@ -172,9 +177,8 @@ PostgreSQL backends (asyncpg, psycopg, psqlpy) provide the fullest feature set: - Full-text search via ``tsvector`` for memory entries. - ``UPSERT`` and ``RETURNING`` clauses for atomic operations. - ``append_event_and_update_state()`` executes as a single transaction. -- ``fillfactor``, ``autovacuum_vacuum_scale_factor``, and - ``autovacuum_analyze_scale_factor`` tune the generated tables. The existing - ``fillfactor=80`` default is preserved. +- ``fillfactor`` (default ``80``), ``autovacuum_vacuum_scale_factor``, and + ``autovacuum_analyze_scale_factor`` tune the generated tables. **Recommended for production deployments.** @@ -290,13 +294,19 @@ DuckDB provides session and event storage but has limitations: ADBC ---- -ADBC (Arrow Database Connectivity) provides a driver-agnostic interface: +ADBC (Arrow Database Connectivity) provides a portable Arrow-native store +(:class:`~sqlspec.adapters.adbc.adk.AdbcADKStore` and +:class:`~sqlspec.adapters.adbc.adk.AdbcADKMemoryStore`): -- Works with any ADBC-compatible driver (PostgreSQL, SQLite, DuckDB, etc.). +- Dialect-aware DDL, parameter binding, and pagination for PostgreSQL, SQLite, + DuckDB, BigQuery, Snowflake, Oracle (``:1, :2`` placeholders and PL/SQL + idempotent DDL), IBM Db2 (``SYSCAT`` catalog guards and ``POSSTR`` memory + search), and SQL Server (``OBJECT_ID`` / ``sys.indexes`` guards and + ``DATETIME2`` / ``NVARCHAR(MAX)`` storage). - Arrow-native data transfer for high-throughput event ingestion. -- Backend capabilities depend on the underlying database driver. -- Memory search uses the portable baseline path; choose a native adapter for - backend-specific FTS, retention, and storage tuning. +- Portable substring memory search across all supported ADBC dialects; choose a + dedicated native adapter when you need backend-specific full-text search + indexes or retention policies. SQL Server (mssql-python and pymssql) ------------------------------------- @@ -304,11 +314,8 @@ SQL Server (mssql-python and pymssql) ``mssql_python`` and ``pymssql`` provide SQL Server-backed ADK storage: - Session and event storage use SQL Server tables with ``DATETIME2(6)`` and - ``NVARCHAR(MAX)`` JSON payload columns. Microsoft's - `type mapping reference `_ - lists native ``JSON`` as unsupported, even when the server supports the type. - The existing ``native_json=True`` override remains available for deployments - that have independently verified driver support. + ``NVARCHAR(MAX)`` JSON payload columns. Pass ``native_json=True`` when your + SQL Server deployment and driver support native ``JSON`` columns. - ``append_event_and_update_state()`` commits the session update, event row, and scoped state in one transaction. - Memory search is ``LIKE``-based across memory entries. diff --git a/docs/recipes/sql_server.rst b/docs/recipes/sql_server.rst index 273008386..9d16a208f 100644 --- a/docs/recipes/sql_server.rst +++ b/docs/recipes/sql_server.rst @@ -12,7 +12,7 @@ It covers driver selection, connection management, transactional workflows, and Choosing a Driver ================= -SQLSpec offers three drivers for Microsoft SQL Server depending on workload requirements and platform dependencies: +SQLSpec offers four drivers for Microsoft SQL Server depending on workload requirements and platform dependencies: .. list-table:: :header-rows: 1 @@ -30,6 +30,10 @@ SQLSpec offers three drivers for Microsoft SQL Server depending on workload requ - qmark (``?``) / pyformat (``%(name)s``) - ``execute_many`` only - FreeTDS-based driver. + * - ``adbc`` (``driver="mssql"``) + - qmark (``?``) / ``:name`` / ``@name`` + - Native ``adbc_ingest`` Arrow tables + - Native Go TDS ADBC driver via ADBC Driver Foundry (``dbc install mssql``); zero-ODBC Arrow transport. * - ``arrow-odbc`` - qmark (``?``) - Arrow-first ODBC streaming diff --git a/docs/reference/adapters/adbc.rst b/docs/reference/adapters/adbc.rst index 3552c1011..a9473915c 100644 --- a/docs/reference/adapters/adbc.rst +++ b/docs/reference/adapters/adbc.rst @@ -2,347 +2,425 @@ ADBC ==== -Arrow Database Connectivity adapter providing native Arrow result handling. -Arrow-return APIs preserve native schemas, while row-return APIs materialize -Python values. SQLSpec can load the ADBC drivers for PostgreSQL, SQLite, DuckDB, -BigQuery, Snowflake, Flight SQL, and GizmoSQL from one ``AdbcConfig`` surface. +The Arrow Database Connectivity (ADBC) adapter connects SQLSpec to 11 relational, +analytical, cloud, and Arrow Flight SQL backends through a single +:class:`~sqlspec.adapters.adbc.AdbcConfig` interface. -Connection Configuration -======================== +- **Arrow-first data path**: :meth:`~sqlspec.adapters.adbc.AdbcDriver.select_to_arrow` + and :meth:`~sqlspec.adapters.adbc.AdbcDriver.load_from_arrow` transfer Arrow + ``Table`` and ``RecordBatchReader`` payloads without intermediate Python tuple + allocation. +- **Standard row & model APIs**: :meth:`~sqlspec.adapters.adbc.AdbcDriver.select`, + :meth:`~sqlspec.adapters.adbc.AdbcDriver.select_one`, and + :meth:`~sqlspec.adapters.adbc.AdbcDriver.select_stream` materialize dictionaries or + typed models (`msgspec`, Pydantic, dataclasses) from Arrow batches. +- **Automatic dialect & parameter routing**: SQLSpec detects the connected engine, + configures SQLGlot dialect compilation, and normalizes parameter placeholders, + exception classes, savepoint syntax, and column casing per backend. -ADBC configuration is driver-specific at the transport layer, so -``connection_config`` accepts both SQLSpec aliases and the keyword arguments -expected by the selected ADBC driver. SQLSpec normalizes common aliases and URI -schemes before calling the driver's ``dbapi.connect`` function. +Installation +============ + +Install SQLSpec with the ``adbc`` extra (which installs ``adbc-driver-manager`` and +``pyarrow``), then install the ADBC driver package for your target database: + +.. tab-set:: + + .. tab-item:: Enterprise (Oracle, Db2, SQL Server) + + .. code-block:: bash + + # Oracle (pure-Rust ADBC driver wheel from GizmoData) + uv add "sqlspec[adbc]" adbc-driver-oracle + + # IBM Db2 (requires IBM ODBC/CLI driver header/library at runtime) + uv add "sqlspec[adbc]" adbc-driver-db2 + + # Microsoft SQL Server via ADBC Driver Foundry (installs native Go TDS driver) + uv add "sqlspec[adbc]" + dbc install mssql + + .. tab-item:: Relational & Local (PostgreSQL, SQLite, DuckDB, Quack) + + .. code-block:: bash + + # PostgreSQL + uv add "sqlspec[adbc]" adbc-driver-postgresql + + # SQLite + uv add "sqlspec[adbc]" adbc-driver-sqlite + + # DuckDB (bundled driver or standalone pure-Rust Quack driver) + uv add "sqlspec[adbc]" adbc-driver-duckdb + uv add "sqlspec[adbc]" adbc-driver-quack + + .. tab-item:: Cloud & Flight SQL (BigQuery, Snowflake, GizmoSQL) + + .. code-block:: bash + + # Google BigQuery & Snowflake + uv add "sqlspec[adbc]" adbc-driver-bigquery adbc-driver-snowflake + + # GizmoSQL (native driver with OAuth/OIDC support, or Flight SQL fallback) + uv add "sqlspec[adbc]" adbc-driver-gizmosql + uv add "sqlspec[adbc]" adbc-driver-flightsql Supported Backends ------------------- +================== + +SQLSpec resolves the ADBC driver from either ``driver_name`` (or ``driver``) or the +connection ``uri`` scheme: .. list-table:: :header-rows: 1 - :widths: 20 24 28 28 + :widths: 15 20 18 22 25 * - Backend - ``driver_name`` aliases - - ADBC driver - - Notes + - URI schemes + - Driver package / binary + - Dialect & parameter behavior * - PostgreSQL - ``postgres``, ``postgresql``, ``pg`` + - ``postgresql://``, ``postgres://`` - ``adbc_driver_postgresql`` - - Numeric parameters; optional pgvector, pg_textsearch, and ParadeDB dialect detection. + - ``postgres`` dialect; numeric (``$1``) parameters; auto-detects ``pgvector``, ``pg_textsearch``, and ``paradedb``. + * - Oracle + - ``oracle``, ``oracledb`` + - ``oracle://`` + - ``adbc_driver_oracle`` + - ``oracle`` dialect; positional colon (``:1``) execution parameters; lowercases implicit uppercase column names. + * - IBM Db2 + - ``db2``, ``ibmdb2``, ``ibm_db`` + - ``db2://`` + - ``adbc_driver_db2`` + - ``db2`` dialect; positional ``?`` parameters; lowercases implicit uppercase column names. + * - SQL Server + - ``mssql``, ``sqlserver``, ``tsql`` + - ``mssql://``, ``sqlserver://`` + - ``adbc_driver_mssql`` or ``adbc_driver_manager`` (``driver="mssql"``) + - ``tsql`` compilation dialect; positional ``?`` execution parameters; ``SAVE TRANSACTION`` savepoints. * - SQLite - ``sqlite``, ``sqlite3`` + - ``sqlite://`` - ``adbc_driver_sqlite`` - - Qmark parameters; ``sqlite://`` URIs are normalized to file paths. + - ``sqlite`` dialect; positional ``?`` parameters; ``sqlite://`` prefixes are normalized to file paths. * - DuckDB - ``duckdb`` + - ``duckdb://`` - ``adbc_driver_duckdb`` - - Qmark and numeric parameters; ``duckdb://`` URIs are normalized to paths. + - ``duckdb`` dialect; ``?`` and ``$1`` parameters; ``COPY ... TO/FROM`` parameter inlining. + * - DuckDB (Quack) + - ``quack`` + - ``quack://``, ``quack+tls://`` + - ``adbc_driver_quack`` + - ``duckdb`` dialect; supports local files, ``:memory:``, and MotherDuck (`quack:///md:db` + ``token``). * - BigQuery - ``bigquery``, ``bq`` + - ``bigquery://`` - ``adbc_driver_bigquery`` - - Named ``@`` parameters; ``project_id``, ``dataset_id``, and ``token`` - are lifted into ``db_kwargs`` under the driver's own option names. - A driver given as a path to a shared library is loaded through the - ADBC driver manager, and ``entrypoint`` applies only on that route. + - ``bigquery`` dialect; named ``@param`` parameters; lifts ``project_id``, ``dataset_id``, and ``token`` into ``db_kwargs``. * - Snowflake - ``snowflake``, ``sf`` + - ``snowflake://`` - ``adbc_driver_snowflake`` - - Qmark and numeric parameters. + - ``snowflake`` dialect; ``?`` and ``$1`` parameters. * - Flight SQL - ``flightsql``, ``grpc`` + - ``grpc://``, ``grpc+tls://`` - ``adbc_driver_flightsql`` - - Generic Flight SQL connections default to SQLite-style statement handling. + - Arrow Flight SQL transport; defaults to ``sqlite`` dialect unless overridden. * - GizmoSQL - ``gizmosql``, ``gizmo`` - - ``adbc_driver_flightsql`` - - Flight SQL transport with DuckDB dialect by default, or SQLite when - ``gizmosql_backend="sqlite"``. - -Examples --------- - -PostgreSQL: - -.. code-block:: python - - from sqlspec.adapters.adbc import AdbcConfig - - postgres = AdbcConfig( - connection_config={ - "driver_name": "postgres", - "uri": "postgresql://user:password@localhost:5432/app", - } - ) - -SQLite: - -.. code-block:: python - - from sqlspec.adapters.adbc import AdbcConfig - - sqlite = AdbcConfig( - connection_config={ - "driver_name": "sqlite", - "uri": "sqlite:///tmp/app.db", - } - ) - -DuckDB: - -.. code-block:: python - - from sqlspec.adapters.adbc import AdbcConfig - - duckdb = AdbcConfig( - connection_config={ - "driver_name": "duckdb", - "path": "/tmp/app.duckdb", - } - ) - -BigQuery: - -.. code-block:: python - - from sqlspec.adapters.adbc import AdbcConfig - - bigquery = AdbcConfig( - connection_config={ - "driver_name": "bigquery", - "project_id": "analytics-project", - "dataset_id": "events", - } - ) - -Flight SQL: - -.. code-block:: python - - from sqlspec.adapters.adbc import AdbcConfig - - flightsql = AdbcConfig( - connection_config={ - "driver_name": "flightsql", - "uri": "grpc+tls://flightsql.example.com:31337", - } - ) - -GizmoSQL: - -.. code-block:: python - - from sqlspec.adapters.adbc import AdbcConfig - - gizmosql = AdbcConfig( - connection_config={ - "driver_name": "gizmosql", - "uri": "grpc+tls://localhost:31337", - "username": "admin", - "password": "secret", - "tls_skip_verify": True, - "gizmosql_backend": "duckdb", - } - ) - -GizmoSQL Notes --------------- + - ``gizmosql://``, ``gizmo://`` + - ``adbc_driver_gizmosql`` (falls back to ``adbc_driver_flightsql``) + - ``duckdb`` dialect by default (or ``sqlite`` via ``gizmosql_backend="sqlite"``); supports ``auth_type="external"`` SSO. + +.. tip:: + + You can also pass a filesystem path to any ADBC shared library (``.so``, + ``.dylib``, or ``.dll``) as ``driver_name``. SQLSpec automatically loads it + through ``adbc_driver_manager.dbapi.connect`` and forwards ``uri``, + ``username``, ``password``, and ``entrypoint`` into ``db_kwargs``. + +Connection Examples +=================== + +.. tab-set:: + + .. tab-item:: Enterprise (Oracle, Db2, SQL Server) + + .. code-block:: python + + from sqlspec.adapters.adbc import AdbcConfig + + # Oracle (EasyConnect URI; discrete username/password are lifted to db_kwargs) + oracle_config = AdbcConfig( + connection_config={ + "driver_name": "oracle", + "uri": "oracle://localhost:1521/FREEPDB1", + "username": "system", + "password": "secret", + } + ) + + # IBM Db2 (DSN string or db2:// URI) + db2_config = AdbcConfig( + connection_config={ + "driver_name": "db2", + "uri": "DATABASE=testdb;HOSTNAME=localhost;PORT=50000;PROTOCOL=TCPIP;UID=db2inst1;PWD=secret;", + } + ) + + # Microsoft SQL Server (loaded via ADBC driver manager or adbc_driver_mssql) + mssql_config = AdbcConfig( + connection_config={ + "driver_name": "mssql", + "uri": "sqlserver://sa:SecretPassword123!@localhost:1433?database=app_db&trustServerCertificate=true", + } + ) + + .. tab-item:: Relational & Local (PostgreSQL, SQLite, DuckDB, Quack) + + .. code-block:: python + + from sqlspec.adapters.adbc import AdbcConfig + + postgres_config = AdbcConfig( + connection_config={ + "uri": "postgresql://user:password@localhost:5432/app", + } + ) + + sqlite_config = AdbcConfig( + connection_config={ + "uri": "sqlite:///tmp/app.db", + } + ) + + duckdb_config = AdbcConfig( + connection_config={ + "driver_name": "duckdb", + "path": "/tmp/app.duckdb", + } + ) + + # DuckDB / MotherDuck via adbc-driver-quack + quack_config = AdbcConfig( + connection_config={ + "driver_name": "quack", + "uri": "quack:///md:analytics_db", + "token": "md_eyJ...", + } + ) + + .. tab-item:: Cloud Data Warehouses (BigQuery, Snowflake) + + .. code-block:: python + + from sqlspec.adapters.adbc import AdbcConfig + + bigquery_config = AdbcConfig( + connection_config={ + "driver_name": "bigquery", + "project_id": "analytics-project", + "dataset_id": "events", + } + ) + + snowflake_config = AdbcConfig( + connection_config={ + "driver_name": "snowflake", + "uri": "snowflake://user:password@account/db/schema?warehouse=compute_wh", + } + ) + + .. tab-item:: Flight SQL & GizmoSQL + + .. code-block:: python + + from sqlspec.adapters.adbc import AdbcConfig + + # GizmoSQL with username/password or browser-based OAuth/OIDC ("external") + gizmosql_config = AdbcConfig( + connection_config={ + "driver_name": "gizmosql", + "uri": "grpc+tls://localhost:31337", + "username": "admin", + "password": "secret", + "tls_skip_verify": True, + "query_timeout": 60, + } + ) + + gizmosql_oauth_config = AdbcConfig( + connection_config={ + "driver_name": "gizmosql", + "uri": "grpc+tls://gizmosql.example.com:31337", + "auth_type": "external", + "tls_root_certs": "/etc/certs/ca.pem", + } + ) + +Flight SQL & GizmoSQL Options +----------------------------- + +SQLSpec accepts high-level shortcut keys in ``connection_config`` for Flight SQL and +GizmoSQL connections and maps them into the underlying ADBC ``db_kwargs`` options: + +- ``username`` and ``password``: Basic authentication credentials. +- ``auth_type``: Set to ``"external"`` on GizmoSQL to trigger browser-based OAuth/OIDC + authentication (mapped to ``adbc.gizmosql.auth_type`` when using ``adbc_driver_flightsql`` + or passed directly to ``adbc_driver_gizmosql``). +- ``authorization_header``: Custom ``Authorization`` header value. +- ``tls_skip_verify``: Boolean flag to disable TLS certificate verification for local development. +- ``tls_root_certs``, ``mtls_cert_chain``, and ``mtls_private_key``: PEM certificate paths for TLS and mutual TLS. +- ``grpc_max_message_size``: Maximum gRPC message size in bytes. +- ``query_timeout`` and ``fetch_timeout``: RPC timeout values in seconds. +- ``with_cookie_middleware``: Enable Flight SQL cookie middleware (boolean). +- ``headers``: Dictionary of custom RPC call headers (mapped to ``adbc.flight.sql.rpc.call_header.``). +- ``gizmosql_backend``: Set to ``"sqlite"`` when targeting a SQLite-backed GizmoSQL server (defaults to ``"duckdb"``). + +Transactions, Savepoints, and Dialect Behavior +============================================== + +Savepoints and Nested Transactions +---------------------------------- + +ADBC connections manage transactions through ``session.begin()``, ``session.commit()``, +``session.rollback()``, and ``session.transaction()``. Nested ``session.transaction()`` +blocks use SQL savepoints with dialect-specific syntax: -GizmoSQL runs over the Flight SQL ADBC driver. SQLSpec maps the common -``username``, ``password``, ``tls_skip_verify``, and ``authorization_header`` -shortcuts into Flight SQL ``db_kwargs`` before opening the connection. For -lower-level Flight SQL options, pass ``db_kwargs`` directly: - -.. code-block:: python - - from sqlspec.adapters.adbc import AdbcConfig - - secure_gizmosql = AdbcConfig( - connection_config={ - "driver_name": "gizmosql", - "uri": "grpc+tls://gizmosql.example.com:31337", - "db_kwargs": { - "username": "admin", - "password": "secret", - "adbc.flight.sql.client_option.tls_root_certs": "/etc/certs/ca.pem", - "adbc.flight.sql.client_option.mtls_cert_chain": "/etc/certs/client.pem", - "adbc.flight.sql.client_option.mtls_private_key": "/etc/certs/client.key", - }, - } - ) - -Use ``gizmosql_backend="sqlite"`` only when the target GizmoSQL server was -started with SQLite as its database backend. DuckDB remains the default dialect -for GizmoSQL. - -PostgreSQL UUID Results -======================= - -PostgreSQL-family ADBC drivers expose ``UUID`` columns as Arrow opaque binary -extension values. SQLSpec decodes those values to :class:`uuid.UUID` on row -APIs, including scalar UUIDs, ``UUID[]`` elements, and nulls. The behavior is -consistent for buffered ``select``/``select_one`` calls and -``select_stream``. - -Native Arrow APIs such as ``select_to_arrow`` preserve the opaque extension -schema and its byte-oriented Arrow semantics. SQLite and DuckDB results are not -rewritten because their ADBC schemas do not use PostgreSQL's opaque UUID -extension. - -Set ``enable_arrow_extension_types=False`` in ``driver_features`` to keep raw -storage bytes on row APIs: +.. list-table:: + :header-rows: 1 + :widths: 22 28 25 25 + + * - Backend Dialect + - ``create_savepoint(name)`` + - ``rollback_to_savepoint(name)`` + - ``release_savepoint(name)`` + * - PostgreSQL, SQLite, MySQL + - ``SAVEPOINT `` + - ``ROLLBACK TO SAVEPOINT `` + - ``RELEASE SAVEPOINT `` + * - Oracle + - ``SAVEPOINT `` + - ``ROLLBACK TO SAVEPOINT `` + - No-op (Oracle does not support ``RELEASE SAVEPOINT``) + * - IBM Db2 + - ``SAVEPOINT ON ROLLBACK RETAIN CURSORS`` + - ``ROLLBACK TO SAVEPOINT `` + - ``RELEASE SAVEPOINT `` + * - SQL Server (``mssql`` / ``tsql``) + - ``SAVE TRANSACTION `` + - ``ROLLBACK TRANSACTION `` + - No-op (T-SQL does not release individual savepoints) + * - DuckDB, BigQuery, Snowflake + - Raises ``NotImplementedError`` + - Raises ``NotImplementedError`` + - Raises ``NotImplementedError`` + +Oracle and Db2 Column Name Normalization +---------------------------------------- + +Oracle and IBM Db2 fold unquoted SQL identifiers to uppercase (`SELECT id, name FROM users` +returns columns named ``ID`` and ``NAME``). When the resolved dialect is ``oracle`` or +``db2``, SQLSpec defaults ``enable_lowercase_column_names=True`` in ``driver_features`` so +implicit-uppercase column names are normalized to lowercase (``row["id"]``, ``row["name"]``) +while preserving quoted mixed-case identifiers (such as ``"MixedCase"``). + +To preserve raw uppercase column names from Oracle or Db2, disable the feature in +``driver_features``: .. code-block:: python from sqlspec.adapters.adbc import AdbcConfig - postgres = AdbcConfig( - connection_config={ - "driver_name": "postgres", - "uri": "postgresql://user:password@localhost:5432/app", - }, - driver_features={"enable_arrow_extension_types": False}, + oracle_raw_columns = AdbcConfig( + connection_config={"uri": "oracle://system:secret@localhost:1521/FREEPDB1"}, + driver_features={"enable_lowercase_column_names": False}, ) -The compatibility alias ``arrow_extension_types`` controls the same feature. - -PostgreSQL Extension Dialects -============================= - -When targeting PostgreSQL, ADBC automatically detects installed extensions on the -first connection and upgrades the SQL dialect accordingly: - -- **pgvector** — If the ``vector`` extension is installed, switches to the ``pgvector`` - dialect which supports distance operators (``<->``, ``<=>``, ``<#>``, ``<+>``, ``<~>``, ``<%>``). -- **pg_textsearch** — If the ``pg_textsearch`` extension is installed, switches to the - ``pg_textsearch`` dialect which supports BM25 score ranking (``<@>``). -- **ParadeDB** — If the ``pg_search`` extension is installed, switches to the ``paradedb`` - dialect which adds BM25 search operators (``@@@``, ``&&&``, ``|||``, ``===``). - -Detection is controlled by driver feature flags: - -- ``enable_pgvector`` — Defaults to ``True`` when the ``pgvector`` Python package is installed. -- ``enable_pg_textsearch`` — Defaults to ``True``. -- ``enable_paradedb`` — Defaults to ``True``. - -Detection runs once per config instance and caches the result. Non-PostgreSQL backends -(SQLite, DuckDB, BigQuery, Snowflake, GizmoSQL) skip detection entirely. - -.. note:: - - ADBC returns vector data as strings (e.g. ``"[0.1,0.2,0.3]"``). - The ``pgvector`` Python package is not required for querying vector data. - It only affects the *default* value of ``enable_pgvector`` — when the package - is installed, detection is enabled automatically. You can always set - ``enable_pgvector=True`` explicitly in ``driver_features`` to enable - detection without the package installed. - -See the :doc:`Dialects <../dialects>` reference for full operator details. +PostgreSQL UUID and Extension Dialects +-------------------------------------- + +- **UUID Decoding & Binding**: PostgreSQL ADBC drivers expose ``UUID`` columns as Arrow + opaque binary extension types. SQLSpec automatically decodes scalar ``UUID`` and + ``UUID[]`` values to :class:`uuid.UUID` on row APIs (`select`, `select_one`, + `select_stream`) and adds explicit ``::uuid`` / ``::uuid[]`` casts when binding Python + :class:`uuid.UUID` parameters. Set ``driver_features={"enable_arrow_extension_types": False}`` + to keep raw bytes on row APIs. +- **Extension Dialect Auto-Upgrade**: On the first PostgreSQL connection, SQLSpec checks + ``pg_extension`` once and upgrades the dialect to ``pgvector`` (when ``vector`` is + installed), ``pg_textsearch`` (when ``pg_textsearch`` is installed), or ``paradedb`` + (when ``pg_search`` is installed). Control detection with ``enable_pgvector``, + ``enable_pg_textsearch``, and ``enable_paradedb`` in ``driver_features``. + +Schema Introspection and Statistics +=================================== + +``AdbcDataDictionary`` combines ADBC's native C-API metadata methods +(``adbc_get_objects``, ``adbc_get_table_schema``) with SQLSpec's central SQL catalog +queries as an automatic fallback: + +- When a driver returns complete table, column, and foreign-key payloads via + ``adbc_get_objects``, SQLSpec normalizes them directly without issuing catalog SQL. +- When native metadata is unsupported, incomplete, or omits index/DDL details, + ``AdbcDataDictionary`` falls back to the dialect's SQL catalog queries (`postgres`, + `oracle`, `db2`, `mssql`, `sqlite`, `duckdb`, `bigquery`). +- For Oracle, ``get_optimal_type(driver, "json")`` inspects the server version and + selects native ``JSON`` on Oracle 21c+, ``BLOB`` on Oracle 12c–19c, and ``CLOB`` on + older versions. +- ``get_statistics`` wraps ``adbc_get_statistics`` and maps standard ADBC statistic keys + (`0`–`6`) to canonical ``adbc.statistic.*`` names; drivers without statistics support + raise :exc:`~sqlspec.exceptions.OperationalError`. + +Framework and AI Extension Stores +================================= + +``AdbcConfig`` integrates with all SQLSpec synchronous extension stores across every +supported relational and analytical dialect (`postgres`, `oracle`, `db2`, `mssql`, +`sqlite`, `duckdb`, `bigquery`, `snowflake`): + +- **Litestar Sessions**: :class:`~sqlspec.adapters.adbc.litestar.store.ADBCStore` +- **Durable Event Queue**: :class:`~sqlspec.adapters.adbc.events.store.AdbcEventQueueStore` +- **Google ADK Sessions & Events**: :class:`~sqlspec.adapters.adbc.adk.store.AdbcADKStore` +- **Google ADK Memory**: :class:`~sqlspec.adapters.adbc.adk.store.AdbcADKMemoryStore` + +API Reference +============= Configuration -============= +------------- .. autoclass:: sqlspec.adapters.adbc.AdbcConfig :members: :show-inheritance: Connection Parameters -===================== +--------------------- .. autoclass:: sqlspec.adapters.adbc.AdbcConnectionParams :members: :show-inheritance: Driver Features -=============== +--------------- .. autoclass:: sqlspec.adapters.adbc.AdbcDriverFeatures :members: :show-inheritance: Driver -====== +------ .. autoclass:: sqlspec.adapters.adbc.AdbcDriver :members: :show-inheritance: Data Dictionary -=============== +--------------- .. autoclass:: sqlspec.adapters.adbc.data_dictionary.AdbcDataDictionary :members: :show-inheritance: - -Native Metadata And Statistics -============================== - -``AdbcDataDictionary`` keeps SQLSpec's central dialect data-dictionary queries -as the canonical fallback. It detects the database behind the ADBC connection -and uses the same dialect query registry as the native adapter when ADBC -metadata is unsupported, incomplete, or too broad. The standardized ADBC -metadata APIs (``adbc_get_objects``, ``adbc_get_table_schema``) are an optional -overlay when the driver returns complete table, column, and foreign-key -payloads that can be normalized to SQLSpec's public metadata types. - -``get_statistics`` is separate from the shared data dictionary surface because -SQLSpec does not define a portable SQL statistics contract. It wraps -``adbc_get_statistics`` directly; unsupported drivers raise -:exc:`sqlspec.exceptions.OperationalError`. - -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. - -.. list-table:: ADBC native metadata support (driver manager 1.11.0) - :header-rows: 1 - - * - Backend - - Metadata behavior - - Statistics behavior - * - PostgreSQL - - Native overlay when available; central PostgreSQL SQL fallback - - Native (approximate; run ``ANALYZE`` for fresh estimates) - * - SQLite - - Native overlay when available; central SQLite SQL fallback - (type names populated; nullability unreliable) - - Unsupported (raises ``OperationalError``) - * - DuckDB - - Native overlay for single tables when schema enrichment succeeds; - schema-wide column listings use central DuckDB SQL - - Unsupported (raises ``OperationalError``) - * - Flight SQL / GizmoSQL - - Native overlay is server dependent; central dialect fallback applies - when the backend dialect is mapped. DuckDB-backed GizmoSQL exposes - catalogs, schemas, tables, columns, and constraints through - ``GetObjects`` when the server returns a complete payload; incomplete - constraint or nullability metadata falls back to SQLSpec's central - dialect SQL. - - Server dependent - * - BigQuery - - Central BigQuery SQL fallback - - Unverified - -Precision limits: - -- ADBC native name filters are SQL ``LIKE`` patterns; SQLSpec post-filters - native results by exact table name, but schema filters containing ``_`` or - ``%`` may match more broadly on the server side before fallback. -- The SQLite driver reports ``xdbc_is_nullable`` as ``YES`` even for - ``NOT NULL`` columns. -- Index metadata always uses SQL introspection; ADBC GetObjects has no - portable index representation. -- ``get_statistics`` maps the standard ADBC statistic keys 0-6 to their - canonical names (``adbc.statistic.row_count`` and friends); driver-specific - keys are reported numerically. - -FlightSQL connection options ----------------------------- - -FlightSQL connection configuration accepts ``grpc_max_message_size``, -``query_timeout``, ``fetch_timeout``, ``tls_root_certs``, ``mtls_cert_chain``, -``mtls_private_key``, ``with_cookie_middleware``, and ``headers``. These map -to native ADBC database options; explicit ``db_kwargs`` entries take precedence. -Timeouts are in seconds. Session options remain native connection options and -can be supplied through ``conn_kwargs``. diff --git a/docs/reference/adapters/db2.rst b/docs/reference/adapters/db2.rst index 8cee2c129..a35b05450 100644 --- a/docs/reference/adapters/db2.rst +++ b/docs/reference/adapters/db2.rst @@ -32,6 +32,11 @@ newer. On other platforms ``pip`` builds ``ibm_db`` from source: set ``CLIDRIVER_VERSION=v11.5.9`` to download a compatible CLI driver, or point ``IBM_DB_HOME`` at an existing Db2 client installation. +.. tip:: + + For Arrow-first analytical workloads on IBM Db2, you can also connect through + :doc:`ADBC ` using ``adbc-driver-db2`` (``AdbcConfig(connection_config={"driver_name": "db2", "uri": ...})``). + Quick Start =========== diff --git a/docs/reference/extensions/events.rst b/docs/reference/extensions/events.rst index c19845921..a4cec1857 100644 --- a/docs/reference/extensions/events.rst +++ b/docs/reference/extensions/events.rst @@ -334,16 +334,23 @@ Listeners Event Queue =========== -The durable table queue is available for SQL Server through ``mssql_python``, -``pymssql``, and ``arrow_odbc`` (the last configured with Microsoft ODBC Driver 18). -All three use SQL Server ``DATETIME2(6)`` timestamps and ``NVARCHAR`` payload -columns, and every SQL Server backend is ``poll_queue``. +The durable ``poll_queue`` table transport is supported across all SQLSpec +relational and analytical adapters, including: + +* **ADBC** (:class:`~sqlspec.adapters.adbc.events.AdbcEventQueueStore`) with + dialect-aware DDL and index guards for PostgreSQL, SQLite, DuckDB, BigQuery, + Snowflake, Oracle, IBM Db2, and SQL Server. +* **SQL Server** through ``mssql_python``, ``pymssql``, ``arrow_odbc``, and + ``adbc`` (using ``DATETIME2`` timestamps and ``NVARCHAR(MAX)`` JSON payload + columns). +* **IBM Db2** through ``db2``, ``arrow_odbc``, and ``adbc`` (using ``CLOB(1M)`` + payloads and ``SYSCAT`` catalog guards). Durable queue migrations reconcile missing tables and additive columns from the adapter store's canonical DDL. Set ``events.manage_schema=False`` when an external migration system owns the queue schema. Set ``events.create_schema=False`` to avoid creating an absent queue table. Column -renames, drops, and type changes still require an explicit migration. +renames, drops, and type changes require an explicit versioned migration. Queue table storage options --------------------------- @@ -354,10 +361,10 @@ that the backend cannot honor raises ``ImproperConfigurationError``. * PostgreSQL (``asyncpg``, ``psycopg``, and ``psqlpy``) accepts ``fillfactor``, ``autovacuum_vacuum_scale_factor``, and - ``autovacuum_analyze_scale_factor``. These queue-table settings are opt-in. + ``autovacuum_analyze_scale_factor``. * BigQuery accepts ``partitioning``, ``partition_expiration_days``, and - ``require_partition_filter`` for ``available_at`` partitioning. Existing - channel and status clustering is preserved. + ``require_partition_filter`` for ``available_at`` partitioning alongside + channel and status clustering. * SQLite and AioSQLite accept ``pragma_profile`` and ``pragma_overrides``. PRAGMAs run once during schema preparation rather than on every queue operation. @@ -373,11 +380,10 @@ that the backend cannot honor raises ``ImproperConfigurationError``. lock-free read-only snapshots (``transaction=False``) and execute DML writes inside ``run_in_transaction`` for automatic ``Aborted`` conflict retries. -CockroachDB deliberately does not expose its session-table row TTL for durable -queues. Queue acknowledgement and retention have different semantics, so -session-only TTL keys are rejected rather than translated to destructive queue -DDL. Other adapters use their existing queue-table defaults and reject these -backend-specific storage keys. +CockroachDB does not expose its session-table row TTL for durable queues +because queue acknowledgement and retention have distinct semantics. Other +adapters use their standard queue-table DDL and reject backend-specific storage +keys from unrelated database families. For example, configure a PostgreSQL queue for a write-heavy workload: diff --git a/docs/usage/drivers_and_querying.rst b/docs/usage/drivers_and_querying.rst index 8aa54b278..8594c1b12 100644 --- a/docs/usage/drivers_and_querying.rst +++ b/docs/usage/drivers_and_querying.rst @@ -11,8 +11,10 @@ Supported Drivers (High Level) - **CockroachDB**: cockroach-asyncpg, cockroach-psycopg - **SQLite**: sqlite3, aiosqlite, ADBC - **MySQL & MariaDB**: aiomysql, asyncmy, mysql-connector, pymysql -- **SQL Server**: mssql-python, pymssql, arrow-odbc -- **Analytics & Cloud**: DuckDB, BigQuery, Spanner, Oracle, ADBC, arrow-odbc +- **Oracle**: oracledb (sync/async), ADBC +- **IBM Db2**: ibm_db (sync/async), ADBC +- **SQL Server**: mssql-python, pymssql, arrow-odbc, ADBC +- **Analytics & Cloud**: DuckDB, BigQuery, Spanner, Snowflake (ADBC), GizmoSQL (ADBC), arrow-odbc Core Execution Pattern ---------------------- @@ -156,9 +158,10 @@ Adapter capability is discoverable via ``Config.supports_native_row_streaming``. Native paths: psycopg and CockroachDB-psycopg (server-side named cursors), asyncpg and CockroachDB-asyncpg (cursors inside a stream-owned transaction), pymysql/aiomysql/asyncmy (``SSCursor``), mysql-connector (unbuffered cursors), -sqlite/aiosqlite and oracledb (chunked ``fetchmany``), psqlpy (server-side -cursor with ``array_size``), and BigQuery (page-wise result iteration). -ADBC, DuckDB, mssql-python, and Spanner are eager-fallback only for row +sqlite/aiosqlite, ibm_db (Db2), and oracledb (chunked ``fetchmany``), psqlpy +(server-side cursor with ``array_size``), ADBC (Arrow ``RecordBatchReader`` +chunks via ``fetch_record_batch``), and BigQuery (page-wise result iteration). +DuckDB, mssql-python, pymssql, and Spanner are eager-fallback only for row streaming. ``arrow-odbc`` row streaming also materializes dict rows eagerly, but ``select_to_arrow(..., return_format="reader" | "batches")`` uses the native ``arrow_odbc`` ``RecordBatchReader`` path. diff --git a/docs/usage/frameworks/litestar/session_stores.rst b/docs/usage/frameworks/litestar/session_stores.rst index d99193201..542544fca 100644 --- a/docs/usage/frameworks/litestar/session_stores.rst +++ b/docs/usage/frameworks/litestar/session_stores.rst @@ -40,7 +40,8 @@ from :class:`~sqlspec.extensions.litestar.store.BaseSQLSpecStore`: - **BigQuery**: ``sqlspec.adapters.bigquery.litestar.BigQueryStore`` - **Spanner**: ``sqlspec.adapters.spanner.litestar.SpannerAsyncStore`` and ``sqlspec.adapters.spanner.litestar.SpannerSyncStore`` - **Oracle**: ``sqlspec.adapters.oracledb.litestar.OracleAsyncStore`` and ``sqlspec.adapters.oracledb.litestar.OracleSyncStore`` -- **Arrow / ADBC**: ``sqlspec.adapters.adbc.litestar.ADBCStore`` +- **IBM Db2**: ``sqlspec.adapters.db2.litestar.Db2AsyncStore`` and ``sqlspec.adapters.db2.litestar.Db2SyncStore`` +- **Arrow / ADBC**: ``sqlspec.adapters.adbc.litestar.ADBCStore`` (supports PostgreSQL, SQLite, DuckDB, MySQL/MariaDB, BigQuery, Snowflake, Oracle, IBM Db2, and SQL Server) Each store can create its session table. It can also add new columns from its own DDL. Set this behavior under ``extension_config["litestar"]``: @@ -79,28 +80,24 @@ For Oracle, use ``OracleLitestarCompressionConfig`` and The available options are: -* PostgreSQL (``asyncpg``, ``psycopg``, and ``psqlpy``): ``fillfactor``, +* PostgreSQL (``asyncpg``, ``psycopg``, and ``psqlpy``): ``fillfactor`` (default ``80``), ``autovacuum_vacuum_scale_factor``, and - ``autovacuum_analyze_scale_factor``. The established session-table - ``fillfactor=80`` default is retained. + ``autovacuum_analyze_scale_factor``. * CockroachDB: ``enable_hash_sharded_indexes``, ``hash_shard_bucket_count``, and ``ttl_expiration_expression="expires_at"``. * BigQuery: ``partitioning``, ``partition_expiration_days``, and ``require_partition_filter``. The partition column is ``expires_at``. -* SQLite and AioSQLite: opt-in ``pragma_profile`` and ``pragma_overrides``. +* SQLite and AioSQLite: ``pragma_profile`` and ``pragma_overrides``. PRAGMAs are applied during schema preparation, not for each session-store operation. -* MySQL and MariaDB: ``table_options`` and ``index_options`` for reviewed +* MySQL and MariaDB: ``table_options`` and ``index_options`` for backend-specific DDL clauses. * Spanner: ``shard_count``, ``table_options``, and ``index_options``. * Oracle Database: ``compression``, ``partitioning``, ``in_memory``, and ``table_options``. See :ref:`oracledb-extension-storage-options` for the capability and licensing behavior. -All newly supported options are opt-in except PostgreSQL's existing -``fillfactor`` default, so upgrading does not otherwise change session DDL. - For example, enable the SQLite profile and override only the values needed by the application: diff --git a/sqlspec/adapters/adbc/adk/store.py b/sqlspec/adapters/adbc/adk/store.py index 6703c6d0c..7ee4e4e81 100644 --- a/sqlspec/adapters/adbc/adk/store.py +++ b/sqlspec/adapters/adbc/adk/store.py @@ -16,6 +16,7 @@ from sqlspec.extensions.adk.memory.store import BaseSyncADKMemoryStore from sqlspec.utils.logging import get_logger from sqlspec.utils.serializers import from_json, to_json +from sqlspec.utils.text import normalize_identifier, split_qualified_identifier if TYPE_CHECKING: from collections.abc import Sequence @@ -33,6 +34,9 @@ DIALECT_DUCKDB: Final = "duckdb" DIALECT_BIGQUERY: Final = "bigquery" DIALECT_SNOWFLAKE: Final = "snowflake" +DIALECT_ORACLE: Final = "oracle" +DIALECT_DB2: Final = "db2" +DIALECT_MSSQL: Final = "mssql" DIALECT_GENERIC: Final = "generic" ADBC_TABLE_NOT_FOUND_PATTERNS: Final = ( @@ -41,6 +45,10 @@ "relation does not exist", "does not exist", "table with name", + "invalid object name", + "undefined name", + "ora-00942", + "sql0204n", ) @@ -215,6 +223,13 @@ def _detect_dialect(self) -> str: return DIALECT_GENERIC resolved = resolve_dialect_from_config(self._config.connection_config) + stmt_dialect = self._config.statement_config.dialect + if (isinstance(stmt_dialect, str) and stmt_dialect.lower() in {"oracle", "db2", "mssql", "tsql"}) or ( + resolved is None + and isinstance(stmt_dialect, str) + and stmt_dialect.lower() in {"sqlite", "duckdb", "bigquery", "snowflake"} + ): + resolved = stmt_dialect.lower() if is_postgres_dialect(resolved): return DIALECT_POSTGRESQL if resolved == "sqlite": @@ -225,6 +240,12 @@ def _detect_dialect(self) -> str: return DIALECT_BIGQUERY if resolved == "snowflake": return DIALECT_SNOWFLAKE + if resolved == "oracle": + return DIALECT_ORACLE + if resolved == "db2": + return DIALECT_DB2 + if resolved in {"mssql", "tsql"}: + return DIALECT_MSSQL logger.warning( "Unknown ADBC driver: %s. Using generic SQL dialect. " @@ -293,6 +314,12 @@ def _json_storage_type(self) -> str: return "JSON" if self._dialect == DIALECT_SNOWFLAKE: return "VARIANT" + if self._dialect == DIALECT_ORACLE: + return "CLOB" + if self._dialect == DIALECT_DB2: + return "CLOB(1M)" + if self._dialect == DIALECT_MSSQL: + return "NVARCHAR(MAX)" return "TEXT" def _timestamp_storage_type(self) -> str: @@ -302,6 +329,12 @@ def _timestamp_storage_type(self) -> str: return "TIMESTAMP_TZ" if self._dialect == DIALECT_SQLITE: return "REAL" + if self._dialect == DIALECT_ORACLE: + return "TIMESTAMP WITH TIME ZONE" + if self._dialect == DIALECT_DB2: + return "TIMESTAMP" + if self._dialect == DIALECT_MSSQL: + return "DATETIME2" return "TIMESTAMP" def _deserialize_json_field(self, data: Any) -> "dict[str, Any] | None": @@ -333,6 +366,12 @@ def _sessions_table_ddl(self) -> str: return self._sessions_ddl_bigquery() if self._dialect == DIALECT_SNOWFLAKE: return self._sessions_ddl_snowflake() + if self._dialect == DIALECT_ORACLE: + return self._sessions_ddl_oracle() + if self._dialect == DIALECT_DB2: + return self._sessions_ddl_db2() + if self._dialect == DIALECT_MSSQL: + return self._sessions_ddl_mssql() return self._sessions_ddl_generic() def _sessions_ddl_postgresql(self) -> str: @@ -425,6 +464,56 @@ def _sessions_ddl_snowflake(self) -> str: ) """ + def _sessions_ddl_oracle(self) -> str: + """Oracle DDL with CLOB and TIMESTAMP WITH TIME ZONE.""" + owner_id_ddl = f", {self._owner_id_column_ddl}" if self._owner_id_column_ddl else "" + return f""" + BEGIN + EXECUTE IMMEDIATE 'CREATE TABLE {self._session_table} ( + id VARCHAR2(128) PRIMARY KEY, + app_name VARCHAR2(128) NOT NULL, + user_id VARCHAR2(128) NOT NULL{owner_id_ddl}, + state CLOB NOT NULL, + create_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, + update_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL + )'; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; + END; + """ + + def _sessions_ddl_db2(self) -> str: + """Db2 DDL with CLOB(1M) and TIMESTAMP.""" + owner_id_ddl = f", {self._owner_id_column_ddl}" if self._owner_id_column_ddl else "" + return f""" + CREATE TABLE {self._session_table} ( + id VARCHAR(128) NOT NULL PRIMARY KEY, + app_name VARCHAR(128) NOT NULL, + user_id VARCHAR(128) NOT NULL{owner_id_ddl}, + state CLOB(1M) NOT NULL, + create_time TIMESTAMP NOT NULL WITH DEFAULT CURRENT TIMESTAMP, + update_time TIMESTAMP NOT NULL WITH DEFAULT CURRENT TIMESTAMP + ) + """ + + def _sessions_ddl_mssql(self) -> str: + """SQL Server DDL with NVARCHAR(MAX) and DATETIME2.""" + owner_id_ddl = f", {self._owner_id_column_ddl}" if self._owner_id_column_ddl else "" + return f""" + IF OBJECT_ID(N'{self._session_table}', N'U') IS NULL + CREATE TABLE {self._session_table} ( + id NVARCHAR(128) NOT NULL PRIMARY KEY, + app_name NVARCHAR(128) NOT NULL, + user_id NVARCHAR(128) NOT NULL{owner_id_ddl}, + state NVARCHAR(MAX) NOT NULL, + create_time DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + update_time DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME() + ) + """ + def _sessions_ddl_generic(self) -> str: """Generic SQL-92 compatible DDL fallback. @@ -459,6 +548,12 @@ def _events_table_ddl(self) -> str: return self._events_ddl_bigquery() if self._dialect == DIALECT_SNOWFLAKE: return self._events_ddl_snowflake() + if self._dialect == DIALECT_ORACLE: + return self._events_ddl_oracle() + if self._dialect == DIALECT_DB2: + return self._events_ddl_db2() + if self._dialect == DIALECT_MSSQL: + return self._events_ddl_mssql() return self._events_ddl_generic() def _events_ddl_postgresql(self) -> str: @@ -555,6 +650,59 @@ def _events_ddl_snowflake(self) -> str: ) """ + def _events_ddl_oracle(self) -> str: + """Oracle DDL for events table.""" + return f""" + BEGIN + EXECUTE IMMEDIATE 'CREATE TABLE {self._events_table} ( + id VARCHAR2(128) PRIMARY KEY, + app_name VARCHAR2(128) NOT NULL, + user_id VARCHAR2(128) NOT NULL, + session_id VARCHAR2(128) NOT NULL, + invocation_id VARCHAR2(256), + timestamp TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, + event_data CLOB NOT NULL, + FOREIGN KEY (session_id) REFERENCES {self._session_table}(id) ON DELETE CASCADE + )'; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; + END; + """ + + def _events_ddl_db2(self) -> str: + """Db2 DDL for events table.""" + return f""" + CREATE TABLE {self._events_table} ( + id VARCHAR(128) NOT NULL PRIMARY KEY, + app_name VARCHAR(128) NOT NULL, + user_id VARCHAR(128) NOT NULL, + session_id VARCHAR(128) NOT NULL, + invocation_id VARCHAR(256), + timestamp TIMESTAMP NOT NULL WITH DEFAULT CURRENT TIMESTAMP, + event_data CLOB(1M) NOT NULL, + FOREIGN KEY (session_id) REFERENCES {self._session_table}(id) ON DELETE CASCADE + ) + """ + + def _events_ddl_mssql(self) -> str: + """SQL Server DDL for events table.""" + return f""" + IF OBJECT_ID(N'{self._events_table}', N'U') IS NULL + CREATE TABLE {self._events_table} ( + id NVARCHAR(128) NOT NULL PRIMARY KEY, + app_name NVARCHAR(128) NOT NULL, + user_id NVARCHAR(128) NOT NULL, + session_id NVARCHAR(128) NOT NULL, + invocation_id NVARCHAR(256), + timestamp DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + event_data NVARCHAR(MAX) NOT NULL, + FOREIGN KEY (session_id) REFERENCES {self._session_table}(id) ON DELETE CASCADE + ) + """ + def _events_ddl_generic(self) -> str: """Generic SQL-92 compatible DDL for events table. @@ -583,6 +731,38 @@ def _app_states_table_ddl(self) -> str: update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() ) """ + if self._dialect == DIALECT_ORACLE: + return f""" + BEGIN + EXECUTE IMMEDIATE 'CREATE TABLE {self._app_state_table} ( + app_name VARCHAR2(128) PRIMARY KEY, + state CLOB NOT NULL, + update_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL + )'; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; + END; + """ + if self._dialect == DIALECT_DB2: + return f""" + CREATE TABLE {self._app_state_table} ( + app_name VARCHAR(128) NOT NULL PRIMARY KEY, + state CLOB(1M) NOT NULL, + update_time TIMESTAMP NOT NULL WITH DEFAULT CURRENT TIMESTAMP + ) + """ + if self._dialect == DIALECT_MSSQL: + return f""" + IF OBJECT_ID(N'{self._app_state_table}', N'U') IS NULL + CREATE TABLE {self._app_state_table} ( + app_name NVARCHAR(128) NOT NULL PRIMARY KEY, + state NVARCHAR(MAX) NOT NULL, + update_time DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME() + ) + """ json_type = self._json_storage_type() timestamp_type = self._timestamp_storage_type() if self._dialect == DIALECT_SQLITE: @@ -609,6 +789,44 @@ def _user_states_table_ddl(self) -> str: update_time TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP() ) """ + if self._dialect == DIALECT_ORACLE: + return f""" + BEGIN + EXECUTE IMMEDIATE 'CREATE TABLE {self._user_state_table} ( + app_name VARCHAR2(128) NOT NULL, + user_id VARCHAR2(128) NOT NULL, + state CLOB NOT NULL, + update_time TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, + PRIMARY KEY (app_name, user_id) + )'; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; + END; + """ + if self._dialect == DIALECT_DB2: + return f""" + CREATE TABLE {self._user_state_table} ( + app_name VARCHAR(128) NOT NULL, + user_id VARCHAR(128) NOT NULL, + state CLOB(1M) NOT NULL, + update_time TIMESTAMP NOT NULL WITH DEFAULT CURRENT TIMESTAMP, + PRIMARY KEY (app_name, user_id) + ) + """ + if self._dialect == DIALECT_MSSQL: + return f""" + IF OBJECT_ID(N'{self._user_state_table}', N'U') IS NULL + CREATE TABLE {self._user_state_table} ( + app_name NVARCHAR(128) NOT NULL, + user_id NVARCHAR(128) NOT NULL, + state NVARCHAR(MAX) NOT NULL, + update_time DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), + PRIMARY KEY (app_name, user_id) + ) + """ json_type = self._json_storage_type() timestamp_type = self._timestamp_storage_type() if self._dialect == DIALECT_SQLITE: @@ -635,6 +853,35 @@ def _metadata_table_ddl(self) -> str: value STRING NOT NULL ) """ + if self._dialect == DIALECT_ORACLE: + return f""" + BEGIN + EXECUTE IMMEDIATE 'CREATE TABLE {self._metadata_table} ( + key VARCHAR2(128) PRIMARY KEY, + value VARCHAR2(512) NOT NULL + )'; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; + END; + """ + if self._dialect == DIALECT_DB2: + return f""" + CREATE TABLE {self._metadata_table} ( + "KEY" VARCHAR(128) NOT NULL PRIMARY KEY, + value VARCHAR(512) NOT NULL + ) + """ + if self._dialect == DIALECT_MSSQL: + return f""" + IF OBJECT_ID(N'{self._metadata_table}', N'U') IS NULL + CREATE TABLE {self._metadata_table} ( + [key] NVARCHAR(128) NOT NULL PRIMARY KEY, + value NVARCHAR(512) NOT NULL + ) + """ return f""" CREATE TABLE IF NOT EXISTS {self._metadata_table} ( key VARCHAR(128) PRIMARY KEY, @@ -643,13 +890,13 @@ def _metadata_table_ddl(self) -> str: """ def _drop_app_states_table_sql(self) -> str: - return f"DROP TABLE IF EXISTS {self._app_state_table}" + return _drop_table_sql_for_dialect(self._dialect, self._app_state_table) def _drop_user_states_table_sql(self) -> str: - return f"DROP TABLE IF EXISTS {self._user_state_table}" + return _drop_table_sql_for_dialect(self._dialect, self._user_state_table) def _drop_metadata_table_sql(self) -> str: - return f"DROP TABLE IF EXISTS {self._metadata_table}" + return _drop_table_sql_for_dialect(self._dialect, self._metadata_table) def _drop_tables_sql(self) -> "list[str]": """Get DROP TABLE SQL statements. @@ -661,8 +908,8 @@ def _drop_tables_sql(self) -> "list[str]": self._drop_metadata_table_sql(), self._drop_user_states_table_sql(), self._drop_app_states_table_sql(), - f"DROP TABLE IF EXISTS {self._events_table}", - f"DROP TABLE IF EXISTS {self._session_table}", + _drop_table_sql_for_dialect(self._dialect, self._events_table), + _drop_table_sql_for_dialect(self._dialect, self._session_table), ] def _create_tables(self) -> None: @@ -673,33 +920,48 @@ def _create_tables(self) -> None: try: self._enable_foreign_keys(cursor, conn) - cursor.execute(self._sessions_table_ddl()) + _execute_table_ddl(cursor, self._dialect, self._session_table, self._sessions_table_ddl()) conn.commit() if not skip_indexes: - sessions_idx_app_user = f"CREATE INDEX IF NOT EXISTS idx_{self._session_table}_app_user ON {self._session_table}(app_name, user_id)" - cursor.execute(sessions_idx_app_user) + _execute_index_ddl( + cursor, + self._dialect, + f"idx_{self._session_table}_app_user", + self._session_table, + "app_name, user_id", + ) conn.commit() - sessions_idx_update = f"CREATE INDEX IF NOT EXISTS idx_{self._session_table}_update_time ON {self._session_table}(update_time DESC)" - cursor.execute(sessions_idx_update) + _execute_index_ddl( + cursor, + self._dialect, + f"idx_{self._session_table}_update_time", + self._session_table, + "update_time DESC", + ) conn.commit() - cursor.execute(self._events_table_ddl()) + _execute_table_ddl(cursor, self._dialect, self._events_table, self._events_table_ddl()) conn.commit() if not skip_indexes: - events_idx = f"CREATE INDEX IF NOT EXISTS idx_{self._events_table}_session ON {self._events_table}(session_id, timestamp ASC)" - cursor.execute(events_idx) + _execute_index_ddl( + cursor, + self._dialect, + f"idx_{self._events_table}_session", + self._events_table, + "session_id, timestamp ASC", + ) conn.commit() - cursor.execute(self._app_states_table_ddl()) + _execute_table_ddl(cursor, self._dialect, self._app_state_table, self._app_states_table_ddl()) conn.commit() - cursor.execute(self._user_states_table_ddl()) + _execute_table_ddl(cursor, self._dialect, self._user_state_table, self._user_states_table_ddl()) conn.commit() - cursor.execute(self._metadata_table_ddl()) + _execute_table_ddl(cursor, self._dialect, self._metadata_table, self._metadata_table_ddl()) conn.commit() finally: cursor.close() @@ -721,9 +983,11 @@ def _enable_foreign_keys(self, cursor: Any, conn: Any) -> None: def _format_sql(self, sql: str) -> str: """Return SQL with dialect-appropriate positional placeholders.""" - if self._dialect != DIALECT_POSTGRESQL: - return sql - return _postgresql_store_sql(sql) + if self._dialect == DIALECT_POSTGRESQL: + return _postgresql_store_sql(sql) + if self._dialect == DIALECT_ORACLE: + return _oracle_store_sql(sql) + return sql def _execute(self, cursor: Any, sql: str, params: "tuple[Any, ...] | list[Any]") -> Any: """Execute parameterized SQL using the current ADBC dialect's placeholder style.""" @@ -741,7 +1005,7 @@ def _session_list_query( page_clause = "" if limit is not None: - if self._dialect == DIALECT_GENERIC: + if self._dialect in {DIALECT_GENERIC, DIALECT_ORACLE, DIALECT_DB2, DIALECT_MSSQL}: params.extend((offset, limit)) page_clause = "\n OFFSET ? ROWS FETCH NEXT ? ROWS ONLY" else: @@ -1162,7 +1426,10 @@ def _get_events( where_clause = " AND ".join(where_clauses) limit_clause = "" if limit is not None: - limit_clause = " LIMIT ?" + if self._dialect in {DIALECT_GENERIC, DIALECT_ORACLE, DIALECT_DB2, DIALECT_MSSQL}: + limit_clause = " OFFSET 0 ROWS FETCH NEXT ? ROWS ONLY" + else: + limit_clause = " LIMIT ?" params.append(limit) sql = f""" SELECT e.id, e.session_id, e.invocation_id, e.timestamp, e.event_data, s.app_name, s.user_id @@ -1352,7 +1619,8 @@ def _upsert_user_state(self, app_name: str, user_id: str, state: "dict[str, Any] cursor.close() def _get_metadata(self, key: str) -> "str | None": - sql = f"SELECT value FROM {self._metadata_table} WHERE key = ?" + key_col = _metadata_key_column(self._dialect) + sql = f"SELECT value FROM {self._metadata_table} WHERE {key_col} = ?" try: with self._config.provide_connection() as conn: cursor = conn.cursor() @@ -1369,8 +1637,9 @@ def _get_metadata(self, key: str) -> "str | None": raise def _set_metadata(self, key: str, value: str) -> None: - delete_sql = f"DELETE FROM {self._metadata_table} WHERE key = ?" - insert_sql = f"INSERT INTO {self._metadata_table} (key, value) VALUES (?, ?)" + key_col = _metadata_key_column(self._dialect) + delete_sql = f"DELETE FROM {self._metadata_table} WHERE {key_col} = ?" + insert_sql = f"INSERT INTO {self._metadata_table} ({key_col}, value) VALUES (?, ?)" with self._config.provide_connection() as conn: cursor = conn.cursor() try: @@ -1445,6 +1714,13 @@ def _detect_dialect(self) -> str: return DIALECT_GENERIC resolved = resolve_dialect_from_config(self._config.connection_config) + stmt_dialect = self._config.statement_config.dialect + if (isinstance(stmt_dialect, str) and stmt_dialect.lower() in {"oracle", "db2", "mssql", "tsql"}) or ( + resolved is None + and isinstance(stmt_dialect, str) + and stmt_dialect.lower() in {"sqlite", "duckdb", "bigquery", "snowflake"} + ): + resolved = stmt_dialect.lower() if is_postgres_dialect(resolved): return DIALECT_POSTGRESQL if resolved == "sqlite": @@ -1455,14 +1731,22 @@ def _detect_dialect(self) -> str: return DIALECT_BIGQUERY if resolved == "snowflake": return DIALECT_SNOWFLAKE + if resolved == "oracle": + return DIALECT_ORACLE + if resolved == "db2": + return DIALECT_DB2 + if resolved in {"mssql", "tsql"}: + return DIALECT_MSSQL logger.warning("Unknown ADBC driver: %s. Using generic SQL dialect.", driver_name) return DIALECT_GENERIC def _format_sql(self, sql: str) -> str: """Return SQL with dialect-appropriate positional placeholders.""" - if self._dialect != DIALECT_POSTGRESQL: - return sql - return _postgresql_store_sql(sql) + if self._dialect == DIALECT_POSTGRESQL: + return _postgresql_store_sql(sql) + if self._dialect == DIALECT_ORACLE: + return _oracle_store_sql(sql) + return sql def _execute(self, cursor: Any, sql: str, params: "tuple[Any, ...] | list[Any]") -> Any: """Execute parameterized SQL using the current ADBC dialect's placeholder style.""" @@ -1505,6 +1789,12 @@ def _memory_table_ddl(self) -> str: return self._memory_ddl_bigquery() if self._dialect == DIALECT_SNOWFLAKE: return self._memory_ddl_snowflake() + if self._dialect == DIALECT_ORACLE: + return self._memory_ddl_oracle() + if self._dialect == DIALECT_DB2: + return self._memory_ddl_db2() + if self._dialect == DIALECT_MSSQL: + return self._memory_ddl_mssql() return self._memory_ddl_generic() def _memory_ddl_postgresql(self) -> str: @@ -1602,6 +1892,71 @@ def _memory_ddl_snowflake(self) -> str: ) """ + def _memory_ddl_oracle(self) -> str: + owner_id_ddl = f", {self._owner_id_column_ddl}" if self._owner_id_column_ddl else "" + return f""" + BEGIN + EXECUTE IMMEDIATE 'CREATE TABLE {self._memory_table} ( + id VARCHAR2(128) PRIMARY KEY, + session_id VARCHAR2(128) NOT NULL, + app_name VARCHAR2(128) NOT NULL, + user_id VARCHAR2(128) NOT NULL, + scope VARCHAR2(16) DEFAULT ''user'' NOT NULL, + event_id VARCHAR2(128) NOT NULL UNIQUE, + author VARCHAR2(256){owner_id_ddl}, + timestamp TIMESTAMP WITH TIME ZONE NOT NULL, + content_json CLOB NOT NULL, + content_text CLOB NOT NULL, + metadata_json CLOB, + inserted_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL + )'; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; + END; + """ + + def _memory_ddl_db2(self) -> str: + owner_id_ddl = f", {self._owner_id_column_ddl}" if self._owner_id_column_ddl else "" + return f""" + CREATE TABLE {self._memory_table} ( + id VARCHAR(128) NOT NULL PRIMARY KEY, + session_id VARCHAR(128) NOT NULL, + app_name VARCHAR(128) NOT NULL, + user_id VARCHAR(128) NOT NULL, + scope VARCHAR(16) NOT NULL WITH DEFAULT 'user', + event_id VARCHAR(128) NOT NULL UNIQUE, + author VARCHAR(256){owner_id_ddl}, + timestamp TIMESTAMP NOT NULL, + content_json CLOB(1M) NOT NULL, + content_text CLOB(1M) NOT NULL, + metadata_json CLOB(1M), + inserted_at TIMESTAMP NOT NULL WITH DEFAULT CURRENT TIMESTAMP + ) + """ + + def _memory_ddl_mssql(self) -> str: + owner_id_ddl = f", {self._owner_id_column_ddl}" if self._owner_id_column_ddl else "" + return f""" + IF OBJECT_ID(N'{self._memory_table}', N'U') IS NULL + CREATE TABLE {self._memory_table} ( + id NVARCHAR(128) NOT NULL PRIMARY KEY, + session_id NVARCHAR(128) NOT NULL, + app_name NVARCHAR(128) NOT NULL, + user_id NVARCHAR(128) NOT NULL, + scope NVARCHAR(16) NOT NULL DEFAULT 'user', + event_id NVARCHAR(128) NOT NULL UNIQUE, + author NVARCHAR(256){owner_id_ddl}, + timestamp DATETIME2 NOT NULL, + content_json NVARCHAR(MAX) NOT NULL, + content_text NVARCHAR(MAX) NOT NULL, + metadata_json NVARCHAR(MAX), + inserted_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME() + ) + """ + def _memory_ddl_generic(self) -> str: owner_id_ddl = f", {self._owner_id_column_ddl}" if self._owner_id_column_ddl else "" return f""" @@ -1622,7 +1977,7 @@ def _memory_ddl_generic(self) -> str: """ def _drop_memory_table_sql(self) -> "list[str]": - return [f"DROP TABLE IF EXISTS {self._memory_table}"] + return [_drop_table_sql_for_dialect(self._dialect, self._memory_table)] def _create_tables(self) -> None: if not self._enabled: @@ -1632,20 +1987,27 @@ def _create_tables(self) -> None: with self._config.provide_connection() as conn: cursor = conn.cursor() try: - cursor.execute(self._memory_table_ddl()) + _execute_table_ddl(cursor, self._dialect, self._memory_table, self._memory_table_ddl()) conn.commit() if not skip_indexes: - idx_app_scope_user = f"CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_app_scope_user_time ON {self._memory_table}(app_name, scope, user_id, timestamp DESC)" - cursor.execute(idx_app_scope_user) + _execute_index_ddl( + cursor, + self._dialect, + f"idx_{self._memory_table}_app_scope_user_time", + self._memory_table, + "app_name, scope, user_id, timestamp DESC", + ) conn.commit() - idx_scope = f"CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_scope ON {self._memory_table}(app_name, scope)" - cursor.execute(idx_scope) + _execute_index_ddl( + cursor, self._dialect, f"idx_{self._memory_table}_scope", self._memory_table, "app_name, scope" + ) conn.commit() - idx_session = f"CREATE INDEX IF NOT EXISTS idx_{self._memory_table}_session ON {self._memory_table}(session_id)" - cursor.execute(idx_session) + _execute_index_ddl( + cursor, self._dialect, f"idx_{self._memory_table}_session", self._memory_table, "session_id" + ) conn.commit() finally: cursor.close() @@ -1750,7 +2112,14 @@ def _insert_memory_entries(self, entries: "list[StoredMemory]", owner_id: "objec inserted_count += 1 except Exception as exc: exc_str = str(exc).lower() - if "unique" in exc_str or "constraint" in exc_str or "duplicate" in exc_str: + if ( + "unique" in exc_str + or "constraint" in exc_str + or "duplicate" in exc_str + or "ora-00001" in exc_str + or "sql0803n" in exc_str + or "23505" in exc_str + ): continue raise conn.commit() @@ -1782,24 +2151,34 @@ def _search_entries( logger.warning("ADBC memory store does not support FTS, falling back to simple search") effective_limit = limit if limit is not None else self._max_results - pattern = f"%{query}%" where_scope, scope_params = _build_adbc_scope_where(app_name, user_id, scope_filter) + if self._dialect == DIALECT_DB2: + match_clause = "POSSTR(LOWER(content_text), LOWER(?)) > 0" + search_param = query + else: + match_clause = "content_text LIKE ?" + search_param = f"%{query}%" + + if self._dialect in {DIALECT_GENERIC, DIALECT_ORACLE, DIALECT_DB2, DIALECT_MSSQL}: + limit_clause = "OFFSET 0 ROWS FETCH NEXT ? ROWS ONLY" + else: + limit_clause = "LIMIT ?" sql = f""" SELECT id, session_id, app_name, user_id, scope, event_id, author, timestamp, content_json, content_text, metadata_json, inserted_at FROM {self._memory_table} WHERE {where_scope} - AND content_text LIKE ? + AND {match_clause} ORDER BY timestamp DESC - LIMIT ? + {limit_clause} """ try: with self._config.provide_connection() as conn: cursor = conn.cursor() try: - self._execute(cursor, sql, (*scope_params, pattern, effective_limit)) + self._execute(cursor, sql, (*scope_params, search_param, effective_limit)) rows = cursor.fetchall() finally: cursor.close() @@ -1904,6 +2283,15 @@ def _rows_to_records(self, rows: "list[Any]") -> "list[StoredMemory]": return records +_DB2_TABLE_EXISTS_SQL: Final = ( + "SELECT 1 FROM SYSCAT.TABLES WHERE TABSCHEMA = COALESCE(CAST(? AS VARCHAR(128)), CURRENT SCHEMA) AND TABNAME = ?" +) +_DB2_INDEX_EXISTS_SQL: Final = ( + "SELECT 1 FROM SYSCAT.INDEXES WHERE INDSCHEMA = COALESCE(CAST(? AS VARCHAR(128)), CURRENT SCHEMA) AND INDNAME = ?" +) +_MIN_QUALIFIED_PARTS: Final = 2 + + @lru_cache(maxsize=256) def _postgresql_store_sql(sql: str) -> str: """Cache positional SQL conversion without changing quoted or commented text.""" @@ -1919,6 +2307,97 @@ def _postgresql_store_sql(sql: str) -> str: return "".join(segments) +@lru_cache(maxsize=256) +def _oracle_store_sql(sql: str) -> str: + """Rewrite positional ``?`` placeholders in store SQL to ``:1, :2, ...``.""" + segments: list[str] = [] + last_end = 0 + index = 0 + for match in PARAMETER_REGEX.finditer(sql): + if match.lastgroup == "qmark": + index += 1 + segments.extend((sql[last_end : match.start()], f":{index}")) + last_end = match.end() + segments.append(sql[last_end:]) + return "".join(segments) + + +def _metadata_key_column(dialect: str) -> str: + if dialect == DIALECT_MSSQL: + return "[key]" + if dialect == DIALECT_DB2: + return '"KEY"' + return "key" + + +def _split_db2_table_name(table_name: str) -> "tuple[str | None, str]": + parts = [part.strip() for part in split_qualified_identifier(table_name, quote_chars='"') if part.strip()] + if len(parts) >= _MIN_QUALIFIED_PARTS: + return normalize_identifier(parts[-2], "db2"), normalize_identifier(parts[-1], "db2") + return None, normalize_identifier(parts[0] if parts else table_name, "db2") + + +def _db2_table_exists(cursor: Any, table_name: str) -> bool: + schema_name, catalog_table = _split_db2_table_name(table_name) + cursor.execute(_DB2_TABLE_EXISTS_SQL, (schema_name, catalog_table)) + return cursor.fetchone() is not None + + +def _db2_index_exists(cursor: Any, index_name: str) -> bool: + schema_name, catalog_index = _split_db2_table_name(index_name) + cursor.execute(_DB2_INDEX_EXISTS_SQL, (schema_name, catalog_index)) + return cursor.fetchone() is not None + + +def _execute_table_ddl(cursor: Any, dialect: str, table_name: str, ddl: str) -> None: + if dialect == DIALECT_DB2: + if not _db2_table_exists(cursor, table_name): + cursor.execute(ddl) + return + cursor.execute(ddl) + + +def _execute_index_ddl(cursor: Any, dialect: str, index_name: str, table_name: str, columns: str) -> None: + if dialect == DIALECT_ORACLE: + stmt = ( + f"BEGIN EXECUTE IMMEDIATE 'CREATE INDEX {index_name} ON {table_name}({columns})'; " + "EXCEPTION WHEN OTHERS THEN IF SQLCODE NOT IN (-955, -1408) THEN RAISE; END IF; END;" + ) + cursor.execute(stmt) + return + if dialect == DIALECT_DB2: + if not _db2_index_exists(cursor, index_name): + cursor.execute(f"CREATE INDEX {index_name} ON {table_name}({columns})") + return + if dialect == DIALECT_MSSQL: + stmt = ( + f"IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'{index_name}' " + f"AND object_id = OBJECT_ID(N'{table_name}')) " + f"CREATE INDEX {index_name} ON {table_name}({columns})" + ) + cursor.execute(stmt) + return + cursor.execute(f"CREATE INDEX IF NOT EXISTS {index_name} ON {table_name}({columns})") + + +def _drop_table_sql_for_dialect(dialect: str, table_name: str) -> str: + if dialect == DIALECT_ORACLE: + return ( + f"BEGIN EXECUTE IMMEDIATE 'DROP TABLE {table_name}'; " + "EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END;" + ) + if dialect == DIALECT_DB2: + schema_name, catalog_table = _split_db2_table_name(table_name) + schema_filter = f"TABSCHEMA = '{schema_name}'" if schema_name is not None else "TABSCHEMA = CURRENT SCHEMA" + return ( + f"BEGIN IF EXISTS (SELECT 1 FROM SYSCAT.TABLES WHERE {schema_filter} AND TABNAME = '{catalog_table}') " + f"THEN EXECUTE IMMEDIATE 'DROP TABLE {table_name}'; END IF; END" + ) + if dialect == DIALECT_MSSQL: + return f"IF OBJECT_ID(N'{table_name}', N'U') IS NOT NULL DROP TABLE {table_name}" + return f"DROP TABLE IF EXISTS {table_name}" + + def _build_adbc_scope_where( app_name: str, user_id: str, scope_filter: Literal["all", "user", "app"] ) -> tuple[str, tuple[Any, ...]]: diff --git a/sqlspec/adapters/adbc/config.py b/sqlspec/adapters/adbc/config.py index 343757240..072fa9f02 100644 --- a/sqlspec/adapters/adbc/config.py +++ b/sqlspec/adapters/adbc/config.py @@ -48,11 +48,13 @@ class AdbcConnectionParams(TypedDict): connection_string: NotRequired[str] db: NotRequired[str] path: NotRequired[str] + driver: NotRequired[str] driver_name: NotRequired[str] db_kwargs: NotRequired[dict[str, Any]] conn_kwargs: NotRequired[dict[str, Any]] entrypoint: NotRequired[str] profile: NotRequired[str] + auth_type: NotRequired[str] adbc_driver_manager_entrypoint: NotRequired[str] autocommit: NotRequired[bool] isolation_level: NotRequired[str] @@ -144,6 +146,7 @@ class AdbcDriverFeatures(TypedDict): enable_pgvector: NotRequired[bool] enable_paradedb: NotRequired[bool] enable_pg_textsearch: NotRequired[bool] + enable_lowercase_column_names: NotRequired[bool] enable_events: NotRequired[bool] on_connection_create: "NotRequired[Callable[[AdbcConnection], None]]" events_backend: NotRequired[Literal["poll_queue"]] @@ -264,7 +267,9 @@ def __init__( if statement_config is None: statement_config = get_statement_config(self._resolved_dialect) - statement_config, driver_features = apply_driver_features(statement_config, driver_features) + statement_config, driver_features = apply_driver_features( + statement_config, driver_features, dialect=self._resolved_dialect + ) features_dict = dict(driver_features) if driver_features else {} self._user_connection_hook = cast( "Callable[[AdbcConnection], None] | None", features_dict.pop("on_connection_create", None) @@ -306,8 +311,12 @@ def create_connection(self) -> AdbcConnection: return self.connection_instance try: - driver_name = cast("str | None", self.connection_config.get("driver_name")) - uri = cast("str | None", self.connection_config.get("uri")) + driver_name = cast( + "str | None", self.connection_config.get("driver_name") or self.connection_config.get("driver") + ) + db_kwargs = self.connection_config.get("db_kwargs") + nested_uri = db_kwargs.get("uri") if isinstance(db_kwargs, dict) else None + uri = cast("str | None", self.connection_config.get("uri") or nested_uri) connection = resolve_driver_connect_func(driver_name, uri)( **build_connection_config(self.connection_config) ) @@ -315,7 +324,9 @@ def create_connection(self) -> AdbcConnection: self._user_connection_hook(cast("AdbcConnection", connection)) return cast("AdbcConnection", connection) except Exception as e: - err_driver_name = self.connection_config.get("driver_name", "Unknown") + err_driver_name = ( + self.connection_config.get("driver_name") or self.connection_config.get("driver") or "Unknown" + ) msg = f"Could not configure connection using driver '{err_driver_name}'. Error: {e}" raise ImproperConfigurationError(msg) from e diff --git a/sqlspec/adapters/adbc/core.py b/sqlspec/adapters/adbc/core.py index 6e4cbd099..7769c4997 100644 --- a/sqlspec/adapters/adbc/core.py +++ b/sqlspec/adapters/adbc/core.py @@ -2,6 +2,7 @@ import datetime as dt import decimal +import re from collections.abc import Sized from functools import lru_cache, partial from typing import TYPE_CHECKING, Any, Final, cast @@ -23,6 +24,7 @@ from sqlspec.exceptions import ( CheckViolationError, DatabaseConnectionError, + DataError, DeadlockError, ForeignKeyViolationError, ImproperConfigurationError, @@ -40,7 +42,7 @@ map_sqlstate_to_exception, ) from sqlspec.typing import PGVECTOR_INSTALLED, Empty, import_optional_attr -from sqlspec.utils.module_loader import import_string +from sqlspec.utils.module_loader import import_optional, import_string from sqlspec.utils.serializers import to_json from sqlspec.utils.type_converters import build_uuid_coercions from sqlspec.utils.type_guards import has_rowcount, has_sqlstate @@ -69,6 +71,7 @@ "handle_postgres_rollback", "is_postgres_dialect", "is_shared_object_driver", + "normalize_column_names", "normalize_connection_aliases", "normalize_driver_path", "normalize_postgres_empty_parameters", @@ -90,6 +93,7 @@ ) COLUMN_CACHE_MAX_SIZE: int = 256 +_IMPLICIT_UPPER_COLUMN_PATTERN: Final[re.Pattern[str]] = re.compile(r"^(?!\d)(?:[A-Z0-9_]+)$") _UUID_TYPES: Final[tuple[type[Any], ...]] = tuple(build_uuid_coercions()) _STRICT_COERCION_ALLOWED_TYPES: Final[tuple[type[Any], ...]] = ( type(None), @@ -116,9 +120,12 @@ "postgres": ("postgres", "postgresql"), "bigquery": ("bigquery",), "sqlite": ("sqlite", "flight", "flightsql"), - "duckdb": ("duckdb", "gizmosql", "gizmo"), + "duckdb": ("duckdb", "gizmosql", "gizmo", "quack"), "mysql": ("mysql",), "snowflake": ("snowflake",), + "oracle": ("oracle",), + "db2": ("db2", "ibm db2"), + "mssql": ("mssql", "sql server", "sqlserver", "microsoft sql"), } @@ -133,6 +140,13 @@ ), "mysql": (ParameterStyle.POSITIONAL_PYFORMAT, [ParameterStyle.POSITIONAL_PYFORMAT, ParameterStyle.NAMED_PYFORMAT]), "snowflake": (ParameterStyle.QMARK, [ParameterStyle.QMARK, ParameterStyle.NUMERIC]), + "oracle": ( + ParameterStyle.POSITIONAL_COLON, + [ParameterStyle.POSITIONAL_COLON, ParameterStyle.NAMED_COLON, ParameterStyle.QMARK], + ), + "db2": (ParameterStyle.QMARK, [ParameterStyle.QMARK, ParameterStyle.NAMED_COLON]), + "mssql": (ParameterStyle.QMARK, [ParameterStyle.QMARK, ParameterStyle.NAMED_COLON, ParameterStyle.NAMED_AT]), + "tsql": (ParameterStyle.QMARK, [ParameterStyle.QMARK, ParameterStyle.NAMED_COLON, ParameterStyle.NAMED_AT]), } _DRIVER_ALIASES: "dict[str, str]" = { @@ -150,6 +164,12 @@ "grpc": "adbc_driver_flightsql.dbapi.connect", "gizmosql": "adbc_driver_flightsql.dbapi.connect", "gizmo": "adbc_driver_flightsql.dbapi.connect", + "oracle": "adbc_driver_oracle.dbapi.connect", + "oracledb": "adbc_driver_oracle.dbapi.connect", + "db2": "adbc_driver_db2.dbapi.connect", + "ibmdb2": "adbc_driver_db2.dbapi.connect", + "ibm_db": "adbc_driver_db2.dbapi.connect", + "quack": "adbc_driver_quack.dbapi.connect", } _URI_PREFIX_DRIVER: "tuple[tuple[str, str], ...]" = ( @@ -163,6 +183,10 @@ ("gizmo://", "adbc_driver_flightsql.dbapi.connect"), ("snowflake://", "adbc_driver_snowflake.dbapi.connect"), ("bigquery://", "adbc_driver_bigquery.dbapi.connect"), + ("oracle://", "adbc_driver_oracle.dbapi.connect"), + ("db2://", "adbc_driver_db2.dbapi.connect"), + ("quack://", "adbc_driver_quack.dbapi.connect"), + ("quack+tls://", "adbc_driver_quack.dbapi.connect"), ) _DRIVER_PATH_KEYWORDS_TO_DIALECT: "tuple[tuple[str, str], ...]" = ( @@ -172,6 +196,11 @@ ("bigquery", "bigquery"), ("snowflake", "snowflake"), ("gizmosql", "duckdb"), + ("quack", "duckdb"), + ("oracle", "oracle"), + ("db2", "db2"), + ("mssql", "mssql"), + ("sqlserver", "mssql"), ("flightsql", "sqlite"), ("grpc", "sqlite"), ) @@ -183,6 +212,11 @@ ) _SHARED_OBJECT_SUFFIXES: "tuple[str, ...]" = (".so", ".dylib", ".dll") _DRIVER_MANAGER_CONNECT: Final[str] = "adbc_driver_manager.dbapi.connect" +_GIZMOSQL_NATIVE_CONNECT: Final[str] = "adbc_driver_gizmosql.dbapi.connect" +_FLIGHTSQL_CONNECT: Final[str] = "adbc_driver_flightsql.dbapi.connect" +_MSSQL_NATIVE_CONNECT: Final[str] = "adbc_driver_mssql.dbapi.connect" +_QUACK_AUTH_KEY: Final[str] = "adbc.quack.token" +_GIZMOSQL_AUTH_TYPE_KEY: Final[str] = "adbc.gizmosql.auth_type" _FLIGHTSQL_DB_KWARGS_FIELDS: "tuple[str, ...]" = ("username", "password") _FLIGHTSQL_TLS_SKIP_VERIFY_KEY: Final[str] = "adbc.flight.sql.client_option.tls_skip_verify" _FLIGHTSQL_AUTHORIZATION_HEADER_KEY: Final[str] = "adbc.flight.sql.authorization_header" @@ -205,6 +239,99 @@ "57": "operational error", "02": "no data", } +_ORA_CODE_PATTERN: Final[re.Pattern[str]] = re.compile(r"\bORA-(\d{5})\b", re.IGNORECASE) +_ORA_INTEGRITY_START: Final[int] = 2290 +_ORA_INTEGRITY_END: Final[int] = 2300 +_ORA_PARSING_START: Final[int] = 900 +_ORA_PARSING_END: Final[int] = 1000 +_ORA_ERROR_CODE_MAP: Final[dict[int, tuple[type[SQLSpecError], str]]] = { + 1: (UniqueViolationError, "unique constraint violation"), + 60: (DeadlockError, "deadlock detected"), + 900: (SQLParsingError, "invalid SQL statement"), + 904: (SQLParsingError, "invalid identifier"), + 907: (SQLParsingError, "missing right parenthesis"), + 923: (SQLParsingError, "FROM keyword not found where expected"), + 933: (SQLParsingError, "SQL command not properly ended"), + 936: (SQLParsingError, "missing expression"), + 942: (SQLParsingError, "table or view does not exist"), + 1013: (QueryTimeoutError, "user requested cancel of current operation"), + 1017: (PermissionDeniedError, "invalid username/password"), + 1031: (PermissionDeniedError, "insufficient privileges"), + 1400: (NotNullViolationError, "not-null constraint violation"), + 1407: (NotNullViolationError, "not-null constraint violation"), + 1438: (DataError, "numeric precision overflow"), + 1722: (DataError, "invalid number"), + 1843: (DataError, "not a valid month"), + 1858: (DataError, "invalid character"), + 1861: (DataError, "literal does not match format string"), + 2290: (CheckViolationError, "check constraint violation"), + 2291: (ForeignKeyViolationError, "foreign key constraint violation"), + 2292: (ForeignKeyViolationError, "foreign key constraint violation"), + 3113: (DatabaseConnectionError, "end-of-file on communication channel"), + 3114: (DatabaseConnectionError, "not connected to ORACLE"), + 3135: (DatabaseConnectionError, "connection lost contact"), + 8177: (SerializationConflictError, "cannot serialize access"), + 12154: (DatabaseConnectionError, "TNS resolution failed"), + 12170: (QueryTimeoutError, "connect timeout"), + 12505: (DatabaseConnectionError, "listener rejected"), + 12514: (DatabaseConnectionError, "service not known"), + 12541: (DatabaseConnectionError, "no listener"), + 12899: (DataError, "value too large for column"), +} +_DB2_SQLSTATE_PATTERN: Final[re.Pattern[str]] = re.compile( + r"(?:SQLSTATE[=:\s]+|state[:=\s]+)([0-9A-Z]{5})\b", re.IGNORECASE +) +_DB2_SQLCODE_PATTERN: Final[re.Pattern[str]] = re.compile(r"\bSQL(?:CODE[=:\s]*)?(-?\d{3,5})[NCW]?\b", re.IGNORECASE) +_DB2_REASON_CODE_PATTERN: Final[re.Pattern[str]] = re.compile(r'Reason code\s*"?(\d+)"?', re.IGNORECASE) +_DB2_LOCK_TIMEOUT_REASON_CODE: Final[int] = 68 +_DB2_UNDEFINED_OBJECT_CODE: Final[int] = 204 +_DB2_ROLLBACK_SQLSTATES: Final[frozenset[str]] = frozenset({"40001", "57033"}) +_DB2_ROLLBACK_SQLCODES: Final[frozenset[int]] = frozenset({911, 913}) +_DB2_SQLCODE_MAP: Final[dict[int, tuple[type[SQLSpecError], str]]] = { + 104: (SQLParsingError, "sql syntax error"), + 204: (SQLParsingError, "undefined object"), + 302: (DataError, "numeric or string value out of range"), + 407: (NotNullViolationError, "not-null constraint violation"), + 433: (DataError, "value is too long"), + 530: (ForeignKeyViolationError, "foreign key constraint violation"), + 532: (ForeignKeyViolationError, "foreign key constraint violation"), + 545: (CheckViolationError, "check constraint violation"), + 551: (PermissionDeniedError, "permission denied"), + 552: (PermissionDeniedError, "authorization error"), + 803: (UniqueViolationError, "unique constraint violation"), + 900: (DatabaseConnectionError, "connection does not exist or was severed"), + 952: (QueryTimeoutError, "statement execution interrupted or timed out"), + 1042: (DatabaseConnectionError, "unexpected system error occurred"), + 30081: (DatabaseConnectionError, "communication failure"), +} +_MSSQL_MSG_NUMBER_PATTERN: Final[re.Pattern[str]] = re.compile(r"\b(?:Msg|error|number)\s+(\d{3,5})\b", re.IGNORECASE) +_MSSQL_CONSTRAINT_ERROR_CODE: Final[int] = 547 +_MSSQL_ERROR_CODE_MAP: Final[dict[int, tuple[type[SQLSpecError], str]]] = { + 53: (DatabaseConnectionError, "could not open connection to SQL Server"), + 102: (SQLParsingError, "incorrect syntax"), + 105: (SQLParsingError, "unclosed quotation mark"), + 156: (SQLParsingError, "incorrect syntax near keyword"), + 207: (SQLParsingError, "invalid column name"), + 208: (SQLParsingError, "invalid object name"), + 229: (PermissionDeniedError, "permission denied on object"), + 230: (PermissionDeniedError, "permission denied on column"), + 241: (DataError, "conversion failed when converting date/time"), + 242: (DataError, "datetime data type conversion out-of-range"), + 245: (DataError, "conversion failed when converting varchar to int"), + 262: (PermissionDeniedError, "permission denied in database"), + 515: (NotNullViolationError, "cannot insert the value NULL into column"), + 1205: (DeadlockError, "transaction was deadlocked on lock resources"), + 1222: (QueryTimeoutError, "lock request time out period exceeded"), + 2601: (UniqueViolationError, "cannot insert duplicate key row"), + 2627: (UniqueViolationError, "violation of unique or primary key constraint"), + 2628: (DataError, "string or binary data would be truncated"), + 3960: (SerializationConflictError, "snapshot isolation transaction aborted due to update conflict"), + 4060: (DatabaseConnectionError, "cannot open database requested by the login"), + 8114: (DataError, "error converting data type"), + 8115: (DataError, "arithmetic overflow error"), + 8152: (DataError, "string or binary data would be truncated"), + 18456: (PermissionDeniedError, "login failed for user"), +} def detect_dialect(connection: Any, logger: Any | None = None, *, fallback_dialect: "str | None" = None) -> str: @@ -326,6 +453,10 @@ def normalize_driver_path(driver_name: str) -> str: def driver_from_uri(uri: str) -> "str | None": """Resolve a default driver connect path from a URI.""" lowered = uri.lower() + if lowered.startswith(("gizmosql://", "gizmo://")): + return _resolve_gizmosql_driver_connect() + if lowered.startswith(("mssql://", "sqlserver://")): + return _resolve_mssql_driver_connect() for prefix, driver_path in _URI_PREFIX_DRIVER: if lowered.startswith(prefix): return driver_path @@ -335,9 +466,17 @@ def driver_from_uri(uri: str) -> "str | None": def driver_kind_from_driver_name(driver_name: str) -> "str | None": """Return a canonical driver kind based on driver name content.""" lowered_name = driver_name.lower() - if lowered_name in {"gizmosql", "gizmo"}: + if lowered_name in {"gizmosql", "gizmo"} or "gizmosql" in lowered_name: return "gizmosql" - resolved = _DRIVER_ALIASES.get(driver_name.lower(), driver_name) + if lowered_name in {"mssql", "sqlserver", "tsql"} or "mssql" in lowered_name or "sqlserver" in lowered_name: + return "mssql" + if lowered_name in {"oracle", "oracledb"} or "oracle" in lowered_name: + return "oracle" + if lowered_name in {"db2", "ibmdb2", "ibm_db"} or "db2" in lowered_name: + return "db2" + if lowered_name == "quack" or "quack" in lowered_name: + return "quack" + resolved = _DRIVER_ALIASES.get(lowered_name, driver_name) lowered = resolved.lower() for keyword, _dialect in _DRIVER_PATH_KEYWORDS_TO_DIALECT: if keyword in lowered: @@ -350,6 +489,14 @@ def driver_kind_from_uri(uri: str) -> "str | None": lowered = uri.lower() if lowered.startswith(("gizmosql://", "gizmo://")): return "gizmosql" + if lowered.startswith(("mssql://", "sqlserver://")): + return "mssql" + if lowered.startswith(("quack://", "quack+tls://")): + return "quack" + if lowered.startswith("oracle://"): + return "oracle" + if lowered.startswith("db2://"): + return "db2" for prefix, driver_path in _URI_PREFIX_DRIVER: if lowered.startswith(prefix): return driver_kind_from_driver_name(driver_path) @@ -362,6 +509,10 @@ def resolve_driver_name(driver_name: str | None, uri: str | None) -> str: if is_shared_object_driver(driver_name): return _DRIVER_MANAGER_CONNECT lowered_driver = driver_name.lower() + if lowered_driver in {"gizmosql", "gizmo"}: + return _resolve_gizmosql_driver_connect() + if lowered_driver in {"mssql", "sqlserver", "tsql"}: + return _resolve_mssql_driver_connect() alias = _DRIVER_ALIASES.get(lowered_driver) if alias is not None: return alias @@ -377,13 +528,17 @@ def resolve_driver_name(driver_name: str | None, uri: str | None) -> str: def resolve_driver_name_from_config(connection_config: "Mapping[str, Any]") -> str: """Resolve and normalize the driver name from a connection config mapping.""" + db_kwargs = connection_config.get("db_kwargs") + nested_uri = db_kwargs.get("uri") if isinstance(db_kwargs, dict) else None uri = ( connection_config.get("uri") or connection_config.get("url") or connection_config.get("dsn") or connection_config.get("connection_string") + or nested_uri ) - return resolve_driver_name(connection_config.get("driver_name"), uri) + driver_name = connection_config.get("driver_name") or connection_config.get("driver") + return resolve_driver_name(driver_name, uri) def resolve_driver_connect_func(driver_name: str | None, uri: str | None) -> "Callable[..., Any]": @@ -430,23 +585,41 @@ def resolve_dialect_from_config(connection_config: "Mapping[str, Any]") -> str: if lowered_backend in {"duckdb", "sqlite"}: return lowered_backend - driver_name = connection_config.get("driver_name") - if isinstance(driver_name, str) and driver_name.lower() in {"gizmosql", "gizmo"}: - return "duckdb" - - if isinstance(driver_name, str) and is_shared_object_driver(driver_name): - return resolve_dialect_from_driver_path(driver_name.lower()) + driver_name = connection_config.get("driver_name") or connection_config.get("driver") + if isinstance(driver_name, str): + lowered_driver_name = driver_name.lower() + if lowered_driver_name in {"gizmosql", "gizmo"}: + return "duckdb" + if lowered_driver_name in {"mssql", "sqlserver", "tsql"}: + return "mssql" + if lowered_driver_name in {"oracle", "oracledb"}: + return "oracle" + if lowered_driver_name in {"db2", "ibmdb2", "ibm_db"}: + return "db2" + if lowered_driver_name == "quack": + return "duckdb" + if is_shared_object_driver(driver_name): + return resolve_dialect_from_driver_path(lowered_driver_name) + db_kwargs = connection_config.get("db_kwargs") + nested_uri = db_kwargs.get("uri") if isinstance(db_kwargs, dict) else None uri = ( connection_config.get("uri") or connection_config.get("url") or connection_config.get("dsn") or connection_config.get("connection_string") + or nested_uri ) if isinstance(uri, str): lowered_uri = uri.lower() - if lowered_uri.startswith(("gizmosql://", "gizmo://")): + if lowered_uri.startswith(("gizmosql://", "gizmo://", "quack://", "quack+tls://")): return "duckdb" + if lowered_uri.startswith(("mssql://", "sqlserver://")): + return "mssql" + if lowered_uri.startswith("oracle://"): + return "oracle" + if lowered_uri.startswith("db2://"): + return "db2" if driver_name is None and lowered_uri.startswith("grpc+tls://"): return "duckdb" @@ -473,7 +646,7 @@ def normalize_connection_aliases(connection_config: "Mapping[str, Any]") -> "dic config.pop("dsn", None) config.pop("connection_string", None) - driver_name = config.get("driver_name") + driver_name = config.get("driver_name") or config.get("driver") uri = config.get("uri") driver_kind: str | None = None if isinstance(driver_name, str): @@ -481,7 +654,7 @@ def normalize_connection_aliases(connection_config: "Mapping[str, Any]") -> "dic if driver_kind is None and isinstance(uri, str): driver_kind = driver_kind_from_uri(uri) - if (driver_kind == "sqlite" or driver_kind is None) and "uri" not in config: + if (driver_kind == "sqlite" or driver_kind is None) and "uri" not in config and "db_kwargs" not in config: db_path = ( config.pop("database", None) or config.pop("db", None) @@ -514,8 +687,10 @@ def build_connection_config(connection_config: "Mapping[str, Any]") -> "dict[str """ config = normalize_connection_aliases(connection_config) - driver_name = config.get("driver_name") - uri = config.get("uri") + driver_name = config.get("driver_name") or config.get("driver") + db_kwargs_existing = config.get("db_kwargs") + nested_uri = db_kwargs_existing.get("uri") if isinstance(db_kwargs_existing, dict) else None + uri = config.get("uri") or nested_uri driver_kind: str | None = None if isinstance(driver_name, str): driver_kind = driver_kind_from_driver_name(driver_name) @@ -534,29 +709,18 @@ def build_connection_config(connection_config: "Mapping[str, Any]") -> "dict[str if legacy_entrypoint is not None: config.setdefault("entrypoint", legacy_entrypoint) - uses_driver_manager = ( - resolve_driver_name( - driver_name if isinstance(driver_name, str) else None, uri if isinstance(uri, str) else None - ) - == _DRIVER_MANAGER_CONNECT + resolved_connect_path = resolve_driver_name( + driver_name if isinstance(driver_name, str) else None, uri if isinstance(uri, str) else None ) + uses_driver_manager = resolved_connect_path == _DRIVER_MANAGER_CONNECT if not uses_driver_manager: config.pop("entrypoint", None) - if driver_kind in {"gizmosql", "flightsql"}: - _lift_flightsql_db_kwargs(config) - elif driver_kind == "bigquery": - db_kwargs = config.get("db_kwargs") - db_kwargs_dict: dict[str, Any] = dict(db_kwargs) if isinstance(db_kwargs, dict) else {} - for param, option in _BIGQUERY_DB_KWARGS_OPTIONS: - if param in config: - db_kwargs_dict[option] = config.pop(param) - if db_kwargs_dict: - config["db_kwargs"] = db_kwargs_dict + _lift_driver_specific_db_kwargs(config, driver_kind, resolved_connect_path) config.pop("driver_name", None) - if uses_driver_manager and isinstance(driver_name, str) and "driver" not in config: - config["driver"] = driver_name.strip() + if uses_driver_manager: + _apply_driver_manager_config(config, driver_kind, driver_name) return config @@ -673,41 +837,17 @@ def create_mapped_exception(error: Any, *, logger: Any | None = None) -> SQLSpec 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 - - if sqlstate: - if sqlstate == "23505": - return _create_adbc_error(error, UniqueViolationError, "unique constraint violation") - if sqlstate == "23503": - return _create_adbc_error(error, ForeignKeyViolationError, "foreign key constraint violation") - if sqlstate == "23502": - return _create_adbc_error(error, NotNullViolationError, "not-null constraint violation") - if sqlstate == "23514": - return _create_adbc_error(error, CheckViolationError, "check constraint violation") - - if sqlstate == "40P01": - return _create_adbc_error(error, DeadlockError, "deadlock detected") - if sqlstate == "40001": - return _create_adbc_error(error, SerializationConflictError, "serialization failure") - - if sqlstate == "57014": - termination_class = _classify_timeout_or_cancellation(str(error)) or OperationalError - return _create_adbc_error(error, termination_class, "query terminated") - if sqlstate == "42501": - return _create_adbc_error(error, PermissionDeniedError, "insufficient privilege") - if sqlstate == "28000": - return _create_adbc_error(error, PermissionDeniedError, "invalid authorization") - - exc_class = map_sqlstate_to_exception(sqlstate) - if exc_class is not None and exc_class is not SQLSpecError: - description = _get_sqlstate_description(sqlstate) - return _create_adbc_error(error, exc_class, description) + raw_message = str(error) + error_msg = raw_message.lower() + sqlstate_attr = error.sqlstate if has_sqlstate(error) else None + sqlstate = sqlstate_attr.strip().upper() if isinstance(sqlstate_attr, str) and sqlstate_attr.strip() else None - return _create_adbc_error(error, SQLSpecError, "database error") + if enterprise_mapped := _map_enterprise_exception(error, raw_message, error_msg, sqlstate): + return enterprise_mapped - error_msg = str(error).lower() + if sqlstate: + return _map_sqlstate_exception(error, sqlstate) if "unique" in error_msg or "duplicate" in error_msg: return _create_adbc_error(error, UniqueViolationError, "unique constraint violation") @@ -782,7 +922,12 @@ def get_statement_config(detected_dialect: str) -> StatementConfig: (ParameterStyle.QMARK, [ParameterStyle.QMARK]), ) - sqlglot_dialect = "postgres" if detected_dialect == "postgresql" else detected_dialect + if detected_dialect == "postgresql": + sqlglot_dialect = "postgres" + elif detected_dialect == "mssql": + sqlglot_dialect = "tsql" + else: + sqlglot_dialect = detected_dialect profile = driver_profile parameter_overrides: dict[str, Any] = { @@ -797,6 +942,12 @@ def get_statement_config(detected_dialect: str) -> StatementConfig: parameter_overrides["preserve_parameter_format"] = False parameter_overrides["supported_execution_parameter_styles"] = {ParameterStyle.QMARK, ParameterStyle.NUMERIC} parameter_overrides["ast_transformer"] = _transform_duckdb_copy_parameters + elif detected_dialect == "oracle": + parameter_overrides["preserve_parameter_format"] = False + parameter_overrides["supported_execution_parameter_styles"] = {ParameterStyle.POSITIONAL_COLON} + elif detected_dialect in {"db2", "mssql", "tsql"}: + parameter_overrides["preserve_parameter_format"] = False + parameter_overrides["supported_execution_parameter_styles"] = {ParameterStyle.QMARK} if is_postgres_dialect(detected_dialect): parameter_overrides["ast_transformer"] = build_null_pruning_transform(dialect=sqlglot_dialect) @@ -807,19 +958,21 @@ def get_statement_config(detected_dialect: str) -> StatementConfig: def apply_driver_features( - statement_config: "StatementConfig", driver_features: "Mapping[str, Any] | None" + statement_config: "StatementConfig", driver_features: "Mapping[str, Any] | None", *, dialect: "str | None" = None ) -> "tuple[StatementConfig, dict[str, Any]]": """Apply ADBC driver feature defaults to the statement config. Args: statement_config: Base statement configuration. driver_features: Optional driver feature overrides. + dialect: Optional resolved dialect name. Returns: Updated statement configuration and normalized driver features. """ processed_features: dict[str, Any] = dict(driver_features) if driver_features else {} processed_features = _normalize_adbc_driver_features(processed_features) + resolved_dialect = (dialect or resolve_dialect_name(statement_config.dialect)).lower() json_serializer = cast("Callable[[Any], str] | None", processed_features.setdefault("json_serializer", to_json)) processed_features.setdefault("enable_cast_detection", True) @@ -830,6 +983,7 @@ def apply_driver_features( processed_features.setdefault("enable_pgvector", PGVECTOR_INSTALLED) processed_features.setdefault("enable_paradedb", True) processed_features.setdefault("enable_pg_textsearch", True) + processed_features.setdefault("enable_lowercase_column_names", resolved_dialect in {"oracle", "db2"}) if json_serializer is not None: statement_config = _apply_adbc_json_serializer(statement_config, json_serializer) @@ -837,6 +991,15 @@ def apply_driver_features( return statement_config, processed_features +def normalize_column_names( + column_names: "list[str]", driver_features: "Mapping[str, Any] | None" = None +) -> "list[str]": + """Lowercase implicit-uppercase column names when enabled in driver_features.""" + if not driver_features or not driver_features.get("enable_lowercase_column_names", False): + return column_names + return [name.lower() if name and _IMPLICIT_UPPER_COLUMN_PATTERN.fullmatch(name) else name for name in column_names] + + def enforce_strict_type_coercion(parameters: Any, *, is_many: bool = False) -> None: """Validate parameter values when ``strict_type_coercion`` is enabled. @@ -856,7 +1019,11 @@ def enforce_strict_type_coercion(parameters: Any, *, is_many: bool = False) -> N _validate_strict_parameter_payload(parameters) -def resolve_column_names(description: "list[Any] | None", cache: "dict[int, tuple[Any, list[str]]]") -> list[str]: +def resolve_column_names( + description: "list[Any] | None", + cache: "dict[int, tuple[Any, list[str]]]", + driver_features: "Mapping[str, Any] | None" = None, +) -> list[str]: """Resolve and cache ADBC cursor description column names.""" if not description: return [] @@ -866,7 +1033,7 @@ def resolve_column_names(description: "list[Any] | None", cache: "dict[int, tupl if cached is not None and cached[0] is description: return cached[1] - column_names = [col[0] for col in description] + column_names = normalize_column_names([col[0] for col in description], driver_features) if len(cache) >= COLUMN_CACHE_MAX_SIZE: cache.pop(next(iter(cache))) cache[cache_key] = (description, column_names) @@ -874,7 +1041,11 @@ def resolve_column_names(description: "list[Any] | None", cache: "dict[int, tupl def collect_rows( - fetched_data: "list[Any] | None", description: "list[Any] | None", *, column_names: "list[str] | None" = None + fetched_data: "list[Any] | None", + description: "list[Any] | None", + *, + column_names: "list[str] | None" = None, + driver_features: "Mapping[str, Any] | None" = None, ) -> "tuple[list[Any], list[str]]": """Collect ADBC rows and column names. @@ -886,13 +1057,18 @@ def collect_rows( fetched_data: Rows returned from cursor.fetchall(). description: Cursor description metadata. column_names: Optional precomputed column names. + driver_features: Optional driver feature configuration. Returns: Tuple of (rows, column_names). """ if not description: return [], [] - resolved_column_names = [col[0] for col in description] if column_names is None else column_names + resolved_column_names = ( + normalize_column_names([col[0] for col in description], driver_features) + if column_names is None + else normalize_column_names(column_names, driver_features) + ) if not fetched_data: return [], resolved_column_names return fetched_data, resolved_column_names @@ -1327,6 +1503,133 @@ def _get_sqlstate_description(sqlstate: str) -> str: return _SQLSTATE_DESCRIPTIONS.get(class_code, "database error") +def _map_oracle_exception(error: Any, raw_message: str) -> "SQLSpecError | None": + match = _ORA_CODE_PATTERN.search(raw_message) + if match is None: + return None + code = int(match.group(1)) + if code in _ORA_ERROR_CODE_MAP: + exc_cls, desc = _ORA_ERROR_CODE_MAP[code] + return _create_adbc_error(error, exc_cls, desc) + if _ORA_INTEGRITY_START <= code < _ORA_INTEGRITY_END: + return _create_adbc_error(error, IntegrityError, "integrity constraint violation") + if _ORA_PARSING_START <= code < _ORA_PARSING_END: + return _create_adbc_error(error, SQLParsingError, "SQL parsing error") + return None + + +def _extract_db2_sqlcode(error: Any, raw_message: str, has_db2_marker: bool) -> "int | None": + match = _DB2_SQLCODE_PATTERN.search(raw_message) + if match is not None: + return abs(int(match.group(1))) + vendor_code = getattr(error, "vendor_code", None) + if isinstance(vendor_code, int) and (vendor_code < 0 or has_db2_marker): + return abs(vendor_code) + return None + + +def _map_db2_exception( + error: Any, raw_message: str, lowered_message: str, explicit_sqlstate: "str | None" +) -> "SQLSpecError | None": + sqlstate = explicit_sqlstate + if sqlstate is None: + state_matches = _DB2_SQLSTATE_PATTERN.findall(raw_message) + if state_matches: + sqlstate = str(state_matches[-1]).upper() + + has_db2_marker = ( + "db2" in lowered_message + or "sqlstate=" in lowered_message + or "reason code" in lowered_message + or _DB2_SQLCODE_PATTERN.search(raw_message) is not None + ) + sqlcode = _extract_db2_sqlcode(error, raw_message, has_db2_marker) + if not has_db2_marker and sqlcode is None and sqlstate not in {"57033", "42704"}: + return None + + if sqlstate in _DB2_ROLLBACK_SQLSTATES or (sqlcode is not None and sqlcode in _DB2_ROLLBACK_SQLCODES): + reason_match = _DB2_REASON_CODE_PATTERN.search(raw_message) + if reason_match is not None and int(reason_match.group(1)) == _DB2_LOCK_TIMEOUT_REASON_CODE: + return _create_adbc_error(error, QueryTimeoutError, "lock timeout") + if sqlstate == "57033" or (sqlcode is not None and sqlcode in _DB2_ROLLBACK_SQLCODES) or has_db2_marker: + return _create_adbc_error(error, DeadlockError, "deadlock detected") + + if sqlstate == "42704" or sqlcode == _DB2_UNDEFINED_OBJECT_CODE: + return _create_adbc_error(error, SQLParsingError, "undefined object") + if sqlstate == "42501" or (sqlcode is not None and sqlcode in {551, 552}): + return _create_adbc_error(error, PermissionDeniedError, "permission denied") + + if sqlcode is not None and sqlcode in _DB2_SQLCODE_MAP: + exc_cls, desc = _DB2_SQLCODE_MAP[sqlcode] + return _create_adbc_error(error, exc_cls, desc) + return None + + +def _map_mssql_exception(error: Any, raw_message: str, lowered_message: str) -> "SQLSpecError | None": + has_mssql_marker = any(marker in lowered_message for marker in ("mssql", "sql server", "sqlserver", "tds:", "msg ")) + code: int | None = None + vendor_code = getattr(error, "vendor_code", None) + if isinstance(vendor_code, int) and vendor_code > 0 and has_mssql_marker: + code = vendor_code + else: + msg_match = _MSSQL_MSG_NUMBER_PATTERN.search(raw_message) + if msg_match is not None and has_mssql_marker: + code = int(msg_match.group(1)) + + if code is None: + return None + if code == _MSSQL_CONSTRAINT_ERROR_CODE: + if "check constraint" in lowered_message: + return _create_adbc_error(error, CheckViolationError, "check constraint violation") + return _create_adbc_error(error, ForeignKeyViolationError, "foreign key constraint violation") + if code in _MSSQL_ERROR_CODE_MAP: + exc_cls, desc = _MSSQL_ERROR_CODE_MAP[code] + return _create_adbc_error(error, exc_cls, desc) + return None + + +def _map_enterprise_exception( + error: Any, raw_message: str, lowered_message: str, sqlstate: "str | None" +) -> "SQLSpecError | None": + if oracle_mapped := _map_oracle_exception(error, raw_message): + return oracle_mapped + if db2_mapped := _map_db2_exception(error, raw_message, lowered_message, sqlstate): + return db2_mapped + return _map_mssql_exception(error, raw_message, lowered_message) + + +def _map_sqlstate_exception(error: Any, sqlstate: str) -> SQLSpecError: + if sqlstate == "23505": + return _create_adbc_error(error, UniqueViolationError, "unique constraint violation") + if sqlstate == "23503": + return _create_adbc_error(error, ForeignKeyViolationError, "foreign key constraint violation") + if sqlstate == "23502": + return _create_adbc_error(error, NotNullViolationError, "not-null constraint violation") + if sqlstate == "23514": + return _create_adbc_error(error, CheckViolationError, "check constraint violation") + + if sqlstate == "40P01": + return _create_adbc_error(error, DeadlockError, "deadlock detected") + if sqlstate == "40001": + return _create_adbc_error(error, SerializationConflictError, "serialization failure") + + if sqlstate == "57014": + termination_class = _classify_timeout_or_cancellation(str(error)) or OperationalError + return _create_adbc_error(error, termination_class, "query terminated") + + if sqlstate == "42501": + return _create_adbc_error(error, PermissionDeniedError, "insufficient privilege") + if sqlstate == "28000": + return _create_adbc_error(error, PermissionDeniedError, "invalid authorization") + + exc_class = map_sqlstate_to_exception(sqlstate) + if exc_class is not None and exc_class is not SQLSpecError: + description = _get_sqlstate_description(sqlstate) + return _create_adbc_error(error, exc_class, description) + + return _create_adbc_error(error, SQLSpecError, "database error") + + def _normalize_adbc_driver_features(processed_features: "dict[str, Any]") -> "dict[str, Any]": if "strict_type_coercion" in processed_features and "enable_strict_type_coercion" not in processed_features: processed_features["enable_strict_type_coercion"] = processed_features["strict_type_coercion"] @@ -1508,6 +1811,20 @@ def _prepare_parameter_sequence_with_casts( _prepare_batch_with_casts = prepare_batch_with_casts +def _resolve_gizmosql_driver_connect() -> str: + """Return the preferred GizmoSQL connect path based on installed packages.""" + if import_optional("adbc_driver_gizmosql") is not None: + return _GIZMOSQL_NATIVE_CONNECT + return _FLIGHTSQL_CONNECT + + +def _resolve_mssql_driver_connect() -> str: + """Return the preferred SQL Server connect path based on installed packages.""" + if import_optional("adbc_driver_mssql") is not None: + return _MSSQL_NATIVE_CONNECT + return _DRIVER_MANAGER_CONNECT + + def _resolve_flightsql_db_kwargs_keys() -> "tuple[str, str]": database_options = import_optional_attr("adbc_driver_flightsql", "DatabaseOptions") if database_options is None: @@ -1515,21 +1832,9 @@ def _resolve_flightsql_db_kwargs_keys() -> "tuple[str, str]": return database_options.TLS_SKIP_VERIFY.value, database_options.AUTHORIZATION_HEADER.value -def _lift_flightsql_db_kwargs(config: "dict[str, Any]") -> None: - db_kwargs = config.pop("db_kwargs", None) - db_kwargs_dict: dict[str, Any] = dict(db_kwargs) if isinstance(db_kwargs, dict) else {} - tls_skip_verify_key, authorization_header_key = _resolve_flightsql_db_kwargs_keys() - - for field in _FLIGHTSQL_DB_KWARGS_FIELDS: - if field in config: - db_kwargs_dict.setdefault(field, config.pop(field)) - - if "tls_skip_verify" in config: - tls_skip_verify = config.pop("tls_skip_verify") - if isinstance(tls_skip_verify, bool): - tls_skip_verify = str(tls_skip_verify).lower() - db_kwargs_dict.setdefault(tls_skip_verify_key, tls_skip_verify) - +def _lift_flightsql_common_options( + config: "dict[str, Any]", db_kwargs_dict: "dict[str, Any]", authorization_header_key: str +) -> None: if "authorization_header" in config: db_kwargs_dict.setdefault(authorization_header_key, config.pop("authorization_header")) @@ -1563,9 +1868,89 @@ def _lift_flightsql_db_kwargs(config: "dict[str, Any]") -> None: for hdr_key, hdr_val in headers.items(): db_kwargs_dict.setdefault(f"{_FLIGHTSQL_RPC_CALL_HEADER_PREFIX}{hdr_key}", str(hdr_val)) + +def _lift_native_gizmosql_db_kwargs(config: "dict[str, Any]") -> None: + db_kwargs = config.pop("db_kwargs", None) + db_kwargs_dict: dict[str, Any] = dict(db_kwargs) if isinstance(db_kwargs, dict) else {} + _tls_skip_verify_key, authorization_header_key = _resolve_flightsql_db_kwargs_keys() + _lift_flightsql_common_options(config, db_kwargs_dict, authorization_header_key) + config.pop("gizmosql_backend", None) + if db_kwargs_dict: + config["db_kwargs"] = db_kwargs_dict + + +def _lift_flightsql_db_kwargs(config: "dict[str, Any]") -> None: + db_kwargs = config.pop("db_kwargs", None) + db_kwargs_dict: dict[str, Any] = dict(db_kwargs) if isinstance(db_kwargs, dict) else {} + tls_skip_verify_key, authorization_header_key = _resolve_flightsql_db_kwargs_keys() + + for field in _FLIGHTSQL_DB_KWARGS_FIELDS: + if field in config: + db_kwargs_dict.setdefault(field, config.pop(field)) + + if "auth_type" in config: + db_kwargs_dict.setdefault(_GIZMOSQL_AUTH_TYPE_KEY, config.pop("auth_type")) + + if "tls_skip_verify" in config: + tls_skip_verify = config.pop("tls_skip_verify") + if isinstance(tls_skip_verify, bool): + tls_skip_verify = str(tls_skip_verify).lower() + db_kwargs_dict.setdefault(tls_skip_verify_key, tls_skip_verify) + + _lift_flightsql_common_options(config, db_kwargs_dict, authorization_header_key) + config.pop("gizmosql_backend", None) if db_kwargs_dict: config["db_kwargs"] = db_kwargs_dict +def _lift_driver_specific_db_kwargs( + config: "dict[str, Any]", driver_kind: "str | None", resolved_connect_path: str +) -> None: + if driver_kind == "gizmosql" and resolved_connect_path == _GIZMOSQL_NATIVE_CONNECT: + _lift_native_gizmosql_db_kwargs(config) + return + if driver_kind in {"gizmosql", "flightsql"}: + _lift_flightsql_db_kwargs(config) + return + if driver_kind == "bigquery": + db_kwargs = config.get("db_kwargs") + db_kwargs_dict: dict[str, Any] = dict(db_kwargs) if isinstance(db_kwargs, dict) else {} + for param, option in _BIGQUERY_DB_KWARGS_OPTIONS: + if param in config: + db_kwargs_dict[option] = config.pop(param) + if db_kwargs_dict: + config["db_kwargs"] = db_kwargs_dict + return + if driver_kind == "quack" and "token" in config: + db_kwargs = config.get("db_kwargs") + db_kwargs_dict = dict(db_kwargs) if isinstance(db_kwargs, dict) else {} + db_kwargs_dict.setdefault(_QUACK_AUTH_KEY, config.pop("token")) + config["db_kwargs"] = db_kwargs_dict + return + if driver_kind == "oracle": + db_kwargs = config.get("db_kwargs") + db_kwargs_dict = dict(db_kwargs) if isinstance(db_kwargs, dict) else {} + for field in ("username", "password"): + if field in config: + db_kwargs_dict.setdefault(field, config.pop(field)) + if db_kwargs_dict: + config["db_kwargs"] = db_kwargs_dict + + +def _apply_driver_manager_config(config: "dict[str, Any]", driver_kind: "str | None", driver_name: Any) -> None: + if driver_kind == "mssql": + config["driver"] = "mssql" + elif isinstance(driver_name, str) and "driver" not in config: + config["driver"] = driver_name.strip() + if any(field in config for field in ("uri", "username", "password")): + db_kwargs = config.get("db_kwargs") + db_kwargs_dict = dict(db_kwargs) if isinstance(db_kwargs, dict) else {} + for field in ("uri", "username", "password"): + if field in config: + db_kwargs_dict.setdefault(field, config.pop(field)) + if db_kwargs_dict: + config["db_kwargs"] = db_kwargs_dict + + driver_profile: Final[DriverParameterProfile] = build_profile() diff --git a/sqlspec/adapters/adbc/data_dictionary.py b/sqlspec/adapters/adbc/data_dictionary.py index 2ac108181..4681610ad 100644 --- a/sqlspec/adapters/adbc/data_dictionary.py +++ b/sqlspec/adapters/adbc/data_dictionary.py @@ -40,6 +40,7 @@ ) from sqlspec.data_dictionary.dialects.cockroachdb import resolve_cockroachdb_json_type from sqlspec.data_dictionary.dialects.mysql import resolve_mysql_json_type +from sqlspec.data_dictionary.dialects.oracle import parse_oracle_version_components, resolve_oracle_json_type from sqlspec.data_dictionary.dialects.postgres import resolve_postgres_json_type from sqlspec.data_dictionary.dialects.sqlite import resolve_sqlite_json_type from sqlspec.driver import SyncDataDictionaryBase @@ -144,7 +145,11 @@ def get_version(self, driver: "AdbcDriver") -> "VersionInfo | None": self.cache_version(driver_id, None) return None - version_info = self.parse_version_with_pattern(config.version_pattern, str(version_value)) + if dialect == "oracle": + components = parse_oracle_version_components(str(version_value)) + version_info = VersionInfo(*components) if components is not None else None + else: + version_info = self.parse_version_with_pattern(config.version_pattern, str(version_value)) if version_info is None: self._log_version_unavailable(dialect, "parse_failed") self.cache_version(driver_id, None) @@ -178,6 +183,12 @@ def get_optimal_type(self, driver: "AdbcDriver", type_category: str) -> str: return resolve_mysql_json_type(version_info) if dialect == "cockroachdb": return resolve_cockroachdb_json_type(version_info) + if dialect == "oracle": + return resolve_oracle_json_type( + version_info, + compatible_major=version_info.major if version_info is not None else None, + is_autonomous=False, + ) json_version = config.get_feature_version("supports_json") if json_version and (version_info is None or version_info < json_version): diff --git a/sqlspec/adapters/adbc/driver.py b/sqlspec/adapters/adbc/driver.py index 278b69e3e..539941a1b 100644 --- a/sqlspec/adapters/adbc/driver.py +++ b/sqlspec/adapters/adbc/driver.py @@ -18,6 +18,7 @@ get_statement_config, handle_postgres_rollback, is_postgres_dialect, + normalize_column_names, normalize_postgres_empty_parameters, normalize_script_rowcount, prepare_batch_with_casts, @@ -38,7 +39,7 @@ get_cache_config, register_driver_profile, ) -from sqlspec.driver import BaseSyncExceptionHandler, SyncDriverAdapterBase, SyncRowStream +from sqlspec.driver import BaseSyncExceptionHandler, SyncDriverAdapterBase, SyncRowStream, validate_savepoint_name from sqlspec.exceptions import DatabaseConnectionError, SQLSpecError from sqlspec.utils.arrow_helpers import arrow_reader_with_deferred_close, arrow_table_to_pylist from sqlspec.utils.logging import get_logger @@ -133,6 +134,13 @@ def fetch_chunk(self) -> "list[dict[str, Any]]": batch = next(reader) except StopIteration: return [] + if bool(self._driver.driver_features.get("enable_lowercase_column_names", False)) and hasattr( + batch, "schema" + ): + batch_names = list(batch.schema.names) + normalized_names = normalize_column_names(batch_names, self._driver.driver_features) + if normalized_names != batch_names and hasattr(batch, "rename_columns"): + batch = batch.rename_columns(normalized_names) rows = arrow_table_to_pylist( batch, decode_arrow_extension_types=bool( @@ -196,7 +204,9 @@ def __init__( base_config = get_statement_config(self._detected_dialect) statement_config = base_config.replace(enable_caching=statement_config.enable_caching) - statement_config, normalized_features = apply_driver_features(statement_config, driver_features) + statement_config, normalized_features = apply_driver_features( + statement_config, driver_features, dialect=dialect or self._detected_dialect + ) super().__init__(connection=connection, statement_config=statement_config, driver_features=normalized_features) self.dialect = statement_config.dialect self._dialect_name = dialect or resolve_dialect_name(self.dialect) @@ -241,11 +251,14 @@ def dispatch_execute(self, cursor: "AdbcRawCursor", statement: SQL) -> "Executio if is_select_like: arrow_table = cursor.fetch_arrow_table() + raw_column_names = list(arrow_table.column_names) + column_names = normalize_column_names(raw_column_names, self.driver_features) + if column_names != raw_column_names and hasattr(arrow_table, "rename_columns"): + arrow_table = arrow_table.rename_columns(column_names) data = arrow_table_to_pylist( arrow_table, decode_arrow_extension_types=bool(self.driver_features.get("enable_arrow_extension_types", True)), ) - column_names = list(arrow_table.column_names) return self.create_execution_result( cursor, selected_data=data, @@ -392,7 +405,15 @@ def create_savepoint(self, name: str) -> None: NotImplementedError: If the connected database does not support savepoints. """ self._require_savepoints() - super().create_savepoint(name) + safe_name = validate_savepoint_name(name) + dialect_name = resolve_dialect_name(self.dialect) + if dialect_name == "db2": + self.execute_script(f"SAVEPOINT {safe_name} ON ROLLBACK RETAIN CURSORS") + return + if dialect_name in {"mssql", "tsql"}: + self.execute_script(f"SAVE TRANSACTION {safe_name}") + return + super().create_savepoint(safe_name) def release_savepoint(self, name: str) -> None: """Release a previously created savepoint. @@ -401,7 +422,10 @@ def release_savepoint(self, name: str) -> None: NotImplementedError: If the connected database does not support savepoints. """ self._require_savepoints() - super().release_savepoint(name) + safe_name = validate_savepoint_name(name) + if resolve_dialect_name(self.dialect) in {"oracle", "mssql", "tsql"}: + return + super().release_savepoint(safe_name) def rollback_to_savepoint(self, name: str) -> None: """Roll back the current transaction to a previously created savepoint. @@ -410,7 +434,11 @@ def rollback_to_savepoint(self, name: str) -> None: NotImplementedError: If the connected database does not support savepoints. """ self._require_savepoints() - super().rollback_to_savepoint(name) + safe_name = validate_savepoint_name(name) + if resolve_dialect_name(self.dialect) in {"mssql", "tsql"}: + self.execute_script(f"ROLLBACK TRANSACTION {safe_name}") + return + super().rollback_to_savepoint(safe_name) def set_migration_session_schema(self, schema: str) -> None: """Set the PostgreSQL search path for migration SQL when using ADBC PostgreSQL.""" @@ -573,6 +601,14 @@ def select_to_arrow( handle_postgres_rollback(self._dialect_name, cursor, logger) raise + if bool(self.driver_features.get("enable_lowercase_column_names", False)) and hasattr( + arrow_table, "column_names" + ): + raw_column_names = list(arrow_table.column_names) + normalized_column_names = normalize_column_names(raw_column_names, self.driver_features) + if normalized_column_names != raw_column_names and hasattr(arrow_table, "rename_columns"): + arrow_table = arrow_table.rename_columns(normalized_column_names) + arrow_result = build_arrow_result_from_table( prepared_statement, arrow_table, @@ -689,7 +725,10 @@ def collect_rows(self, cursor: "AdbcRawCursor", fetched: "list[Any]") -> "tuple[ """Collect ADBC rows for the direct execution path.""" column_names = self._resolve_column_names(cursor.description) data, column_names = collect_rows( - cast("list[Any] | None", fetched), cursor.description, column_names=column_names + cast("list[Any] | None", fetched), + cursor.description, + column_names=column_names, + driver_features=self.driver_features, ) return data, column_names, len(data) @@ -784,7 +823,7 @@ def _detect_flightsql_connection(connection: "AdbcConnection") -> bool: return "flight sql" in driver_name or "flightsql" in driver_name def _resolve_column_names(self, description: Any) -> list[str]: - return resolve_column_names(description, self._column_name_cache) + return resolve_column_names(description, self._column_name_cache, self.driver_features) @staticmethod def _resolve_count_result_rowcount(cursor: "AdbcRawCursor", *, fallback: int) -> int: diff --git a/sqlspec/adapters/adbc/events/store.py b/sqlspec/adapters/adbc/events/store.py index 14f701029..7083c78f3 100644 --- a/sqlspec/adapters/adbc/events/store.py +++ b/sqlspec/adapters/adbc/events/store.py @@ -3,12 +3,13 @@ ADBC supports the following database backends: - PostgreSQL (adbc_driver_postgresql) - SQLite (adbc_driver_sqlite) - - DuckDB (adbc_driver_duckdb) + - DuckDB (adbc_driver_duckdb / adbc_driver_quack) + - Oracle (adbc_driver_oracle) + - IBM Db2 (adbc_driver_db2) + - SQL Server (adbc_driver_mssql / adbc_driver_manager) - Snowflake (adbc_driver_snowflake) - BigQuery (adbc_driver_bigquery) - - FlightSQL (adbc_driver_flightsql) - -For unsupported databases (Oracle, Spanner, MySQL), use their native adapters instead. + - FlightSQL / GizmoSQL (adbc_driver_flightsql / adbc_driver_gizmosql) """ import logging @@ -31,6 +32,9 @@ DIALECT_BIGQUERY: Final = "bigquery" DIALECT_SNOWFLAKE: Final = "snowflake" DIALECT_FLIGHTSQL: Final = "flightsql" +DIALECT_ORACLE: Final = "oracle" +DIALECT_DB2: Final = "db2" +DIALECT_MSSQL: Final = "mssql" class AdbcEventQueueStore(BaseEventQueueStore["AdbcConfig"]): @@ -47,6 +51,9 @@ class AdbcEventQueueStore(BaseEventQueueStore["AdbcConfig"]): - PostgreSQL: JSONB columns, TIMESTAMPTZ, partial indexes - SQLite: TEXT columns, TIMESTAMP (default fallback) - DuckDB: JSON columns, TIMESTAMP + - Oracle: CLOB columns, TIMESTAMP WITH TIME ZONE, PL/SQL existence guards + - IBM Db2: CLOB(1M) columns, TIMESTAMP, SQLSTATE existence handlers + - SQL Server: NVARCHAR(MAX) columns, DATETIME2, OBJECT_ID / sys.indexes guards - BigQuery: JSON/STRING columns, CLUSTER BY (no indexes) - Snowflake: VARIANT columns, no indexes supported - FlightSQL: Uses SQLite-compatible types (TEXT, TIMESTAMP) @@ -79,10 +86,10 @@ def create_statements(self) -> "list[str]": Returns separate statements for table and index to support databases that require separate execution. """ - statements = [self._table_ddl()] + statements = [self._wrap_create_statement(self._table_ddl(), "table")] index_sql = self._index_ddl() if index_sql: - statements.append(index_sql) + statements.append(self._wrap_create_statement(index_sql, "index")) return statements def drop_statements(self) -> "list[str]": @@ -92,6 +99,21 @@ def drop_statements(self) -> "list[str]": if dialect in {DIALECT_BIGQUERY, DIALECT_SNOWFLAKE}: return [f"DROP TABLE IF EXISTS {self.table_name}"] + if dialect == DIALECT_ORACLE: + escaped_table = self.table_name.replace("'", "''") + return [ + f"BEGIN EXECUTE IMMEDIATE 'DROP TABLE {escaped_table}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END;" + ] + + if dialect == DIALECT_DB2: + escaped_table = self.table_name.replace("'", "''") + return [ + f"BEGIN DECLARE CONTINUE HANDLER FOR SQLSTATE '42704' BEGIN END; EXECUTE IMMEDIATE 'DROP TABLE {escaped_table}'; END" + ] + + if dialect == DIALECT_MSSQL: + return [f"IF OBJECT_ID(N'{self.table_name}', N'U') IS NOT NULL DROP TABLE {self.table_name}"] + index_name = self._index_name() return [f"DROP INDEX IF EXISTS {index_name}", f"DROP TABLE IF EXISTS {self.table_name}"] @@ -102,7 +124,7 @@ def _detect_dialect_from_config(self) -> str: Dialect identifier for DDL generation. """ connection_config = self._config.connection_config - driver_name = connection_config.get("driver_name", "") + driver_name = connection_config.get("driver_name") or connection_config.get("driver") or "" uri = connection_config.get("uri", "") driver_lower = str(driver_name).lower() if driver_name else "" @@ -114,7 +136,9 @@ def _detect_dialect_from_config(self) -> str: resolved = resolve_dialect_from_config(connection_config) if is_postgres_dialect(resolved): return DIALECT_POSTGRESQL - if resolved in {DIALECT_DUCKDB, DIALECT_BIGQUERY, DIALECT_SNOWFLAKE}: + if resolved in {"mssql", "tsql"}: + return DIALECT_MSSQL + if resolved in {DIALECT_DUCKDB, DIALECT_BIGQUERY, DIALECT_SNOWFLAKE, DIALECT_ORACLE, DIALECT_DB2}: return resolved if resolved == DIALECT_SQLITE and ( "sqlite" in driver_lower @@ -128,7 +152,17 @@ def _detect_dialect_from_config(self) -> str: dialect_str = str(statement_config.dialect).lower() if is_postgres_dialect(dialect_str): return DIALECT_POSTGRESQL - if dialect_str in {DIALECT_SQLITE, DIALECT_DUCKDB, DIALECT_BIGQUERY, DIALECT_SNOWFLAKE, DIALECT_FLIGHTSQL}: + if dialect_str in {"mssql", "tsql"}: + return DIALECT_MSSQL + if dialect_str in { + DIALECT_SQLITE, + DIALECT_DUCKDB, + DIALECT_BIGQUERY, + DIALECT_SNOWFLAKE, + DIALECT_FLIGHTSQL, + DIALECT_ORACLE, + DIALECT_DB2, + }: return dialect_str log_with_context( @@ -154,6 +188,12 @@ def _column_types(self) -> "tuple[str, str, str]": return "JSON", "JSON", "TIMESTAMP" if dialect == DIALECT_SNOWFLAKE: return "VARIANT", "VARIANT", "TIMESTAMP_TZ" + if dialect == DIALECT_ORACLE: + return "CLOB", "CLOB", "TIMESTAMP WITH TIME ZONE" + if dialect == DIALECT_DB2: + return "CLOB(1M)", "CLOB(1M)", "TIMESTAMP" + if dialect == DIALECT_MSSQL: + return "NVARCHAR(MAX)", "NVARCHAR(MAX)", "DATETIME2" return "TEXT", "TEXT", "TIMESTAMP" @@ -173,35 +213,64 @@ def _table_ddl(self) -> str: if dialect == DIALECT_POSTGRESQL: return f"CREATE TABLE IF NOT EXISTS {self.table_name} (event_id VARCHAR(64) PRIMARY KEY, channel VARCHAR(128) NOT NULL, payload_json JSONB NOT NULL, metadata_json JSONB, status VARCHAR(32) NOT NULL DEFAULT 'pending', available_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, lease_expires_at TIMESTAMPTZ, attempts INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, acknowledged_at TIMESTAMPTZ)" + if dialect == DIALECT_ORACLE: + return f"CREATE TABLE {self.table_name} (event_id VARCHAR2(64) PRIMARY KEY, channel VARCHAR2(128) NOT NULL, payload_json CLOB NOT NULL, metadata_json CLOB, status VARCHAR2(32) DEFAULT 'pending' NOT NULL, available_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, lease_expires_at TIMESTAMP WITH TIME ZONE, attempts NUMBER(10) DEFAULT 0 NOT NULL, created_at TIMESTAMP WITH TIME ZONE DEFAULT SYSTIMESTAMP NOT NULL, acknowledged_at TIMESTAMP WITH TIME ZONE)" + + if dialect == DIALECT_DB2: + return f"CREATE TABLE {self.table_name} (event_id VARCHAR(64) NOT NULL PRIMARY KEY, channel VARCHAR(128) NOT NULL, payload_json CLOB(1M) NOT NULL, metadata_json CLOB(1M), status VARCHAR(32) NOT NULL DEFAULT 'pending', available_at TIMESTAMP NOT NULL DEFAULT CURRENT TIMESTAMP, lease_expires_at TIMESTAMP, attempts INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP NOT NULL DEFAULT CURRENT TIMESTAMP, acknowledged_at TIMESTAMP)" + + if dialect == DIALECT_MSSQL: + return f"CREATE TABLE {self.table_name} (event_id NVARCHAR(64) NOT NULL PRIMARY KEY, channel NVARCHAR(128) NOT NULL, payload_json NVARCHAR(MAX) NOT NULL, metadata_json NVARCHAR(MAX), status NVARCHAR(32) NOT NULL DEFAULT 'pending', available_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), lease_expires_at DATETIME2, attempts INT NOT NULL DEFAULT 0, created_at DATETIME2 NOT NULL DEFAULT SYSUTCDATETIME(), acknowledged_at DATETIME2)" + return super()._table_ddl() def _index_ddl(self) -> str | None: """Build dialect-specific index SQL.""" dialect = self.dialect - if dialect == DIALECT_BIGQUERY: - return None - if dialect == DIALECT_SNOWFLAKE: + if dialect in {DIALECT_BIGQUERY, DIALECT_SNOWFLAKE}: return None if dialect == DIALECT_POSTGRESQL: return f"CREATE INDEX IF NOT EXISTS {self._index_name()} ON {self.table_name}(channel, status, available_at) WHERE status = 'pending'" + if dialect in {DIALECT_ORACLE, DIALECT_DB2, DIALECT_MSSQL}: + return f"CREATE INDEX {self._index_name()} ON {self.table_name}(channel, status, available_at)" + return super()._index_ddl() def _wrap_create_statement(self, statement: str, object_type: str) -> str: - """Return statement unchanged since ADBC dialects support IF NOT EXISTS. + """Wrap DDL statements in dialect-specific existence guards when needed. Args: statement: The DDL statement. - object_type: Unused - ADBC dialects handle existence checks natively. + object_type: Either ``"table"`` or ``"index"``. Returns: - The statement unchanged. + Idempotent DDL statement. """ - del object_type + dialect = self.dialect + if dialect == DIALECT_ORACLE: + escaped = statement.replace("'", "''") + if object_type == "table": + return f"BEGIN EXECUTE IMMEDIATE '{escaped}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -955 THEN RAISE; END IF; END;" + if object_type == "index": + return f"BEGIN EXECUTE IMMEDIATE '{escaped}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE NOT IN (-955, -1408) THEN RAISE; END IF; END;" + if dialect == DIALECT_DB2: + escaped = statement.replace("'", "''") + if object_type == "table": + return ( + f"BEGIN DECLARE CONTINUE HANDLER FOR SQLSTATE '42710' BEGIN END; EXECUTE IMMEDIATE '{escaped}'; END" + ) + if object_type == "index": + return f"BEGIN DECLARE CONTINUE HANDLER FOR SQLSTATE '01550' BEGIN END; DECLARE CONTINUE HANDLER FOR SQLSTATE '42710' BEGIN END; EXECUTE IMMEDIATE '{escaped}'; END" + if dialect == DIALECT_MSSQL: + if object_type == "table": + return f"IF OBJECT_ID(N'{self.table_name}', N'U') IS NULL BEGIN {statement}; END" + if object_type == "index": + return f"IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'{self._index_name()}' AND object_id = OBJECT_ID(N'{self.table_name}')) BEGIN {statement}; END" return statement def _wrap_drop_statement(self, statement: str) -> str: - """Return statement unchanged since ADBC dialects support IF EXISTS.""" + """Return statement unchanged since drop_statements handles dialect guards.""" return statement diff --git a/sqlspec/adapters/adbc/litestar/store.py b/sqlspec/adapters/adbc/litestar/store.py index 516ee7a71..c2951e289 100644 --- a/sqlspec/adapters/adbc/litestar/store.py +++ b/sqlspec/adapters/adbc/litestar/store.py @@ -19,6 +19,7 @@ from sqlspec.adapters.adbc.core import is_postgres_dialect from sqlspec.extensions.litestar.store import BaseSQLSpecStore from sqlspec.utils.sync_tools import async_ +from sqlspec.utils.text import normalize_identifier, split_qualified_identifier if TYPE_CHECKING: from sqlspec.adapters.adbc.config import AdbcConfig @@ -143,7 +144,8 @@ def _get_dialect(self) -> str: with self._config.provide_session() as driver: dialect_value = driver.dialect - self._dialect = str(dialect_value) if dialect_value else "postgres" + raw_dialect = str(dialect_value).lower() if dialect_value else "postgres" + self._dialect = "mssql" if raw_dialect in {"tsql", "sqlserver"} else raw_dialect assert self._dialect is not None return self._dialect @@ -218,6 +220,48 @@ def _table_ddl(self) -> str: ) """ + if dialect == "oracle": + return f""" + BEGIN + EXECUTE IMMEDIATE 'CREATE TABLE {self._table_name} ( + session_id VARCHAR2(255) PRIMARY KEY, + data BLOB NOT NULL, + expires_at TIMESTAMP WITH TIME ZONE + )'; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE != -955 THEN + RAISE; + END IF; + END; + """ + + if dialect == "db2": + return f""" + CREATE TABLE {self._table_name} ( + session_id VARCHAR(255) NOT NULL PRIMARY KEY, + data BLOB NOT NULL, + expires_at TIMESTAMP + ) + """ + + if dialect == "mssql": + return f""" + IF OBJECT_ID(N'{self._table_name}', N'U') IS NULL + CREATE TABLE {self._table_name} ( + session_id NVARCHAR(255) PRIMARY KEY, + data VARBINARY(MAX) NOT NULL, + expires_at DATETIME2 + ); + IF NOT EXISTS ( + SELECT 1 FROM sys.indexes + WHERE name = N'idx_{self._table_name}_expires_at' + AND object_id = OBJECT_ID(N'{self._table_name}') + ) + CREATE INDEX idx_{self._table_name}_expires_at + ON {self._table_name}(expires_at) WHERE expires_at IS NOT NULL; + """ + return f""" CREATE TABLE IF NOT EXISTS {self._table_name} ( session_id TEXT PRIMARY KEY, @@ -241,6 +285,8 @@ def _get_param_placeholder(self, position: int) -> str: if is_postgres_dialect(dialect): return f"${position}" + if dialect == "oracle": + return f":{position}" if dialect in {"mysql", "mariadb"}: return "%s" if dialect == "bigquery": @@ -257,6 +303,12 @@ def _get_current_timestamp_expr(self) -> str: if is_postgres_dialect(dialect): return "CURRENT_TIMESTAMP AT TIME ZONE 'UTC'" + if dialect == "oracle": + return "SYSTIMESTAMP AT TIME ZONE 'UTC'" + if dialect == "db2": + return "CURRENT TIMESTAMP" + if dialect == "mssql": + return "SYSUTCDATETIME()" if dialect in {"mysql", "mariadb"}: return "UTC_TIMESTAMP()" if dialect == "bigquery": @@ -267,6 +319,38 @@ def _get_current_timestamp_expr(self) -> str: def _create_table(self) -> None: """Synchronous implementation of create_table using ADBC driver.""" + dialect = self._get_dialect() + if dialect == "oracle": + table_block = self._table_ddl().strip() + index_block = f""" + BEGIN + EXECUTE IMMEDIATE 'CREATE INDEX idx_{self._table_name}_expires_at ON {self._table_name}(expires_at)'; + EXCEPTION + WHEN OTHERS THEN + IF SQLCODE NOT IN (-955, -1408) THEN + RAISE; + END IF; + END; + """.strip() + with self._config.provide_session() as driver: + driver.execute(table_block) + driver.execute(index_block) + driver.commit() + self._log_table_created() + return + + if dialect == "db2": + schema_name, catalog_table = _split_db2_table_name(self._table_name) + index_name = normalize_identifier(f"idx_{self._table_name}_expires_at", "db2") + with self._config.provide_session() as driver: + if driver.select_one_or_none(_DB2_TABLE_EXISTS_SQL, (schema_name, catalog_table)) is None: + driver.execute(self._table_ddl().strip()) + if driver.select_one_or_none(_DB2_INDEX_EXISTS_SQL, (schema_name, index_name)) is None: + driver.execute(f"CREATE INDEX idx_{self._table_name}_expires_at ON {self._table_name}(expires_at)") + driver.commit() + self._log_table_created() + return + sql_text = self._table_ddl() with self._config.provide_session() as driver: driver.execute_script(sql_text) @@ -284,6 +368,25 @@ def _drop_table_sql(self) -> "list[str]": if dialect in {"bigquery", "snowflake"}: return [f"DROP TABLE IF EXISTS {self._table_name}"] + if dialect == "oracle": + return [ + f"BEGIN EXECUTE IMMEDIATE 'DROP INDEX idx_{self._table_name}_expires_at'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -1418 THEN RAISE; END IF; END;", + f"BEGIN EXECUTE IMMEDIATE 'DROP TABLE {self._table_name}'; EXCEPTION WHEN OTHERS THEN IF SQLCODE != -942 THEN RAISE; END IF; END;", + ] + + if dialect == "db2": + schema_name, catalog_table = _split_db2_table_name(self._table_name) + schema_filter = f"TABSCHEMA = '{schema_name}'" if schema_name is not None else "TABSCHEMA = CURRENT SCHEMA" + return [ + f"BEGIN IF EXISTS (SELECT 1 FROM SYSCAT.TABLES WHERE {schema_filter} AND TABNAME = '{catalog_table}') THEN EXECUTE IMMEDIATE 'DROP TABLE {self._table_name}'; END IF; END" + ] + + if dialect == "mssql": + return [ + f"IF EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'idx_{self._table_name}_expires_at' AND object_id = OBJECT_ID(N'{self._table_name}')) DROP INDEX idx_{self._table_name}_expires_at ON {self._table_name}", + f"IF OBJECT_ID(N'{self._table_name}', N'U') IS NOT NULL DROP TABLE {self._table_name}", + ] + if dialect in {"mysql", "mariadb"}: return [ f"DROP INDEX idx_{self._table_name}_expires_at ON {self._table_name}", @@ -389,7 +492,7 @@ def _set(self, key: str, value: "str | bytes", expires_in: "int | timedelta | No VALUES ({p1}, {p2}, {p3}) ON DUPLICATE KEY UPDATE data = VALUES(data), expires_at = VALUES(expires_at) """ - elif dialect in {"bigquery", "snowflake"}: + elif dialect in {"bigquery", "snowflake", "oracle", "db2", "mssql"}: with self._config.provide_session() as driver: check_sql = f"SELECT COUNT(*) as count FROM {self._table_name} WHERE session_id = {p1}" result = driver.select_one(check_sql, key) @@ -528,3 +631,19 @@ def _delete_expired(self) -> int: if count > 0: self._log_delete_expired(count) return count + + +_DB2_TABLE_EXISTS_SQL = ( + "SELECT 1 FROM SYSCAT.TABLES WHERE TABSCHEMA = COALESCE(CAST(? AS VARCHAR(128)), CURRENT SCHEMA) AND TABNAME = ?" +) +_DB2_INDEX_EXISTS_SQL = ( + "SELECT 1 FROM SYSCAT.INDEXES WHERE INDSCHEMA = COALESCE(CAST(? AS VARCHAR(128)), CURRENT SCHEMA) AND INDNAME = ?" +) +_MIN_QUALIFIED_PARTS = 2 + + +def _split_db2_table_name(table_name: str) -> "tuple[str | None, str]": + parts = [part.strip() for part in split_qualified_identifier(table_name, quote_chars='"') if part.strip()] + if len(parts) >= _MIN_QUALIFIED_PARTS: + return normalize_identifier(parts[-2], "db2"), normalize_identifier(parts[-1], "db2") + return None, normalize_identifier(parts[0] if parts else table_name, "db2") diff --git a/tests/integration/adapters/_shared/_driver_type_system.py b/tests/integration/adapters/_shared/_driver_type_system.py index dbbfeac05..629e15b55 100644 --- a/tests/integration/adapters/_shared/_driver_type_system.py +++ b/tests/integration/adapters/_shared/_driver_type_system.py @@ -103,6 +103,7 @@ class SourceEquivalenceCase: "enable_pgvector", "enable_paradedb", "enable_pg_textsearch", + "enable_lowercase_column_names", "enable_events", "on_connection_create", "events_backend", diff --git a/tests/unit/adapters/test_adbc/test_adbc_config_normalization.py b/tests/unit/adapters/test_adbc/test_adbc_config_normalization.py index 1db3918c4..2f9c57ae8 100644 --- a/tests/unit/adapters/test_adbc/test_adbc_config_normalization.py +++ b/tests/unit/adapters/test_adbc/test_adbc_config_normalization.py @@ -531,3 +531,135 @@ def test_adbc_driver_standalone_applies_features_and_enforces_strict_coercion() with pytest.raises(SQLSpecError): driver.prepare_driver_parameters([object()], driver.statement_config) + + +def test_oracle_driver_aliases_uri_and_parameter_profile() -> None: + """Oracle aliases and oracle:// URI resolve to adbc_driver_oracle and POSITIONAL_COLON parameters.""" + for conn_cfg in ( + {"driver_name": "oracle", "uri": "oracle://scott:tiger@localhost:1521/FREEPDB1"}, + {"driver_name": "oracledb", "uri": "oracle://scott:tiger@localhost:1521/FREEPDB1"}, + {"uri": "oracle://scott:tiger@localhost:1521/FREEPDB1"}, + ): + config = AdbcConfig(connection_config=conn_cfg) + assert _resolve_driver_name(config) == "adbc_driver_oracle.dbapi.connect" + assert config.statement_config.dialect == "oracle" + assert config.statement_config.parameter_config.default_parameter_style == ParameterStyle.POSITIONAL_COLON + assert set(config.statement_config.parameter_config.supported_execution_parameter_styles or ()) == { + ParameterStyle.POSITIONAL_COLON + } + + +def test_oracle_lifts_discrete_username_and_password_into_db_kwargs() -> None: + """adbc_driver_oracle.dbapi.connect only accepts uri at top level; username/password move to db_kwargs.""" + config = AdbcConfig( + connection_config={ + "driver_name": "oracle", + "uri": "oracle://localhost:1521/FREEPDB1", + "username": "scott", + "password": "tiger", + } + ) + resolved = _get_connection_config_dict(config) + assert resolved["uri"] == "oracle://localhost:1521/FREEPDB1" + assert "username" not in resolved + assert "password" not in resolved + assert resolved["db_kwargs"] == {"username": "scott", "password": "tiger"} + + +def test_db2_driver_aliases_uri_and_parameter_profile() -> None: + """Db2 aliases and db2:// URI resolve to adbc_driver_db2 and QMARK parameters.""" + for conn_cfg in ( + {"driver_name": "db2", "uri": "DATABASE=testdb;HOSTNAME=localhost;PORT=50000;UID=db2inst1;PWD=secret;"}, + {"driver_name": "ibmdb2", "uri": "db2://db2inst1:secret@localhost:50000/testdb"}, + {"driver_name": "ibm_db", "uri": "db2://db2inst1:secret@localhost:50000/testdb"}, + {"uri": "db2://db2inst1:secret@localhost:50000/testdb"}, + ): + config = AdbcConfig(connection_config=conn_cfg) + assert _resolve_driver_name(config) == "adbc_driver_db2.dbapi.connect" + assert config.statement_config.dialect == "db2" + assert config.statement_config.parameter_config.default_parameter_style == ParameterStyle.QMARK + assert set(config.statement_config.parameter_config.supported_execution_parameter_styles or ()) == { + ParameterStyle.QMARK + } + + +def test_mssql_driver_manager_fallback_and_tsql_statement_config(monkeypatch: Any) -> None: + """When adbc_driver_mssql is absent, mssql routes through adbc_driver_manager with driver='mssql'.""" + monkeypatch.setattr( + "sqlspec.adapters.adbc.core.import_optional", lambda name: None if name == "adbc_driver_mssql" else MagicMock() + ) + + for conn_cfg in ( + {"driver_name": "mssql", "uri": "sqlserver://sa:Pass@localhost:1433?database=master"}, + {"driver_name": "sqlserver", "uri": "mssql://sa:Pass@localhost:1433?database=master"}, + {"uri": "sqlserver://sa:Pass@localhost:1433?database=master"}, + {"driver": "mssql", "db_kwargs": {"uri": "sqlserver://sa:Pass@localhost:1433?database=master"}}, + ): + config = AdbcConfig(connection_config=conn_cfg) + assert _resolve_driver_name(config) == "adbc_driver_manager.dbapi.connect" + assert config.statement_config.dialect == "tsql" + assert config.statement_config.parameter_config.default_parameter_style == ParameterStyle.QMARK + assert set(config.statement_config.parameter_config.supported_execution_parameter_styles or ()) == { + ParameterStyle.QMARK + } + resolved = _get_connection_config_dict(config) + assert resolved["driver"] == "mssql" + assert "uri" not in resolved + assert resolved["db_kwargs"]["uri"].startswith(("sqlserver://", "mssql://")) + + +def test_mssql_native_package_preferred_when_installed(monkeypatch: Any) -> None: + """When adbc_driver_mssql is installed, mssql resolves to adbc_driver_mssql.dbapi.connect.""" + monkeypatch.setattr( + "sqlspec.adapters.adbc.core.import_optional", lambda name: MagicMock() if name == "adbc_driver_mssql" else None + ) + config = AdbcConfig(connection_config={"driver_name": "mssql", "uri": "sqlserver://localhost:1433"}) + assert _resolve_driver_name(config) == "adbc_driver_mssql.dbapi.connect" + assert config.statement_config.dialect == "tsql" + + +def test_gizmosql_prefers_native_driver_when_installed(monkeypatch: Any) -> None: + """When adbc_driver_gizmosql is installed, gizmosql resolves to adbc_driver_gizmosql.dbapi.connect.""" + monkeypatch.setattr( + "sqlspec.adapters.adbc.core.import_optional", + lambda name: MagicMock() if name == "adbc_driver_gizmosql" else None, + ) + config = AdbcConfig( + connection_config={ + "driver_name": "gizmosql", + "uri": "grpc+tls://localhost:31337", + "auth_type": "external", + "tls_skip_verify": True, + } + ) + assert _resolve_driver_name(config) == "adbc_driver_gizmosql.dbapi.connect" + resolved = _get_connection_config_dict(config) + assert resolved["auth_type"] == "external" + assert resolved["tls_skip_verify"] is True + + +def test_gizmosql_flightsql_fallback_lifts_auth_type_into_db_kwargs(monkeypatch: Any) -> None: + """When adbc_driver_gizmosql is absent, auth_type is lifted into db_kwargs['adbc.gizmosql.auth_type'].""" + monkeypatch.setattr("sqlspec.adapters.adbc.core.import_optional", lambda _name: None) + config = AdbcConfig( + connection_config={"driver_name": "gizmosql", "uri": "grpc+tls://localhost:31337", "auth_type": "external"} + ) + assert _resolve_driver_name(config) == "adbc_driver_flightsql.dbapi.connect" + resolved = _get_connection_config_dict(config) + assert "auth_type" not in resolved + assert resolved["db_kwargs"]["adbc.gizmosql.auth_type"] == "external" + + +def test_quack_driver_aliases_uri_and_token_normalization() -> None: + """Quack driver resolves to adbc_driver_quack, duckdb dialect, and lifts token into db_kwargs.""" + for conn_cfg in ( + {"driver_name": "quack", "uri": "quack+tls://quack.example.com:9999", "token": "secret-jwt"}, + {"uri": "quack://localhost:9999", "token": "secret-jwt"}, + {"uri": "quack+tls://localhost:9999", "token": "secret-jwt"}, + ): + config = AdbcConfig(connection_config=conn_cfg) + assert _resolve_driver_name(config) == "adbc_driver_quack.dbapi.connect" + assert config.statement_config.dialect == "duckdb" + resolved = _get_connection_config_dict(config) + assert "token" not in resolved + assert resolved["db_kwargs"]["adbc.quack.token"] == "secret-jwt" diff --git a/tests/unit/adapters/test_adbc/test_adk_dialect_support.py b/tests/unit/adapters/test_adbc/test_adk_dialect_support.py index 953f744b1..a96f45e8a 100644 --- a/tests/unit/adapters/test_adbc/test_adk_dialect_support.py +++ b/tests/unit/adapters/test_adbc/test_adk_dialect_support.py @@ -8,9 +8,10 @@ from sqlspec.adapters.adbc import AdbcConfig from sqlspec.adapters.adbc.adk import AdbcADKMemoryStore, AdbcADKStore -from sqlspec.adapters.adbc.adk.store import _postgresql_store_sql +from sqlspec.adapters.adbc.adk.store import _oracle_store_sql, _postgresql_store_sql from sqlspec.adapters.adbc.events.store import AdbcEventQueueStore from sqlspec.adapters.adbc.litestar.store import ADBCStore +from sqlspec.core import StatementConfig pytestmark = [pytest.mark.xdist_group("sqlite"), pytest.mark.adbc, pytest.mark.integration] @@ -116,7 +117,7 @@ def test_sqlite_events_ddl_uses_text() -> None: assert "TEXT" in ddl assert "event_data" in ddl assert "session_id" in ddl - assert "REAL" in ddl # SQLite uses REAL for timestamps + assert "REAL" in ddl def test_duckdb_events_ddl_uses_json() -> None: @@ -209,14 +210,6 @@ def test_owner_id_column_snowflake() -> None: assert "account_id VARCHAR NOT NULL" in ddl -def _store_for(driver_name: str) -> AdbcADKStore: - return AdbcADKStore(AdbcConfig(connection_config={"driver_name": driver_name, "uri": ":memory:"})) - - -def _normalized(sql: str) -> str: - return " ".join(sql.split()) - - @pytest.mark.parametrize("driver_name", ["postgresql", "sqlite", "duckdb", "snowflake"]) def test_limit_offset_dialects_bind_a_row_limited_page(driver_name: str) -> None: """PostgreSQL, SQLite, DuckDB, and Snowflake page with bound LIMIT/OFFSET.""" @@ -380,3 +373,179 @@ def test_adbc_events_store_delegates_dialect_resolution() -> None: connection_config={"driver_name": "flightsql", "uri": "grpc://localhost:8815"}, extension_config={"events": {}} ) assert AdbcEventQueueStore(flight_config).dialect == "flightsql" + + +@pytest.mark.parametrize( + ("driver_name", "expected_dialect"), + [ + ("oracle", "oracle"), + ("oracledb", "oracle"), + ("db2", "db2"), + ("ibmdb2", "db2"), + ("mssql", "mssql"), + ("sqlserver", "mssql"), + ("tsql", "mssql"), + ], +) +def test_detect_enterprise_dialects_in_adk_stores(driver_name: str, expected_dialect: str) -> None: + """AdbcADKStore and AdbcADKMemoryStore detect oracle, db2, and mssql dialects.""" + config = AdbcConfig(connection_config={"driver_name": driver_name}) + store = AdbcADKStore(config) + mem_store = AdbcADKMemoryStore(config) + assert store.dialect() == expected_dialect + assert mem_store.dialect == expected_dialect + + +def test_detect_dialect_fallback_to_statement_config() -> None: + """AdbcADKStore and AdbcADKMemoryStore fall back to statement_config.dialect when connection_config has no driver.""" + config = AdbcConfig(connection_config={}, statement_config=StatementConfig(dialect="oracle")) + store = AdbcADKStore(config) + mem_store = AdbcADKMemoryStore(config) + assert store.dialect() == "oracle" + assert mem_store.dialect == "oracle" + + +def test_oracle_store_sql_rewrites_qmark_and_preserves_literals_and_comments() -> None: + """_oracle_store_sql rewrites unquoted ? to :1, :2 while preserving literals and comments.""" + _oracle_store_sql.cache_clear() + sql = "SELECT '?', \"col?\" FROM adk_session /* ? */ WHERE app_name = ? -- ?\nAND user_id = ?" + rewritten = _oracle_store_sql(sql) + assert rewritten == "SELECT '?', \"col?\" FROM adk_session /* ? */ WHERE app_name = :1 -- ?\nAND user_id = :2" + assert _oracle_store_sql(sql) == rewritten + assert _oracle_store_sql.cache_info().hits == 1 + + config = AdbcConfig(connection_config={"driver_name": "oracle"}) + store = AdbcADKStore(config) + mem_store = AdbcADKMemoryStore(config) + assert getattr(store, "_format_sql")("SELECT * FROM t WHERE a = ? AND b = ?") == ( + "SELECT * FROM t WHERE a = :1 AND b = :2" + ) + assert getattr(mem_store, "_format_sql")("SELECT * FROM t WHERE a = ? AND b = ?") == ( + "SELECT * FROM t WHERE a = :1 AND b = :2" + ) + + +def test_enterprise_adk_ddl_generation() -> None: + """AdbcADKStore and AdbcADKMemoryStore emit dialect-specific DDL for oracle, db2, and mssql.""" + oracle_store = AdbcADKStore(AdbcConfig(connection_config={"driver_name": "oracle"})) + oracle_mem = AdbcADKMemoryStore(AdbcConfig(connection_config={"driver_name": "oracle"})) + assert "EXECUTE IMMEDIATE" in getattr(oracle_store, "_sessions_table_ddl")() + assert "VARCHAR2(128)" in getattr(oracle_store, "_sessions_table_ddl")() + assert "CLOB" in getattr(oracle_store, "_events_table_ddl")() + assert "TIMESTAMP WITH TIME ZONE" in getattr(oracle_store, "_app_states_table_ddl")() + assert "EXECUTE IMMEDIATE" in getattr(oracle_mem, "_memory_table_ddl")() + assert "SQLCODE != -942" in getattr(oracle_store, "_drop_tables_sql")()[0] + + db2_store = AdbcADKStore(AdbcConfig(connection_config={"driver_name": "db2"})) + db2_mem = AdbcADKMemoryStore(AdbcConfig(connection_config={"driver_name": "db2"})) + assert "CLOB(1M)" in getattr(db2_store, "_sessions_table_ddl")() + assert "WITH DEFAULT CURRENT TIMESTAMP" in getattr(db2_store, "_sessions_table_ddl")() + assert "CLOB(1M)" in getattr(db2_store, "_events_table_ddl")() + assert '"KEY"' in getattr(db2_store, "_metadata_table_ddl")() + assert "CLOB(1M)" in getattr(db2_mem, "_memory_table_ddl")() + + mssql_store = AdbcADKStore(AdbcConfig(connection_config={"driver_name": "mssql"})) + mssql_mem = AdbcADKMemoryStore(AdbcConfig(connection_config={"driver_name": "mssql"})) + assert "IF OBJECT_ID" in getattr(mssql_store, "_sessions_table_ddl")() + assert "NVARCHAR(MAX)" in getattr(mssql_store, "_sessions_table_ddl")() + assert "SYSUTCDATETIME()" in getattr(mssql_store, "_sessions_table_ddl")() + assert "[key]" in getattr(mssql_store, "_metadata_table_ddl")() + assert "IF OBJECT_ID" in getattr(mssql_mem, "_memory_table_ddl")() + + +@pytest.mark.parametrize("driver_name", ["oracle", "db2", "mssql"]) +def test_enterprise_dialects_page_sessions_with_offset_fetch(driver_name: str) -> None: + """Oracle, Db2, and MSSQL use OFFSET ? ROWS FETCH NEXT ? ROWS ONLY with offset bound first.""" + store = _store_for(driver_name) + sql, params = getattr(store, "_session_list_query")("app", "u1", "update_time", "DESC", 15, 5) + assert _normalized(sql) == ( + "SELECT id, app_name, user_id, state, create_time, update_time " + "FROM adk_session WHERE app_name = ? AND user_id = ? " + "ORDER BY update_time DESC, id DESC OFFSET ? ROWS FETCH NEXT ? ROWS ONLY" + ) + assert params == ("app", "u1", 5, 15) + + +@pytest.mark.parametrize("driver_name", ["oracle", "db2", "mssql"]) +def test_enterprise_dialects_create_tables_uses_safe_index_creation(driver_name: str, monkeypatch: Any) -> None: + """_create_tables never emits CREATE INDEX IF NOT EXISTS for oracle, db2, or mssql.""" + executed_sql: list[str] = [] + mock_cursor = MagicMock() + + def _capture_execute(sql: str, params: Any = None) -> None: + executed_sql.append(_normalized(sql)) + + mock_cursor.execute.side_effect = _capture_execute + mock_cursor.fetchone.return_value = None + mock_conn = MagicMock() + mock_conn.cursor.return_value = mock_cursor + mock_ctx = MagicMock() + mock_ctx.__enter__.return_value = mock_conn + mock_ctx.__exit__.return_value = None + monkeypatch.setattr(AdbcConfig, "provide_connection", lambda *_a, **_k: mock_ctx) + + config = AdbcConfig( + connection_config={"driver_name": driver_name}, extension_config={"adk": {"enable_memory": True}} + ) + store = AdbcADKStore(config) + mem_store = AdbcADKMemoryStore(config) + store.create_tables() + mem_store.create_tables() + + assert executed_sql + assert all("CREATE INDEX IF NOT EXISTS" not in stmt for stmt in executed_sql) + if driver_name == "oracle": + assert any("SQLCODE NOT IN (-955, -1408)" in stmt for stmt in executed_sql) + elif driver_name == "db2": + assert any("SYSCAT.INDEXES" in stmt for stmt in executed_sql) + elif driver_name == "mssql": + assert any("sys.indexes" in stmt for stmt in executed_sql) + + +@pytest.mark.parametrize("driver_name", ["oracle", "db2", "mssql"]) +def test_enterprise_memory_search_entries_uses_dialect_grammar(driver_name: str, monkeypatch: Any) -> None: + """AdbcADKMemoryStore._search_entries uses POSSTR for Db2 and OFFSET/FETCH for enterprise dialects.""" + captured: list[tuple[str, Any]] = [] + mock_cursor = MagicMock() + + def _capture_execute(sql: str, params: Any = None) -> None: + captured.append((_normalized(sql), params)) + + mock_cursor.execute.side_effect = _capture_execute + mock_cursor.fetchall.return_value = [] + mock_conn = MagicMock() + mock_conn.cursor.return_value = mock_cursor + mock_ctx = MagicMock() + mock_ctx.__enter__.return_value = mock_conn + mock_ctx.__exit__.return_value = None + monkeypatch.setattr(AdbcConfig, "provide_connection", lambda *_a, **_k: mock_ctx) + + config = AdbcConfig( + connection_config={"driver_name": driver_name}, extension_config={"adk": {"enable_memory": True}} + ) + mem_store = AdbcADKMemoryStore(config) + mem_store.search_entries("needle", "app1", "user1", limit=5) + + assert len(captured) == 1 + sql, params = captured[0] + assert "LIMIT" not in sql + if driver_name == "db2": + assert "POSSTR(LOWER(content_text), LOWER(?)) > 0" in sql + assert "OFFSET 0 ROWS FETCH NEXT ? ROWS ONLY" in sql + assert params == ("app1", "user1", "needle", 5) + elif driver_name == "oracle": + assert "content_text LIKE :3" in sql + assert "OFFSET 0 ROWS FETCH NEXT :4 ROWS ONLY" in sql + assert params == ("app1", "user1", "%needle%", 5) + elif driver_name == "mssql": + assert "content_text LIKE ?" in sql + assert "OFFSET 0 ROWS FETCH NEXT ? ROWS ONLY" in sql + assert params == ("app1", "user1", "%needle%", 5) + + +def _store_for(driver_name: str) -> AdbcADKStore: + return AdbcADKStore(AdbcConfig(connection_config={"driver_name": driver_name, "uri": ":memory:"})) + + +def _normalized(sql: str) -> str: + return " ".join(sql.split()) diff --git a/tests/unit/adapters/test_adbc/test_core.py b/tests/unit/adapters/test_adbc/test_core.py index c8dd08dda..c6f66592c 100644 --- a/tests/unit/adapters/test_adbc/test_core.py +++ b/tests/unit/adapters/test_adbc/test_core.py @@ -6,6 +6,8 @@ from typing import Any, cast from uuid import UUID +import pyarrow as pa +import pytest from adbc_driver_manager import AdbcStatusCode, DatabaseError from sqlspec.adapters.adbc import AdbcDriver @@ -27,11 +29,21 @@ from sqlspec.core import SQL, ParameterStyle from sqlspec.dialects import PGTextSearch from sqlspec.exceptions import ( + CheckViolationError, + DatabaseConnectionError, + DataError, DeadlockError, + ForeignKeyViolationError, + IntegrityError, + NotNullViolationError, OperationalError, OperationCancelledError, + PermissionDeniedError, QueryTimeoutError, SerializationConflictError, + SQLParsingError, + SQLSpecError, + UniqueViolationError, ) @@ -411,3 +423,193 @@ def test_case_insensitive_uri_and_grpc_tls_resolution() -> None: built = build_connection_config({"uri": "grpc+tls://localhost:31337", "tls_skip_verify": True}) assert built["db_kwargs"]["adbc.flight.sql.client_option.tls_skip_verify"] == "true" + + +def test_detect_dialect_recognizes_oracle_db2_mssql_and_quack() -> None: + """detect_dialect maps Oracle, Db2, SQL Server, and Quack ADBC driver info.""" + oracle_conn = SimpleNamespace(adbc_get_info=lambda: {"vendor_name": "Oracle", "driver_name": "ADBC Oracle Driver"}) + db2_conn = SimpleNamespace(adbc_get_info=lambda: {"vendor_name": "IBM Db2", "driver_name": "ADBC Db2 Driver"}) + mssql_conn = SimpleNamespace( + adbc_get_info=lambda: {"vendor_name": "Microsoft SQL Server", "driver_name": "ADBC MSSQL Driver"} + ) + quack_conn = SimpleNamespace(adbc_get_info=lambda: {"vendor_name": "DuckDB", "driver_name": "ADBC Quack Driver"}) + + assert adbc_core.detect_dialect(oracle_conn) == "oracle" + assert adbc_core.detect_dialect(db2_conn) == "db2" + assert adbc_core.detect_dialect(mssql_conn) == "mssql" + assert adbc_core.detect_dialect(quack_conn) == "duckdb" + + +@pytest.mark.parametrize( + ("message", "expected_cls"), + [ + ("ORA-00001: unique constraint (APP.PK_USERS) violated", UniqueViolationError), + ("ORA-02291: integrity constraint (APP.FK_DEPT) violated - parent key not found", ForeignKeyViolationError), + ("ORA-02292: integrity constraint (APP.FK_DEPT) violated - child record found", ForeignKeyViolationError), + ('ORA-01400: cannot insert NULL into ("APP"."USERS"."EMAIL")', NotNullViolationError), + ('ORA-01407: cannot update ("APP"."USERS"."EMAIL") to NULL', NotNullViolationError), + ("ORA-02290: check constraint (APP.CHK_AGE) violated", CheckViolationError), + ("ORA-00060: deadlock detected while waiting for resource", DeadlockError), + ("ORA-08177: can't serialize access for this transaction", SerializationConflictError), + ("ORA-01013: user requested cancel of current operation", QueryTimeoutError), + ("ORA-12170: TNS:Connect timeout occurred", QueryTimeoutError), + ("ORA-01017: invalid username/password; logon denied", PermissionDeniedError), + ("ORA-01031: insufficient privileges", PermissionDeniedError), + ("ORA-12154: TNS:could not resolve the connect identifier specified", DatabaseConnectionError), + ("ORA-12541: TNS:no listener", DatabaseConnectionError), + ("ORA-03113: end-of-file on communication channel", DatabaseConnectionError), + ("ORA-00942: table or view does not exist", SQLParsingError), + ('ORA-00904: "BAD_COL": invalid identifier', SQLParsingError), + ("ORA-01722: invalid number", DataError), + ("ORA-12899: value too large for column", DataError), + ("ORA-02299: cannot validate (APP.UQ_TEST) - duplicate keys found", IntegrityError), + ], +) +def test_create_mapped_exception_oracle_ora_codes(message: str, expected_cls: type[SQLSpecError]) -> None: + mapped = adbc_core.create_mapped_exception(Exception(message)) + assert isinstance(mapped, expected_cls) + + +@pytest.mark.parametrize( + ("message", "sqlstate", "vendor_code", "expected_cls"), + [ + ( + 'SQL0911N The current transaction has been rolled back because of a deadlock or timeout. Reason code "68". SQLSTATE=40001', + "40001", + -911, + QueryTimeoutError, + ), + ( + 'SQL0911N The current transaction has been rolled back because of a deadlock or timeout. Reason code "2". SQLSTATE=40001', + "40001", + -911, + DeadlockError, + ), + ( + 'SQL0913N Unsuccessful execution caused by deadlock or timeout. Reason code "2". SQLSTATE=57033', + "57033", + -913, + DeadlockError, + ), + ( + "SQL0803N One or more values in the INSERT statement are not valid. SQLSTATE=23505", + None, + -803, + UniqueViolationError, + ), + ( + "SQL0530N The insert or update value of the FOREIGN KEY is not equal. SQLSTATE=23503", + None, + -530, + ForeignKeyViolationError, + ), + ("SQL0407N Assignment of a NULL value to a NOT NULL column is not allowed.", None, -407, NotNullViolationError), + ( + "SQL0545N The requested operation is not allowed because a row does not satisfy the check constraint.", + None, + -545, + CheckViolationError, + ), + ('SQL0204N "DB2INST1.MISSING" is an undefined name. SQLSTATE=42704', "42704", -204, SQLParsingError), + ("SQL0104N An unexpected token was found.", None, -104, SQLParsingError), + ("SQL0551N Does not have the required authorization or privilege.", None, -551, PermissionDeniedError), + ( + "SQL0302N The value of a host variable in the EXECUTE or OPEN statement is out of range.", + None, + -302, + DataError, + ), + ("SQL0952N Processing was cancelled due to an interrupt.", None, -952, QueryTimeoutError), + ("SQL30081N A communication error has been detected.", None, -30081, DatabaseConnectionError), + ], +) +def test_create_mapped_exception_db2_diagnostics( + message: str, sqlstate: str | None, vendor_code: int | None, expected_cls: type[SQLSpecError] +) -> None: + err = Exception(message) + if sqlstate is not None: + setattr(err, "sqlstate", sqlstate) + if vendor_code is not None: + setattr(err, "vendor_code", vendor_code) + mapped = adbc_core.create_mapped_exception(err) + assert isinstance(mapped, expected_cls) + + +@pytest.mark.parametrize( + ("message", "vendor_code", "expected_cls"), + [ + ("mssql: Msg 2627, Level 14, Violation of PRIMARY KEY constraint", 2627, UniqueViolationError), + ("mssql: Msg 2601, Cannot insert duplicate key row in object", 2601, UniqueViolationError), + ( + "mssql: Msg 547, The INSERT statement conflicted with the FOREIGN KEY constraint", + 547, + ForeignKeyViolationError, + ), + ("mssql: Msg 547, The INSERT statement conflicted with the CHECK constraint", 547, CheckViolationError), + ("mssql: Msg 515, Cannot insert the value NULL into column", 515, NotNullViolationError), + ("mssql: Msg 1205, Transaction (Process ID 52) was deadlocked on lock resources", 1205, DeadlockError), + ( + "mssql: Msg 3960, Snapshot isolation transaction aborted due to update conflict", + 3960, + SerializationConflictError, + ), + ("mssql: Msg 1222, Lock request time out period exceeded", 1222, QueryTimeoutError), + ("mssql: Msg 18456, Login failed for user 'sa'", 18456, PermissionDeniedError), + ("mssql: Msg 4060, Cannot open database requested by the login", 4060, DatabaseConnectionError), + ("mssql: Msg 208, Invalid object name 'dbo.missing'", 208, SQLParsingError), + ("mssql: Msg 8152, String or binary data would be truncated", 8152, DataError), + ], +) +def test_create_mapped_exception_mssql_diagnostics( + message: str, vendor_code: int | None, expected_cls: type[SQLSpecError] +) -> None: + err = Exception(message) + if vendor_code is not None: + setattr(err, "vendor_code", vendor_code) + mapped = adbc_core.create_mapped_exception(err) + assert isinstance(mapped, expected_cls) + + +def test_apply_driver_features_defaults_lowercase_columns_for_oracle_and_db2() -> None: + oracle_cfg, oracle_features = adbc_core.apply_driver_features( + get_statement_config("oracle"), None, dialect="oracle" + ) + db2_cfg, db2_features = adbc_core.apply_driver_features(get_statement_config("db2"), None, dialect="db2") + _pg_cfg, pg_features = adbc_core.apply_driver_features(get_statement_config("postgres"), None, dialect="postgres") + _mssql_cfg, mssql_features = adbc_core.apply_driver_features(get_statement_config("mssql"), None, dialect="mssql") + + assert oracle_cfg.dialect == "oracle" + assert oracle_features["enable_lowercase_column_names"] is True + assert db2_cfg.dialect == "db2" + assert db2_features["enable_lowercase_column_names"] is True + assert pg_features["enable_lowercase_column_names"] is False + assert mssql_features["enable_lowercase_column_names"] is False + + +def test_normalize_column_names_lowercases_implicit_upper_names() -> None: + names = ["USER_ID", "EMAIL_ADDR", "MixedCase", "123_INVALID"] + assert adbc_core.normalize_column_names(names, {"enable_lowercase_column_names": True}) == [ + "user_id", + "email_addr", + "MixedCase", + "123_INVALID", + ] + assert adbc_core.normalize_column_names(names, {"enable_lowercase_column_names": False}) == names + + +def test_adbc_driver_dispatch_execute_lowercases_oracle_columns() -> None: + table = pa.table({"USER_ID": [1], "MixedCase": ["a"]}) + cursor = SimpleNamespace( + execute=lambda _sql, parameters=None: None, + fetch_arrow_table=lambda: table, + description=[("USER_ID",), ("MixedCase",)], + rowcount=1, + ) + conn = SimpleNamespace( + adbc_get_info=lambda: {"vendor_name": "Oracle", "driver_name": "ADBC Oracle Driver"}, cursor=lambda: cursor + ) + driver = AdbcDriver(cast("Any", conn), dialect="oracle") + result = driver.dispatch_execute(cast("Any", cursor), SQL('SELECT USER_ID, "MixedCase" FROM dual')) + + assert result.column_names == ["user_id", "MixedCase"] + assert result.selected_data == [{"user_id": 1, "MixedCase": "a"}] diff --git a/tests/unit/adapters/test_adbc/test_data_dictionary.py b/tests/unit/adapters/test_adbc/test_data_dictionary.py index 4b8e831e5..4e74b0da8 100644 --- a/tests/unit/adapters/test_adbc/test_data_dictionary.py +++ b/tests/unit/adapters/test_adbc/test_data_dictionary.py @@ -529,3 +529,16 @@ def test_get_statistics_uses_native_statistic_names_when_available() -> None: assert statistics[0]["statistic_name"] == "vendor.custom_count" assert statistics[0]["is_approximate"] is False driver.connection.adbc_get_statistic_names.assert_called_once() + + +@pytest.mark.parametrize( + ("version_str", "expected_type"), + [("23.4.0.24.05", "JSON"), ("21.3.0.0.0", "JSON"), ("19.3.0.0.0", "BLOB"), ("11.2.0.4.0", "CLOB")], +) +def test_oracle_optimal_json_type_resolution(version_str: str, expected_type: str) -> None: + driver = Mock() + driver.dialect = "oracle" + driver.select_value_or_none.return_value = f"Oracle Database {version_str}" + + dictionary = AdbcDataDictionary() + assert dictionary.get_optimal_type(driver, "json") == expected_type diff --git a/tests/unit/adapters/test_adbc/test_extension_stores.py b/tests/unit/adapters/test_adbc/test_extension_stores.py new file mode 100644 index 000000000..d328f1e24 --- /dev/null +++ b/tests/unit/adapters/test_adbc/test_extension_stores.py @@ -0,0 +1,102 @@ +"""Unit tests for ADBC Litestar and Events extension stores across enterprise dialects.""" + +from contextlib import nullcontext +from types import SimpleNamespace +from typing import Any, cast + +import pytest + +from sqlspec.adapters.adbc.config import AdbcConfig +from sqlspec.adapters.adbc.events.store import AdbcEventQueueStore +from sqlspec.adapters.adbc.litestar.store import ADBCStore + +__all__ = () + + +@pytest.mark.parametrize( + ("dialect", "expected_placeholder", "expected_ts_expr", "expected_ddl_fragment"), + [ + ("oracle", ":1", "SYSTIMESTAMP AT TIME ZONE 'UTC'", "VARCHAR2(255) PRIMARY KEY"), + ("db2", "?", "CURRENT TIMESTAMP", "VARCHAR(255) NOT NULL PRIMARY KEY"), + ("mssql", "?", "SYSUTCDATETIME()", "NVARCHAR(255) PRIMARY KEY"), + ("tsql", "?", "SYSUTCDATETIME()", "NVARCHAR(255) PRIMARY KEY"), + ], +) +def test_adbc_litestar_store_enterprise_dialect_primitives( + dialect: str, expected_placeholder: str, expected_ts_expr: str, expected_ddl_fragment: str +) -> None: + store = ADBCStore(_make_adbc_config("sqlite:///:memory:")) + store._dialect = "mssql" if dialect == "tsql" else dialect + + assert store._get_param_placeholder(1) == expected_placeholder + assert store._get_current_timestamp_expr() == expected_ts_expr + assert expected_ddl_fragment in store._table_ddl() + + +def test_adbc_litestar_store_normalize_tsql_dialect() -> None: + driver_stub = SimpleNamespace(dialect="tsql") + config_stub = SimpleNamespace(extension_config={"litestar": {}}, provide_session=lambda: nullcontext(driver_stub)) + store = ADBCStore(cast("Any", config_stub)) + assert store._get_dialect() == "mssql" + + +@pytest.mark.parametrize( + ("dialect", "expected_drop_fragment"), + [("oracle", "SQLCODE != -942"), ("db2", "SYSCAT.TABLES"), ("mssql", "IF OBJECT_ID")], +) +def test_adbc_litestar_store_enterprise_drop_sql(dialect: str, expected_drop_fragment: str) -> None: + store = ADBCStore(_make_adbc_config("sqlite:///:memory:")) + store._dialect = dialect + drop_sql = "\n".join(store._drop_table_sql()) + assert expected_drop_fragment in drop_sql + + +@pytest.mark.parametrize("dialect", ["oracle", "db2", "mssql"]) +def test_adbc_litestar_store_enterprise_set_uses_check_then_insert_or_update(dialect: str) -> None: + executed: list[tuple[str, tuple[Any, ...]]] = [] + + class _DriverStub: + def select_one(self, sql: str, *params: Any) -> dict[str, Any]: + executed.append((sql.strip(), params)) + return {"count": 0} + + def execute(self, sql: str, *params: Any) -> None: + executed.append((sql.strip(), params)) + + def commit(self) -> None: + pass + + config_stub = SimpleNamespace(extension_config={"litestar": {}}, provide_session=lambda: nullcontext(_DriverStub())) + store = ADBCStore(cast("Any", config_stub)) + store._dialect = dialect + store._set("sess_1", b"payload", expires_in=None) + + assert len(executed) == 2 + assert "SELECT COUNT(*)" in executed[0][0] + assert "INSERT INTO" in executed[1][0] + assert "ON CONFLICT" not in executed[1][0] + + +@pytest.mark.parametrize( + ("uri", "expected_dialect", "expected_ddl_fragment", "expected_drop_fragment"), + [ + ("oracle://localhost:1521/FREEPDB1", "oracle", "SQLCODE != -955", "SQLCODE != -942"), + ("db2://db2inst1:secret@localhost:50000/testdb", "db2", "SQLSTATE '42710'", "SQLSTATE '42704'"), + ("sqlserver://sa:pass@localhost:1433?database=app", "mssql", "IF OBJECT_ID", "IF OBJECT_ID"), + ], +) +def test_adbc_event_queue_store_enterprise_dialects( + uri: str, expected_dialect: str, expected_ddl_fragment: str, expected_drop_fragment: str +) -> None: + config = _make_adbc_config(uri) + store = AdbcEventQueueStore(config) + + assert store.dialect == expected_dialect + create_sql = "\n".join(store.create_statements()) + drop_sql = "\n".join(store.drop_statements()) + assert expected_ddl_fragment in create_sql + assert expected_drop_fragment in drop_sql + + +def _make_adbc_config(dialect_uri: str) -> AdbcConfig: + return AdbcConfig(connection_config={"uri": dialect_uri}) diff --git a/tests/unit/adapters/test_adbc/test_savepoints.py b/tests/unit/adapters/test_adbc/test_savepoints.py index d3468e916..b66a38e5d 100644 --- a/tests/unit/adapters/test_adbc/test_savepoints.py +++ b/tests/unit/adapters/test_adbc/test_savepoints.py @@ -74,3 +74,48 @@ def test_nested_service_block_reports_missing_savepoints(dialect: str) -> None: assert isinstance(raised.value.__cause__, NotImplementedError) assert driver._transaction_depth == 0 assert cast("_Connection", driver.connection).cursor_obj.statements == [] + + +def test_oracle_savepoint_lifecycle_omits_release_statement() -> None: + driver = _driver("oracle") + driver.create_savepoint("sp_1") + driver.rollback_to_savepoint("sp_1") + driver.release_savepoint("sp_1") + + assert cast("_Connection", driver.connection).cursor_obj.statements == [ + "SAVEPOINT sp_1", + "ROLLBACK TO SAVEPOINT sp_1", + ] + + +def test_db2_savepoint_lifecycle_retains_cursors() -> None: + driver = _driver("db2") + driver.create_savepoint("sp_1") + driver.rollback_to_savepoint("sp_1") + driver.release_savepoint("sp_1") + + assert cast("_Connection", driver.connection).cursor_obj.statements == [ + "SAVEPOINT sp_1 ON ROLLBACK RETAIN CURSORS", + "ROLLBACK TO SAVEPOINT sp_1", + "RELEASE SAVEPOINT sp_1", + ] + + +def test_mssql_savepoint_lifecycle_uses_transaction_keywords() -> None: + driver = _driver("mssql") + driver.create_savepoint("sp_1") + driver.rollback_to_savepoint("sp_1") + driver.release_savepoint("sp_1") + + assert cast("_Connection", driver.connection).cursor_obj.statements == [ + "SAVE TRANSACTION sp_1", + "ROLLBACK TRANSACTION sp_1", + ] + + +@pytest.mark.parametrize("dialect", ["postgres", "oracle", "db2", "mssql"]) +@pytest.mark.parametrize("method", ["create_savepoint", "release_savepoint", "rollback_to_savepoint"]) +def test_savepoints_reject_unsafe_identifiers(dialect: str, method: str) -> None: + driver = _driver(dialect) + with pytest.raises(Exception, match="Invalid savepoint name"): + getattr(driver, method)("sp_1; DROP TABLE users") From 8539ba6921d146c1d70710a6483f84faefa8940a Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 13 Oct 2026 20:51:52 +0000 Subject: [PATCH 2/7] fix(adbc): preserve two-parameter apply_driver_features contract --- sqlspec/adapters/adbc/config.py | 7 ++++--- sqlspec/adapters/adbc/core.py | 5 ++--- sqlspec/adapters/adbc/driver.py | 7 ++++--- tests/unit/adapters/test_adbc/test_core.py | 10 ++++------ 4 files changed, 14 insertions(+), 15 deletions(-) diff --git a/sqlspec/adapters/adbc/config.py b/sqlspec/adapters/adbc/config.py index 072fa9f02..d96d7d91d 100644 --- a/sqlspec/adapters/adbc/config.py +++ b/sqlspec/adapters/adbc/config.py @@ -267,10 +267,11 @@ def __init__( if statement_config is None: statement_config = get_statement_config(self._resolved_dialect) - statement_config, driver_features = apply_driver_features( - statement_config, driver_features, dialect=self._resolved_dialect - ) + explicit_lowercase = bool(driver_features and "enable_lowercase_column_names" in driver_features) + statement_config, driver_features = apply_driver_features(statement_config, driver_features) features_dict = dict(driver_features) if driver_features else {} + if not explicit_lowercase and self._resolved_dialect in {"oracle", "db2"}: + features_dict["enable_lowercase_column_names"] = True self._user_connection_hook = cast( "Callable[[AdbcConnection], None] | None", features_dict.pop("on_connection_create", None) ) diff --git a/sqlspec/adapters/adbc/core.py b/sqlspec/adapters/adbc/core.py index 7769c4997..a467ac91d 100644 --- a/sqlspec/adapters/adbc/core.py +++ b/sqlspec/adapters/adbc/core.py @@ -958,21 +958,20 @@ def get_statement_config(detected_dialect: str) -> StatementConfig: def apply_driver_features( - statement_config: "StatementConfig", driver_features: "Mapping[str, Any] | None", *, dialect: "str | None" = None + statement_config: "StatementConfig", driver_features: "Mapping[str, Any] | None" ) -> "tuple[StatementConfig, dict[str, Any]]": """Apply ADBC driver feature defaults to the statement config. Args: statement_config: Base statement configuration. driver_features: Optional driver feature overrides. - dialect: Optional resolved dialect name. Returns: Updated statement configuration and normalized driver features. """ processed_features: dict[str, Any] = dict(driver_features) if driver_features else {} processed_features = _normalize_adbc_driver_features(processed_features) - resolved_dialect = (dialect or resolve_dialect_name(statement_config.dialect)).lower() + resolved_dialect = resolve_dialect_name(statement_config.dialect).lower() json_serializer = cast("Callable[[Any], str] | None", processed_features.setdefault("json_serializer", to_json)) processed_features.setdefault("enable_cast_detection", True) diff --git a/sqlspec/adapters/adbc/driver.py b/sqlspec/adapters/adbc/driver.py index 539941a1b..6cd715c69 100644 --- a/sqlspec/adapters/adbc/driver.py +++ b/sqlspec/adapters/adbc/driver.py @@ -204,9 +204,10 @@ def __init__( base_config = get_statement_config(self._detected_dialect) statement_config = base_config.replace(enable_caching=statement_config.enable_caching) - statement_config, normalized_features = apply_driver_features( - statement_config, driver_features, dialect=dialect or self._detected_dialect - ) + explicit_lowercase = bool(driver_features and "enable_lowercase_column_names" in driver_features) + statement_config, normalized_features = apply_driver_features(statement_config, driver_features) + if not explicit_lowercase and (dialect or self._detected_dialect) in {"oracle", "db2"}: + normalized_features["enable_lowercase_column_names"] = True super().__init__(connection=connection, statement_config=statement_config, driver_features=normalized_features) self.dialect = statement_config.dialect self._dialect_name = dialect or resolve_dialect_name(self.dialect) diff --git a/tests/unit/adapters/test_adbc/test_core.py b/tests/unit/adapters/test_adbc/test_core.py index c6f66592c..9ec9835af 100644 --- a/tests/unit/adapters/test_adbc/test_core.py +++ b/tests/unit/adapters/test_adbc/test_core.py @@ -571,12 +571,10 @@ def test_create_mapped_exception_mssql_diagnostics( def test_apply_driver_features_defaults_lowercase_columns_for_oracle_and_db2() -> None: - oracle_cfg, oracle_features = adbc_core.apply_driver_features( - get_statement_config("oracle"), None, dialect="oracle" - ) - db2_cfg, db2_features = adbc_core.apply_driver_features(get_statement_config("db2"), None, dialect="db2") - _pg_cfg, pg_features = adbc_core.apply_driver_features(get_statement_config("postgres"), None, dialect="postgres") - _mssql_cfg, mssql_features = adbc_core.apply_driver_features(get_statement_config("mssql"), None, dialect="mssql") + oracle_cfg, oracle_features = adbc_core.apply_driver_features(get_statement_config("oracle"), None) + db2_cfg, db2_features = adbc_core.apply_driver_features(get_statement_config("db2"), None) + _pg_cfg, pg_features = adbc_core.apply_driver_features(get_statement_config("postgres"), None) + _mssql_cfg, mssql_features = adbc_core.apply_driver_features(get_statement_config("mssql"), None) assert oracle_cfg.dialect == "oracle" assert oracle_features["enable_lowercase_column_names"] is True From ab4c71d04106f2577a019d45108fb1f0353d810b Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 13 Oct 2026 22:01:50 +0000 Subject: [PATCH 3/7] docs(adbc): remove non-pip-installable SQL Server ADBC driver entry --- docs/extensions/adk/adapters.rst | 2 +- docs/extensions/adk/backends.rst | 7 ++-- docs/recipes/sql_server.rst | 6 +--- docs/reference/adapters/adbc.rst | 35 ++++--------------- docs/reference/extensions/events.rst | 6 ++-- docs/usage/drivers_and_querying.rst | 2 +- .../frameworks/litestar/session_stores.rst | 2 +- 7 files changed, 17 insertions(+), 43 deletions(-) diff --git a/docs/extensions/adk/adapters.rst b/docs/extensions/adk/adapters.rst index 4aa9e6314..eb0b5e761 100644 --- a/docs/extensions/adk/adapters.rst +++ b/docs/extensions/adk/adapters.rst @@ -17,7 +17,7 @@ Use async adapters for best performance with ADK runners: - **Oracle**: ``oracledb`` - **IBM Db2**: ``db2`` (native ``ibm_db`` sync and async modes) - **DuckDB**: ``duckdb`` (analytics; reduced-scope for ADK) -- **ADBC**: ``adbc`` (Arrow-native portability across PostgreSQL, SQLite, DuckDB, BigQuery, Snowflake, Oracle, IBM Db2, and SQL Server) +- **ADBC**: ``adbc`` (Arrow-native portability across PostgreSQL, SQLite, DuckDB, BigQuery, Snowflake, Oracle, and IBM Db2) - **Spanner**: ``spanner`` (Google Cloud, globally distributed; supports both async and sync modes) - **SQL Server**: ``mssql_python`` (native Arrow and BulkCopy), ``pymssql`` (FreeTDS), ``arrow_odbc`` (Arrow over Microsoft ODBC Driver 18) diff --git a/docs/extensions/adk/backends.rst b/docs/extensions/adk/backends.rst index 0194b176c..eeff74fac 100644 --- a/docs/extensions/adk/backends.rst +++ b/docs/extensions/adk/backends.rst @@ -97,7 +97,7 @@ The table below classifies every backend by its ADK support level. - Reduced-scope - Full - Basic - - Arrow Database Connectivity across PostgreSQL, SQLite, DuckDB, BigQuery, Snowflake, Oracle, IBM Db2, and SQL Server. + - Arrow Database Connectivity across PostgreSQL, SQLite, DuckDB, BigQuery, Snowflake, Oracle, and IBM Db2. * - mssql_python - Supported - Full @@ -300,9 +300,8 @@ ADBC (Arrow Database Connectivity) provides a portable Arrow-native store - Dialect-aware DDL, parameter binding, and pagination for PostgreSQL, SQLite, DuckDB, BigQuery, Snowflake, Oracle (``:1, :2`` placeholders and PL/SQL - idempotent DDL), IBM Db2 (``SYSCAT`` catalog guards and ``POSSTR`` memory - search), and SQL Server (``OBJECT_ID`` / ``sys.indexes`` guards and - ``DATETIME2`` / ``NVARCHAR(MAX)`` storage). + idempotent DDL), and IBM Db2 (``SYSCAT`` catalog guards and ``POSSTR`` memory + search). - Arrow-native data transfer for high-throughput event ingestion. - Portable substring memory search across all supported ADBC dialects; choose a dedicated native adapter when you need backend-specific full-text search diff --git a/docs/recipes/sql_server.rst b/docs/recipes/sql_server.rst index 9d16a208f..273008386 100644 --- a/docs/recipes/sql_server.rst +++ b/docs/recipes/sql_server.rst @@ -12,7 +12,7 @@ It covers driver selection, connection management, transactional workflows, and Choosing a Driver ================= -SQLSpec offers four drivers for Microsoft SQL Server depending on workload requirements and platform dependencies: +SQLSpec offers three drivers for Microsoft SQL Server depending on workload requirements and platform dependencies: .. list-table:: :header-rows: 1 @@ -30,10 +30,6 @@ SQLSpec offers four drivers for Microsoft SQL Server depending on workload requi - qmark (``?``) / pyformat (``%(name)s``) - ``execute_many`` only - FreeTDS-based driver. - * - ``adbc`` (``driver="mssql"``) - - qmark (``?``) / ``:name`` / ``@name`` - - Native ``adbc_ingest`` Arrow tables - - Native Go TDS ADBC driver via ADBC Driver Foundry (``dbc install mssql``); zero-ODBC Arrow transport. * - ``arrow-odbc`` - qmark (``?``) - Arrow-first ODBC streaming diff --git a/docs/reference/adapters/adbc.rst b/docs/reference/adapters/adbc.rst index a9473915c..f423ca3b8 100644 --- a/docs/reference/adapters/adbc.rst +++ b/docs/reference/adapters/adbc.rst @@ -2,7 +2,7 @@ ADBC ==== -The Arrow Database Connectivity (ADBC) adapter connects SQLSpec to 11 relational, +The Arrow Database Connectivity (ADBC) adapter connects SQLSpec to 10 relational, analytical, cloud, and Arrow Flight SQL backends through a single :class:`~sqlspec.adapters.adbc.AdbcConfig` interface. @@ -26,20 +26,16 @@ Install SQLSpec with the ``adbc`` extra (which installs ``adbc-driver-manager`` .. tab-set:: - .. tab-item:: Enterprise (Oracle, Db2, SQL Server) + .. tab-item:: Enterprise (Oracle, Db2) .. code-block:: bash - # Oracle (pure-Rust ADBC driver wheel from GizmoData) + # Oracle (pure-Go ADBC driver wheel from GizmoData) uv add "sqlspec[adbc]" adbc-driver-oracle - # IBM Db2 (requires IBM ODBC/CLI driver header/library at runtime) + # IBM Db2 (pure-Go DRDA ADBC driver wheel from GizmoData) uv add "sqlspec[adbc]" adbc-driver-db2 - # Microsoft SQL Server via ADBC Driver Foundry (installs native Go TDS driver) - uv add "sqlspec[adbc]" - dbc install mssql - .. tab-item:: Relational & Local (PostgreSQL, SQLite, DuckDB, Quack) .. code-block:: bash @@ -95,11 +91,6 @@ connection ``uri`` scheme: - ``db2://`` - ``adbc_driver_db2`` - ``db2`` dialect; positional ``?`` parameters; lowercases implicit uppercase column names. - * - SQL Server - - ``mssql``, ``sqlserver``, ``tsql`` - - ``mssql://``, ``sqlserver://`` - - ``adbc_driver_mssql`` or ``adbc_driver_manager`` (``driver="mssql"``) - - ``tsql`` compilation dialect; positional ``?`` execution parameters; ``SAVE TRANSACTION`` savepoints. * - SQLite - ``sqlite``, ``sqlite3`` - ``sqlite://`` @@ -148,7 +139,7 @@ Connection Examples .. tab-set:: - .. tab-item:: Enterprise (Oracle, Db2, SQL Server) + .. tab-item:: Enterprise (Oracle, Db2) .. code-block:: python @@ -172,14 +163,6 @@ Connection Examples } ) - # Microsoft SQL Server (loaded via ADBC driver manager or adbc_driver_mssql) - mssql_config = AdbcConfig( - connection_config={ - "driver_name": "mssql", - "uri": "sqlserver://sa:SecretPassword123!@localhost:1433?database=app_db&trustServerCertificate=true", - } - ) - .. tab-item:: Relational & Local (PostgreSQL, SQLite, DuckDB, Quack) .. code-block:: python @@ -311,10 +294,6 @@ blocks use SQL savepoints with dialect-specific syntax: - ``SAVEPOINT ON ROLLBACK RETAIN CURSORS`` - ``ROLLBACK TO SAVEPOINT `` - ``RELEASE SAVEPOINT `` - * - SQL Server (``mssql`` / ``tsql``) - - ``SAVE TRANSACTION `` - - ``ROLLBACK TRANSACTION `` - - No-op (T-SQL does not release individual savepoints) * - DuckDB, BigQuery, Snowflake - Raises ``NotImplementedError`` - Raises ``NotImplementedError`` @@ -367,7 +346,7 @@ queries as an automatic fallback: ``adbc_get_objects``, SQLSpec normalizes them directly without issuing catalog SQL. - When native metadata is unsupported, incomplete, or omits index/DDL details, ``AdbcDataDictionary`` falls back to the dialect's SQL catalog queries (`postgres`, - `oracle`, `db2`, `mssql`, `sqlite`, `duckdb`, `bigquery`). + `oracle`, `db2`, `sqlite`, `duckdb`, `bigquery`). - For Oracle, ``get_optimal_type(driver, "json")`` inspects the server version and selects native ``JSON`` on Oracle 21c+, ``BLOB`` on Oracle 12c–19c, and ``CLOB`` on older versions. @@ -379,7 +358,7 @@ Framework and AI Extension Stores ================================= ``AdbcConfig`` integrates with all SQLSpec synchronous extension stores across every -supported relational and analytical dialect (`postgres`, `oracle`, `db2`, `mssql`, +supported relational and analytical dialect (`postgres`, `oracle`, `db2`, `sqlite`, `duckdb`, `bigquery`, `snowflake`): - **Litestar Sessions**: :class:`~sqlspec.adapters.adbc.litestar.store.ADBCStore` diff --git a/docs/reference/extensions/events.rst b/docs/reference/extensions/events.rst index a4cec1857..ad907ca41 100644 --- a/docs/reference/extensions/events.rst +++ b/docs/reference/extensions/events.rst @@ -339,9 +339,9 @@ relational and analytical adapters, including: * **ADBC** (:class:`~sqlspec.adapters.adbc.events.AdbcEventQueueStore`) with dialect-aware DDL and index guards for PostgreSQL, SQLite, DuckDB, BigQuery, - Snowflake, Oracle, IBM Db2, and SQL Server. -* **SQL Server** through ``mssql_python``, ``pymssql``, ``arrow_odbc``, and - ``adbc`` (using ``DATETIME2`` timestamps and ``NVARCHAR(MAX)`` JSON payload + Snowflake, Oracle, and IBM Db2. +* **SQL Server** through ``mssql_python``, ``pymssql``, and ``arrow_odbc`` + (using ``DATETIME2(6)`` timestamps and ``NVARCHAR(MAX)`` JSON payload columns). * **IBM Db2** through ``db2``, ``arrow_odbc``, and ``adbc`` (using ``CLOB(1M)`` payloads and ``SYSCAT`` catalog guards). diff --git a/docs/usage/drivers_and_querying.rst b/docs/usage/drivers_and_querying.rst index 8594c1b12..2819c74a0 100644 --- a/docs/usage/drivers_and_querying.rst +++ b/docs/usage/drivers_and_querying.rst @@ -13,7 +13,7 @@ Supported Drivers (High Level) - **MySQL & MariaDB**: aiomysql, asyncmy, mysql-connector, pymysql - **Oracle**: oracledb (sync/async), ADBC - **IBM Db2**: ibm_db (sync/async), ADBC -- **SQL Server**: mssql-python, pymssql, arrow-odbc, ADBC +- **SQL Server**: mssql-python, pymssql, arrow-odbc - **Analytics & Cloud**: DuckDB, BigQuery, Spanner, Snowflake (ADBC), GizmoSQL (ADBC), arrow-odbc Core Execution Pattern diff --git a/docs/usage/frameworks/litestar/session_stores.rst b/docs/usage/frameworks/litestar/session_stores.rst index 542544fca..1c176ebc7 100644 --- a/docs/usage/frameworks/litestar/session_stores.rst +++ b/docs/usage/frameworks/litestar/session_stores.rst @@ -41,7 +41,7 @@ from :class:`~sqlspec.extensions.litestar.store.BaseSQLSpecStore`: - **Spanner**: ``sqlspec.adapters.spanner.litestar.SpannerAsyncStore`` and ``sqlspec.adapters.spanner.litestar.SpannerSyncStore`` - **Oracle**: ``sqlspec.adapters.oracledb.litestar.OracleAsyncStore`` and ``sqlspec.adapters.oracledb.litestar.OracleSyncStore`` - **IBM Db2**: ``sqlspec.adapters.db2.litestar.Db2AsyncStore`` and ``sqlspec.adapters.db2.litestar.Db2SyncStore`` -- **Arrow / ADBC**: ``sqlspec.adapters.adbc.litestar.ADBCStore`` (supports PostgreSQL, SQLite, DuckDB, MySQL/MariaDB, BigQuery, Snowflake, Oracle, IBM Db2, and SQL Server) +- **Arrow / ADBC**: ``sqlspec.adapters.adbc.litestar.ADBCStore`` (supports PostgreSQL, SQLite, DuckDB, MySQL/MariaDB, BigQuery, Snowflake, Oracle, and IBM Db2) Each store can create its session table. It can also add new columns from its own DDL. Set this behavior under ``extension_config["litestar"]``: From 799c64fffea1054f559d21c97622504c931fd46f Mon Sep 17 00:00:00 2001 From: Cody Fincher <204685+cofin@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:21:26 -0500 Subject: [PATCH 4/7] chore(release): bump to v0.67.0, optimize hot-path execution, and update baselines (#838) ## Summary Prepares the `v0.67.0` release, upgrades project dependencies in `uv.lock`, updates Sphinx documentation configuration, optimizes statement compilation, caching, and driver hot-path execution across sync and async adapters, unifies DuckDB row materialization under the native Python type contract, and hardens the adapter and subsystem performance benchmark gates with refreshed baselines. ## Key Changes ### Core Compilation & Statement Caching (`sqlspec/core/`) - **Zero-Copy Cached AST Sharing (`statement.py`, `compiler.py`)**: - Track shared SQLGlot AST expressions on `ProcessedState` (`expression_is_shared=True`) and expose `ProcessedState.ensure_owned_expression()` and `SQL._peek_expression()`. - Defer AST deep-copying to public `SQL.expression` and `SQL.statement_expression` accessors and mutating compile transforms (`_apply_parameter_casts`), eliminating per-call `.copy()` overhead on compile-cache hits, parameter rebinds, and parse-cache lookups. - **Single-Pass Parameter Processing & Precomputed Coercion (`parameters/_processor.py`, `statement.py`, `utils/dispatch.py`)**: - Precompute and memoize type-coercion fallback items (`type_coercion_fallbacks`) on `ParameterProcessor` and cache fallback-tuple lookups in `type_coercion_dispatcher`. - Short-circuit sequence and mapping coercion when no container elements require conversion (`_NO_MATCH` sentinel). - Share a single rebind `ParameterProcessor` instance across cached `SQL` rebinds and construct cached direct `SQL` instances with a single `ProcessedState` allocation (`SQL._create_cached_direct`). - **Lazy Fast-Path Result Statements (`result/_base.py`)**: - Add `SQLResult.create_cached_select` so cached `SELECT` executions defer `SQL` statement construction until `result.statement` is accessed. ### Driver Execution Pipeline (`sqlspec/driver/`, `sqlspec/config.py`) - **Expanded Fast-Path Eligibility (`_common.py`, `_sync.py`, `_async.py`)**: - Add `_resolve_fast_params(parameters, kwargs)` so parameterless (`execute(sql)`) and scalar-positional (`execute(sql, a, b)`) calls route through `_cached_execution` alongside tuple, list, and dict parameters. - Guard `_cache_statement` against caching statements whose `parsed_expression` is `None` or whose `operation_type` is `"COMMAND"` or `"UNKNOWN"`. - Precompute `CachedQuery.needs_style_remap` at cache-store time and skip `StatementFilter` scans when raw parameters are already normalized. - **Per-Config Shared Statement Caches (`_query_cache.py`, `_common.py`, `config.py`)**: - Introduce thread-safe (`threading.RLock`) `QueryCache` and `StatementCacheHolder` owned by the database config and shared across all sessions created with the config's default `StatementConfig`. - **Fast-Path Observability & Inline Exception Isolation (`_common.py`, `_sync.py`, `_async.py`)**: - Keep the statement-cache fast path enabled when spans, statement observers, or lifecycle hooks are active via `_execute_with_observability_sync` and `_execute_with_observability_async`. - Check `sys.exc_info()[1] is None` inline before entering exception handlers on fast paths, invoking generator-isolated `_run_with_sync_exception_handler` / `_run_with_async_exception_handler` only when an ambient exception is active. - Add DB-API cursor fast-path `SELECT` execution (`_execute_cache_hit_with_cursor`) for cursor-based adapters (`cursor is not self.connection`). - **Single-Pass `execute_many` Preparation (`_common.py`)**: - Reuse batch parameters already coerced during `statement.compile()` via `_many_parameters_already_prepared` and copy parameter rows in `_check_declared_parameters` only when declared defaults are applied (`_row_needs_declared_default`). ### Database Adapters (`sqlspec/adapters/`, `sqlspec/extensions/adk/`) - **DuckDB Native Type Contract & Bulk Insert Caching (`duckdb/core.py`, `duckdb/driver.py`)**: - Unify slow-path (`dispatch_execute`) and cache-hit (`_execute_cache_hit_with_cursor`) row materialization in `_materialize_select_rows` so all `execute()`, `select()`, and `fetch*` calls return DuckDB-native `fetchall()` Python types (`HUGEINT`, `UHUGEINT`, `BIT`, `TIMETZ`, `TIMESTAMP_NS`, `INTERVAL`, `MAP`, `BIGNUM`/`VARINT`, fixed-size arrays, and nested `UUID` always materialize via `fetchall()`; compatible schemas use Arrow when observed row counts reach `ARROW_MIN_ROWS = 64`). - Cache parsed `(target_table, column_sql)` fragments in `StatementCacheHolder.duckdb_bulk_sql` for Arrow bulk `execute_many` inserts and replace `contextlib.suppress` in `resolve_rowcount`. - **Aiosqlite Pool & Fast-Path Cursor Execution (`aiosqlite/pool.py`, `aiosqlite/driver.py`, `sqlite/driver.py`)**: - Add `_try_acquire_idle_connection()` to bypass `asyncio.wait_for` when an idle worker connection is immediately available and skip `end_transaction()` on release when `raw_conn.in_transaction` is `False`. - **PostgreSQL, CockroachDB & ADK Store Typing (`asyncpg`, `psqlpy`, `psycopg`, `cockroach_asyncpg`, `cockroach_psycopg`, `extensions/adk/`)**: - Explicitly type `_POSTGRES_METADATA_DOMAINS` and `_COCKROACH_METADATA_DOMAINS` as `tuple[str, ...]` and define concrete `_drop_tables_sql` / `_drop_memory_table_sql` base methods on `_ADKStoreCommon` and `_ADKMemoryStoreCommon`. ### Release, Benchmarks & Documentation (`Makefile`, `tools/`, `docs/`, `pyproject.toml`, `uv.lock`) - **Version, Lockfile & Docs (`pyproject.toml`, `uv.lock`, `docs/changelog.rst`, `docs/conf.py`)**: - Bump version to `0.67.0`, upgrade locked dependencies, update Sphinx configuration for MyST v5.1+, and update `docs/changelog.rst`. - **Benchmark Harness, Gate & Baselines (`Makefile`, `tools/scripts/`, `tools/perf_baselines/`)**: - Enforce workload metadata parity (`rows`, `pool_size`, `mypyc_compiled`) and add `--mode ratio` (`abs-fallback`) and `--print-run-args` to `bench_compare.py`. - Update `make bench-gate-adapters` to derive run arguments from each baseline JSON and evaluate all requested drivers before exiting. - Add `scalar_param_select`, `no_param_select`, `complex_query`, `session_per_request`, and `observability_select` scenarios across `sqlite`, `aiosqlite`, `duckdb`, `asyncpg`, `psycopg_sync`, `psycopg_async`, `psqlpy`, and `oracle`, plus 9 interleaved stub-driver micro-benchmarks in `bench_subsystems.py`. - Re-capture baselines in `tools/perf_baselines/` (`sqlite.json`, `aiosqlite.json`, `duckdb.json`, `subsystems.json`, `oracle.json`, `asyncpg.json`, `psycopg_sync.json`, `psycopg_async.json`, `psqlpy.json`, `postgres.json`, `adbc.json`) and update `tools/perf_baselines/README.md`. ## Verification ```bash make lint make bench-gate-adapters DRIVERS="sqlite aiosqlite duckdb" uv run --no-sync pytest tests/unit -q uv run --no-sync pytest tests/integration/adapters/sqlite tests/integration/adapters/duckdb -q uv run --no-sync python tools/scripts/mypyc_inventory.py --check uv run --no-sync python tools/scripts/mypyc_smoke.py ``` --- Makefile | 20 +- docs/changelog.rst | 68 + docs/conf.py | 4 +- pyproject.toml | 4 +- sqlspec/adapters/aiosqlite/driver.py | 90 +- sqlspec/adapters/aiosqlite/pool.py | 23 +- sqlspec/adapters/asyncpg/data_dictionary.py | 2 +- .../cockroach_asyncpg/data_dictionary.py | 2 +- .../cockroach_psycopg/data_dictionary.py | 2 +- sqlspec/adapters/duckdb/core.py | 76 +- sqlspec/adapters/duckdb/driver.py | 76 +- sqlspec/adapters/psqlpy/data_dictionary.py | 2 +- sqlspec/adapters/psycopg/data_dictionary.py | 2 +- sqlspec/adapters/sqlite/driver.py | 18 +- sqlspec/config.py | 9 + sqlspec/core/compiler.py | 25 +- sqlspec/core/parameters/_processor.py | 92 +- sqlspec/core/result/_base.py | 101 +- sqlspec/core/statement.py | 88 +- sqlspec/driver/_async.py | 261 +- sqlspec/driver/_common.py | 279 +- sqlspec/driver/_query_cache.py | 180 +- sqlspec/driver/_sync.py | 255 +- sqlspec/extensions/adk/memory/store.py | 9 +- sqlspec/extensions/adk/store.py | 9 +- sqlspec/utils/dispatch.py | 2 +- .../adapters/duckdb/duckdb/test_driver.py | 39 + .../unit/adapters/test_aiosqlite/test_pool.py | 211 +- .../unit/adapters/test_duckdb/test_driver.py | 203 + tests/unit/adapters/test_sync_adapters.py | 2 +- tests/unit/core/test_compiler.py | 32 + tests/unit/core/test_statement.py | 69 + tests/unit/driver/test_query_cache.py | 684 +++- .../observability/test_observability_core.py | 36 + tests/unit/tools/test_bench_compare.py | 223 ++ tools/perf_baselines/README.md | 78 +- tools/perf_baselines/adbc.json | 44 +- tools/perf_baselines/aiosqlite.json | 482 ++- tools/perf_baselines/asyncpg.json | 430 +- tools/perf_baselines/duckdb.json | 484 ++- tools/perf_baselines/oracle.json | 452 ++- tools/perf_baselines/postgres.json | 992 ++++- tools/perf_baselines/psqlpy.json | 215 + tools/perf_baselines/psycopg_async.json | 204 +- tools/perf_baselines/psycopg_sync.json | 206 +- tools/perf_baselines/sqlite.json | 626 ++- tools/perf_baselines/subsystems.json | 336 ++ tools/scripts/bench.py | 3504 ++++++++++++----- tools/scripts/bench_compare.py | 393 +- tools/scripts/bench_gate.py | 93 +- tools/scripts/bench_subsystems.py | 676 ++-- uv.lock | 400 +- 52 files changed, 9922 insertions(+), 2891 deletions(-) create mode 100644 tests/unit/adapters/test_duckdb/test_driver.py create mode 100644 tests/unit/tools/test_bench_compare.py create mode 100644 tools/perf_baselines/psqlpy.json create mode 100644 tools/perf_baselines/subsystems.json diff --git a/Makefile b/Makefile index eb8b8270c..6024178bd 100644 --- a/Makefile +++ b/Makefile @@ -346,10 +346,22 @@ bench-gate: ## Run performance regressio DRIVERS ?= duckdb aiosqlite bench-gate-adapters: ## Compare selected adapter benchmarks with committed baselines @mkdir -p /tmp/sqlspec-bench - @for driver in ${DRIVERS}; do \ - uv run python tools/scripts/bench.py --driver "$${driver}" --extended --json-output "/tmp/sqlspec-bench/$${driver}.json"; \ - uv run python tools/scripts/bench_compare.py "tools/perf_baselines/$${driver}.json" "/tmp/sqlspec-bench/$${driver}.json" --threshold "$${BENCHMARK_THRESHOLD:-30}"; \ - done + @failed_drivers=""; \ + for driver in ${DRIVERS}; do \ + baseline="tools/perf_baselines/$${driver}.json"; \ + run_args=$$(uv run --no-sync python tools/scripts/bench_compare.py "$${baseline}" --print-run-args); \ + if ! uv run --no-sync python tools/scripts/bench.py --driver "$${driver}" --extended $${run_args} --json-output "/tmp/sqlspec-bench/$${driver}.json"; then \ + failed_drivers="$${failed_drivers} $${driver}"; \ + continue; \ + fi; \ + if ! uv run --no-sync python tools/scripts/bench_compare.py "$${baseline}" "/tmp/sqlspec-bench/$${driver}.json" --mode "$${BENCHMARK_MODE:-ratio}" --threshold "$${BENCHMARK_THRESHOLD:-30}"; then \ + failed_drivers="$${failed_drivers} $${driver}"; \ + fi; \ + done; \ + if [ -n "$${failed_drivers}" ]; then \ + echo "Adapter benchmark gate failed for:$${failed_drivers}"; \ + exit 1; \ + fi .PHONY: bench-subsystems bench-subsystems: ## Run subsystem micro-benchmarks diff --git a/docs/changelog.rst b/docs/changelog.rst index 926f49291..4f0e3c321 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,74 @@ important operational fixes. Recent Updates ============== +v0.67.0 - Spanner graph and hybrid search, ADK artifact stores, and Python 3.11+ baseline +----------------------------------------------------------------------------------------- + +**Added:** + +* Spanner and Spangres SQLGlot dialects support ``CREATE PROPERTY GRAPH`` and + ``CREATE OR REPLACE PROPERTY GRAPH`` DDL (with ``NODE TABLES``, ``EDGE TABLES``, + ``SOURCE KEY``, ``DESTINATION KEY``, ``LABEL``, and ``PROPERTIES`` clauses) as + well as ``GRAPH_TABLE`` queries with ``MATCH`` path patterns and ``COLUMNS`` + projections. + (`#834 `_) +* Added ``sql.hybrid_rrf(...)`` on the ``SQL`` builder factory to generate + Reciprocal Rank Fusion (RRF) hybrid search queries combining full-text BM25 + scoring and vector distance ranking on Spanner and Spangres. + (`#834 `_) +* Added Google ADK artifact store base classes (``BaseAsyncADKArtifactStore`` + and ``BaseSyncADKArtifactStore``) and Spanner implementations + (``SpannerSyncADKArtifactStore`` and ``SpannerAsyncADKArtifactStore``) with + optional Cloud Storage URI offloading for large payloads. + (`#834 `_) +* Spanner event queue stores add a composite queue index + ``(channel, status, available_at)`` with ``FORCE_INDEX`` query hints, row + deletion TTL policies, and single-statement ``THEN RETURN`` claim execution. + (`#834 `_) +* Added Python 3.15 support across package metadata and CI test workflows. + (`#835 `_) +* Added pure-Go GizmoData ADBC driver support in ``sqlspec.adapters.adbc`` and + extension stores (``ADBCStore``, ``AdbcEventQueueStore``, ``AdbcADKStore``, + and ``AdbcADKMemoryStore``) for Oracle (``adbc-driver-oracle``), IBM Db2 + (``adbc-driver-db2``), native GizmoSQL (``adbc-driver-gizmosql`` with + ``auth_type="external"`` OAuth SSO), and DuckDB Quack (``adbc-driver-quack``). + (`#839 `_) + +**Changed:** + +* Upgraded the minimum supported Python version to 3.11+ and modernized runtime + typing, ``datetime.UTC``, and ``asyncio.timeout`` usage across the codebase. + (`#835 `_) +* Standardized driver session lifecycles, statement compilation caching, SQLGlot + builder immutability, migration version tracking, and ADK/Litestar/Events + store contracts across all 20 database adapters. + (`#837 `_) +* Unified DuckDB row materialization across ``execute()``, ``select()``, and + ``fetch*`` methods to consistently return DuckDB-native ``fetchall()`` Python + types on both first and cached executions, while ``select_to_arrow()`` + continues to expose raw Arrow tables. + (`#838 `_) +* Reduced per-statement and per-session execution overhead with per-config shared + statement caches (``StatementCacheHolder``), zero-copy cached SQLGlot AST + sharing with copy-on-mutation via ``ensure_owned_expression()``, fast-path + execution for parameterless and scalar-positional calls, fast-path execution + when statement observers are active, lazy ``SQLResult.statement`` + materialization on cached ``SELECT`` hits, single-pass ``execute_many()`` + parameter preparation, and idle-queue/clean-transaction fast paths in + ``AiosqliteConnectionPool``. + (`#838 `_) + +**Fixed:** + +* Hardened parameter conversion, arrow batch streaming, transaction rollback and + cleanup handling, exception mapping, and dialect generation across core, + storage, migrations, extensions, and database adapters. + (`#837 `_) +* Prevented unparsed or ``COMMAND``/``UNKNOWN`` fallback compilations from + populating the driver statement cache, and cached parsed target-table metadata + on DuckDB Arrow bulk ``execute_many()`` inserts. + (`#838 `_) + v0.66.0 - Native AsyncIO Spanner adapter, extension stores, and sync/async parity --------------------------------------------------------------------------------- diff --git a/docs/conf.py b/docs/conf.py index e3603299e..d7b7e6f96 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -65,7 +65,6 @@ "auto_pytabs.sphinx_ext", "sphinx_copybutton", "sphinx_click", - "click_extra.sphinx", "sphinx_design", "sphinx_paramlinks", "sphinxcontrib.mermaid", @@ -73,6 +72,7 @@ "sphinx_iconify", "tools.sphinx_ext.playground", ] +myst_enable_extensions = ["alert", "colon_fence"] intersphinx_mapping = { "python": ("https://docs.python.org/3", None), "litestar": ("https://docs.litestar.dev/latest/", None), @@ -80,7 +80,7 @@ "anyio": ("https://anyio.readthedocs.io/en/stable/", None), "multidict": ("https://multidict.aio-libs.org/en/stable/", None), "cryptography": ("https://cryptography.io/en/latest/", None), - "pydantic": ("https://docs.pydantic.dev/latest/", None), + "pydantic": ("https://pydantic.dev/docs/validation/latest/", None), "sanic": ("https://sanic.readthedocs.io/en/latest/", None), "flask": ("https://flask.palletsprojects.com/en/stable/", None), "fastapi": ("https://fastapi.tiangolo.com/", None), diff --git a/pyproject.toml b/pyproject.toml index 663a16f59..f1265960b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,7 +23,7 @@ maintainers = [{ name = "Litestar Developers", email = "hello@litestar.dev" }] name = "sqlspec" readme = "README.md" requires-python = ">=3.11, <4.0" -version = "0.66.0" +version = "0.67.0" [project.urls] Discord = "https://discord.gg/litestar" @@ -336,7 +336,7 @@ opt_level = "3" # Maximum optimization (0-3) allow_dirty = true commit = false commit_args = "--no-verify" -current_version = "0.66.0" +current_version = "0.67.0" ignore_missing_files = false ignore_missing_version = false message = "chore(release): bump to v{new_version}" diff --git a/sqlspec/adapters/aiosqlite/driver.py b/sqlspec/adapters/aiosqlite/driver.py index d41d3963c..fc90c5277 100644 --- a/sqlspec/adapters/aiosqlite/driver.py +++ b/sqlspec/adapters/aiosqlite/driver.py @@ -133,7 +133,7 @@ async def dispatch_execute(self, cursor: "AiosqliteRawCursor", statement: "SQL") sql, normalized_parameters, statement.operation_type, - statement.expression, + statement._peek_expression(), self._rowid_target_cache, ) data, column_names, row_count = collect_rows(fetched_data, description) @@ -156,7 +156,7 @@ async def dispatch_execute(self, cursor: "AiosqliteRawCursor", statement: "SQL") sql, normalized_parameters, statement.operation_type, - statement.expression, + statement._peek_expression(), self._rowid_target_cache, ) return self.create_execution_result(cursor, rowcount_override=affected_rows, last_inserted_id=last_inserted_id) @@ -389,56 +389,48 @@ async def _execute_cache_hit( ) -> "SQLResult": """Execute cached queries through the async cursor fast path.""" normalized_parameters = normalize_execute_parameters(params) - direct_statement: SQL | None = None exc_handler = self.handle_database_exceptions() result: SQLResult | None = None self._invalidate_rowid_target_cache(cached.operation_type) - try: - async with exc_handler: - if cached.operation_profile.returns_rows: - fetched_data, description, _affected_rows, last_inserted_id = await run_on_worker_thread( - self.connection, - _execute_fetchall_with_metadata, - self.connection, - cached.compiled_sql, - normalized_parameters, - cached.operation_type, - cached.processed_state.parsed_expression, - self._rowid_target_cache, - ) - data, column_names, row_count = collect_rows(fetched_data, description) - execution_result = self.create_execution_result( - self.connection, - selected_data=data, - column_names=column_names, - data_row_count=row_count, - is_select_result=True, - row_format=resolve_row_format(data), - last_inserted_id=last_inserted_id, - ) - direct_statement = self._cached_statement( - sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql - ) - result = self.build_statement_result(direct_statement, execution_result) - else: - affected_rows, last_inserted_id = await run_on_worker_thread( - self.connection, - _execute_and_resolve_metadata, - self.connection, - cached.compiled_sql, - normalized_parameters, - cached.operation_type, - cached.processed_state.parsed_expression, - self._rowid_target_cache, - ) - result = DMLResult(cached.operation_type, affected_rows, last_inserted_id) - - self._check_pending_exception(exc_handler) - assert result is not None - return result - finally: - if direct_statement is not None: - self._release_pooled_statement(direct_statement) + async with exc_handler: + if cached.operation_profile.returns_rows: + fetched_data, description, _affected_rows, last_inserted_id = await run_on_worker_thread( + self.connection, + _execute_fetchall_with_metadata, + self.connection, + cached.compiled_sql, + normalized_parameters, + cached.operation_type, + cached.processed_state.parsed_expression, + self._rowid_target_cache, + ) + data, column_names, row_count = collect_rows(fetched_data, description) + result = self.build_cached_select_result( + sql, + params, + cached, + selected_data=data, + column_names=column_names, + data_row_count=row_count, + row_format=resolve_row_format(data), + last_inserted_id=last_inserted_id, + ) + else: + affected_rows, last_inserted_id = await run_on_worker_thread( + self.connection, + _execute_and_resolve_metadata, + self.connection, + cached.compiled_sql, + normalized_parameters, + cached.operation_type, + cached.processed_state.parsed_expression, + self._rowid_target_cache, + ) + result = DMLResult(cached.operation_type, affected_rows, last_inserted_id) + + self._check_pending_exception(exc_handler) + assert result is not None + return result def _invalidate_rowid_target_cache(self, operation_type: "OperationType") -> None: if operation_type not in {"SELECT", "INSERT", "UPDATE", "DELETE"}: diff --git a/sqlspec/adapters/aiosqlite/pool.py b/sqlspec/adapters/aiosqlite/pool.py index 72dfb9f67..2c166cc9f 100644 --- a/sqlspec/adapters/aiosqlite/pool.py +++ b/sqlspec/adapters/aiosqlite/pool.py @@ -382,6 +382,9 @@ async def acquire(self) -> AiosqlitePoolConnection: Raises: AiosqliteConnectTimeoutError: If acquisition times out """ + fast_connection = self._try_acquire_idle_connection() + if fast_connection is not None: + return fast_connection try: connection = await asyncio.wait_for(self._get_connection(), timeout=self._connect_timeout) except AiosqlitePoolClosedError: @@ -414,8 +417,10 @@ async def release(self, connection: AiosqlitePoolConnection) -> None: return try: - with suppress(Exception): - await end_transaction(connection.connection, commit=False) + raw_conn = connection.connection + if getattr(raw_conn, "in_transaction", False): + with suppress(Exception): + await end_transaction(raw_conn, commit=False) connection.idle_since = time.time() self._queue.put_nowait(connection) except Exception as e: @@ -796,6 +801,20 @@ async def _warm_pool(self) -> None: async with self._lock: self._in_flight_creations -= connections_needed + def _try_acquire_idle_connection(self) -> "AiosqlitePoolConnection | None": + """Synchronously claim a warm, recently-idle connection when available.""" + if self._closed_event.is_set() or (not self._warmed and self._min_size > 0) or self._queue.empty(): + return None + connection = self._queue.get_nowait() + idle_since = connection.idle_since + if idle_since is not None and connection.is_healthy: + idle_time = time.time() - idle_since + if idle_time <= self._health_check_interval and idle_time <= self._idle_timeout: + connection.idle_since = None + return connection + self._queue.put_nowait(connection) + return None + async def _get_connection(self) -> AiosqlitePoolConnection: """Run the three-phase connection acquisition cycle. diff --git a/sqlspec/adapters/asyncpg/data_dictionary.py b/sqlspec/adapters/asyncpg/data_dictionary.py index 9077b2fc6..33674c6af 100644 --- a/sqlspec/adapters/asyncpg/data_dictionary.py +++ b/sqlspec/adapters/asyncpg/data_dictionary.py @@ -36,7 +36,7 @@ __all__ = ("AsyncpgDataDictionary",) -_POSTGRES_METADATA_DOMAINS = ( +_POSTGRES_METADATA_DOMAINS: tuple[str, ...] = ( "schemas", "objects", "tables", diff --git a/sqlspec/adapters/cockroach_asyncpg/data_dictionary.py b/sqlspec/adapters/cockroach_asyncpg/data_dictionary.py index 1de19ecfb..1335a2f56 100644 --- a/sqlspec/adapters/cockroach_asyncpg/data_dictionary.py +++ b/sqlspec/adapters/cockroach_asyncpg/data_dictionary.py @@ -38,7 +38,7 @@ __all__ = ("CockroachAsyncpgDataDictionary",) -_COCKROACH_METADATA_DOMAINS = ( +_COCKROACH_METADATA_DOMAINS: tuple[str, ...] = ( "schemas", "objects", "tables", diff --git a/sqlspec/adapters/cockroach_psycopg/data_dictionary.py b/sqlspec/adapters/cockroach_psycopg/data_dictionary.py index c58d27320..7891bb7c1 100644 --- a/sqlspec/adapters/cockroach_psycopg/data_dictionary.py +++ b/sqlspec/adapters/cockroach_psycopg/data_dictionary.py @@ -38,7 +38,7 @@ __all__ = ("CockroachPsycopgAsyncDataDictionary", "CockroachPsycopgSyncDataDictionary") -_COCKROACH_METADATA_DOMAINS = ( +_COCKROACH_METADATA_DOMAINS: tuple[str, ...] = ( "schemas", "objects", "tables", diff --git a/sqlspec/adapters/duckdb/core.py b/sqlspec/adapters/duckdb/core.py index d8c756588..69ffcdfd5 100644 --- a/sqlspec/adapters/duckdb/core.py +++ b/sqlspec/adapters/duckdb/core.py @@ -1,6 +1,7 @@ """DuckDB adapter compiled helpers.""" import contextlib +import re from datetime import date, datetime from decimal import Decimal from typing import TYPE_CHECKING, Any, Final, cast @@ -46,12 +47,13 @@ from sqlspec.utils.uuids import uuid_from_string if TYPE_CHECKING: - from collections.abc import Callable, Mapping + from collections.abc import Callable, Mapping, Sequence from sqlspec.storage import StorageDestination, SyncStoragePipeline __all__ = ( + "ARROW_MIN_ROWS", "apply_driver_features", "build_connection_config", "build_profile", @@ -59,12 +61,56 @@ "collect_rows", "create_mapped_exception", "default_statement_config", + "description_has_arrow_divergent_types", "driver_profile", "format_identifier", "normalize_execute_parameters", "resolve_rowcount", ) +ARROW_MIN_ROWS: Final[int] = 64 + +_FIXED_ARRAY_PATTERN: Final[re.Pattern[str]] = re.compile(r"\[\d+\]") +_ARROW_SAFE_SCALAR_TYPES: Final[frozenset[str]] = frozenset({ + "INTEGER", + "BIGINT", + "VARCHAR", + "DOUBLE", + "BOOLEAN", + "DATE", + "TIMESTAMP", + "SMALLINT", + "TINYINT", + "UBIGINT", + "UINTEGER", + "USMALLINT", + "UTINYINT", + "FLOAT", + "BLOB", + "UUID", + "TIME", + "TIMESTAMP WITH TIME ZONE", + "TIMESTAMPTZ", + "TIMESTAMP_S", + "TIMESTAMP_MS", + "JSON", +}) + + +def description_has_arrow_divergent_types(description: "Sequence[Any] | None") -> bool: + """Return whether any column in ``cursor.description`` diverges between Arrow and ``fetchall()``.""" + if not description: + return False + for column in description: + if len(column) <= 1 or column[1] is None: + continue + type_str = str(column[1]).upper() + if type_str in _ARROW_SAFE_SCALAR_TYPES: + continue + if _type_str_is_arrow_divergent(type_str): + return True + return False + def format_identifier(identifier: str) -> str: """Format an identifier with safe double-quote escaping. @@ -178,10 +224,15 @@ def resolve_rowcount(cursor: Any) -> int: Returns: Rowcount value derived from cursor output. """ - with contextlib.suppress(Exception): + try: result = cursor.fetchone() - if result and isinstance(result, tuple) and len(result) == 1: + except Exception: + result = None + if result and isinstance(result, tuple) and len(result) == 1: + try: return int(result[0]) + except (TypeError, ValueError): + result = None if has_rowcount(cursor): return max(cursor.rowcount, 0) return 0 @@ -717,6 +768,25 @@ def _restore_uuid_columns(rows: "list[dict[str, Any]]", description: "list[Any] row[column] = uuid_from_string(value) +def _type_str_is_arrow_divergent(type_str: str) -> bool: + if ( + "HUGEINT" in type_str + or "TIMETZ" in type_str + or "TIME WITH TIME ZONE" in type_str + or "TIMESTAMP_NS" in type_str + or "INTERVAL" in type_str + or "MAP(" in type_str + or "BIGNUM" in type_str + or "VARINT" in type_str + ): + return True + if type_str == "BIT" or "BIT[" in type_str or " BIT" in type_str or type_str.startswith("BIT "): + return True + if "UUID" in type_str and type_str != "UUID": + return True + return "[" in type_str and _FIXED_ARRAY_PATTERN.search(type_str) is not None + + driver_profile = build_profile() default_statement_config = build_statement_config() diff --git a/sqlspec/adapters/duckdb/driver.py b/sqlspec/adapters/duckdb/driver.py index 3c24579ea..6194f7a3f 100644 --- a/sqlspec/adapters/duckdb/driver.py +++ b/sqlspec/adapters/duckdb/driver.py @@ -9,6 +9,7 @@ from sqlspec.adapters.duckdb._typing import DuckDBCursor, DuckDBSessionContext from sqlspec.adapters.duckdb._typing import duckdb_module as duckdb from sqlspec.adapters.duckdb.core import ( + ARROW_MIN_ROWS, _build_storage_copy_sql, _build_storage_read_sql, _DuckDBStreamSource, @@ -17,6 +18,7 @@ collect_rows, create_mapped_exception, default_statement_config, + description_has_arrow_divergent_types, driver_profile, format_identifier, normalize_execute_parameters, @@ -51,8 +53,9 @@ from sqlspec.builder import QueryBuilder from sqlspec.core import ArrowResult, SQLResult, Statement, StatementFilter from sqlspec.driver import ExecutionResult + from sqlspec.driver._query_cache import CachedQuery from sqlspec.storage import StorageBridgeJob, StorageDestination, StorageFormat, StorageTelemetry - from sqlspec.typing import ArrowReturnFormat, StatementParameters + from sqlspec.typing import ArrowReturnFormat, RowFormat, StatementParameters else: pa = import_optional("pyarrow") @@ -143,18 +146,14 @@ def dispatch_execute(self, cursor: "DuckDBConnection", statement: SQL) -> "Execu is_select_like = statement.returns_rows() or self._should_force_select(statement, cursor) if is_select_like: - arrow_table = cursor.to_arrow_table() - data = arrow_table.to_pylist() - _restore_uuid_columns(data, cursor.description) - column_names = list(arrow_table.column_names) - + data, column_names, row_count, row_format = self._materialize_select_rows(cursor, sql) return self.create_execution_result( cursor, selected_data=data, column_names=column_names, - data_row_count=len(data), + data_row_count=row_count, is_select_result=True, - row_format="dict", + row_format=row_format, ) row_count = resolve_rowcount(cursor) @@ -179,8 +178,9 @@ def dispatch_execute_many(self, cursor: "DuckDBConnection", statement: SQL) -> " sql = cached_statement.compiled_sql if prepared_parameters: - parsed_expression = cached_statement.expression or statement.expression + parsed_expression = cached_statement.expression or statement._peek_expression() if isinstance(parsed_expression, exp.Insert) and not parsed_expression.args.get("returning"): + parsed_expression.meta["compiled_sql"] = sql bulk_result = self._execute_bulk_insert_many(parsed_expression, prepared_parameters) if bulk_result is not None: return self.create_execution_result( @@ -559,6 +559,49 @@ def resolve_rowcount(self, cursor: "DuckDBConnection") -> int: """Resolve rowcount from DuckDB cursor for the direct execution path.""" return resolve_rowcount(cursor) + def _execute_cache_hit_with_cursor( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" + ) -> "SQLResult": + """Execute a cached query using DuckDB's unified row materializer.""" + with self.with_cursor(self.connection) as cursor: + cursor.execute(cached.compiled_sql, normalize_execute_parameters(params)) + if cached.operation_profile.returns_rows: + data, column_names, row_count, row_format = self._materialize_select_rows(cursor, cached.compiled_sql) + return self.build_cached_select_result( + sql, + params, + cached, + selected_data=data, + column_names=column_names, + data_row_count=row_count, + row_format=row_format, + ) + affected_rows = resolve_rowcount(cursor) + return DMLResult(cached.operation_type, affected_rows) + + def _materialize_select_rows(self, cursor: Any, compiled_sql: str) -> "tuple[list[Any], list[str], int, RowFormat]": + """Materialize SELECT rows using DuckDB's native Python type contract.""" + description = cursor.description + hint = self._cache_holder.get_row_count_hint(compiled_sql) + if ( + (hint is not None and hint < ARROW_MIN_ROWS) + or description_has_arrow_divergent_types(description) + or not hasattr(cursor, "to_arrow_table") + ): + fetched_data = cursor.fetchall() + data, column_names = collect_rows(fetched_data, description) + row_count = len(data) + self._cache_holder.set_row_count_hint(compiled_sql, row_count) + return data, column_names, row_count, "tuple" + + arrow_table = cursor.to_arrow_table() + data = arrow_table.to_pylist() + _restore_uuid_columns(data, description) + column_names = list(arrow_table.column_names) + row_count = len(data) + self._cache_holder.set_row_count_hint(compiled_sql, row_count) + return data, column_names, row_count, "dict" + def _native_storage_job( self, sql: str, @@ -607,7 +650,9 @@ def _execute_storage_sql(self, sql: str, parameters: "list[Any]") -> int: raise SQLSpecError(msg) return int(result[0]) - def _execute_bulk_insert_many(self, expression: exp.Insert, prepared_parameters: Any) -> "DMLResult | None": + def _execute_bulk_insert_many( + self, expression: exp.Insert, prepared_parameters: Any, compiled_sql: str = "" + ) -> "DMLResult | None": """Execute a batch INSERT via Arrow registration when the payload is simple.""" if not isinstance(prepared_parameters, list) or not prepared_parameters: return None @@ -632,8 +677,15 @@ def _execute_bulk_insert_many(self, expression: exp.Insert, prepared_parameters: if arrow_table is None: return None - target_table = table_expr.sql(dialect="duckdb") - column_sql = ", ".join(column.sql(dialect="duckdb") for column in expression.this.expressions) + cache_key = compiled_sql or (str(expression._meta.get("compiled_sql", "")) if expression._meta else "") + cached_sql_pair = self._cache_holder.get_bulk_insert_sql(cache_key) if cache_key else None + if cached_sql_pair is not None: + target_table, column_sql = cached_sql_pair + else: + target_table = table_expr.sql(dialect="duckdb") + column_sql = ", ".join(column.sql(dialect="duckdb") for column in expression.this.expressions) + if cache_key: + self._cache_holder.set_bulk_insert_sql(cache_key, (target_table, column_sql)) temp_view = f"_sqlspec_batch_{uuid4().hex}" exc_handler = self.handle_database_exceptions() with exc_handler: diff --git a/sqlspec/adapters/psqlpy/data_dictionary.py b/sqlspec/adapters/psqlpy/data_dictionary.py index 41e0d060f..1f76ef6f2 100644 --- a/sqlspec/adapters/psqlpy/data_dictionary.py +++ b/sqlspec/adapters/psqlpy/data_dictionary.py @@ -38,7 +38,7 @@ __all__ = ("PsqlpyDataDictionary",) -_POSTGRES_METADATA_DOMAINS = ( +_POSTGRES_METADATA_DOMAINS: tuple[str, ...] = ( "schemas", "objects", "tables", diff --git a/sqlspec/adapters/psycopg/data_dictionary.py b/sqlspec/adapters/psycopg/data_dictionary.py index 2bcf3ee5b..2a2a3390b 100644 --- a/sqlspec/adapters/psycopg/data_dictionary.py +++ b/sqlspec/adapters/psycopg/data_dictionary.py @@ -38,7 +38,7 @@ __all__ = ("PsycopgAsyncDataDictionary", "PsycopgSyncDataDictionary") -_POSTGRES_METADATA_DOMAINS = ( +_POSTGRES_METADATA_DOMAINS: tuple[str, ...] = ( "schemas", "objects", "tables", diff --git a/sqlspec/adapters/sqlite/driver.py b/sqlspec/adapters/sqlite/driver.py index 25e487fb1..a9a24ad90 100644 --- a/sqlspec/adapters/sqlite/driver.py +++ b/sqlspec/adapters/sqlite/driver.py @@ -146,7 +146,7 @@ def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": cursor, statement.operation_type, effective_rowcount, - statement.expression, + statement._peek_expression(), self._rowid_target_cache, ) @@ -166,7 +166,7 @@ def dispatch_execute(self, cursor: Any, statement: "SQL") -> "ExecutionResult": cursor, statement.operation_type, affected_rows, - statement.expression, + statement._peek_expression(), self._rowid_target_cache, ) return self.create_execution_result(cursor, rowcount_override=affected_rows, last_inserted_id=last_inserted_id) @@ -451,7 +451,6 @@ def _execute_cache_hit( statements while preserving driver exception mapping behavior. """ cursor = None - direct_statement: SQL | None = None returns_rows = cached.operation_profile.returns_rows self._invalidate_rowid_target_cache(cached.operation_type) try: @@ -489,25 +488,20 @@ def _execute_cache_hit( description = cursor.description column_names = [col[0] for col in description] if description else [] row_format = resolve_row_format(fetched_data) - execution_result = self.create_execution_result( - cursor, + return self.build_cached_select_result( + sql, + params, + cached, selected_data=fetched_data, column_names=column_names, data_row_count=len(fetched_data), - is_select_result=True, row_format=row_format, last_inserted_id=last_inserted_id, ) - direct_statement = self._cached_statement( - sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql - ) - return self.build_statement_result(direct_statement, execution_result) finally: if cursor is not None: with contextlib.suppress(Exception): cursor.close() - if direct_statement is not None: - self._release_pooled_statement(direct_statement) def _invalidate_rowid_target_cache(self, operation_type: "OperationType") -> None: if operation_type not in {"SELECT", "INSERT", "UPDATE", "DELETE"}: diff --git a/sqlspec/config.py b/sqlspec/config.py index 8e33dfdbd..86cc89238 100644 --- a/sqlspec/config.py +++ b/sqlspec/config.py @@ -28,6 +28,7 @@ create_sync_pool, seed_runtime_driver_features, ) +from sqlspec.driver._query_cache import StatementCacheHolder, resolve_statement_cache_size from sqlspec.exceptions import ImproperConfigurationError, MissingDependencyError from sqlspec.extensions.events import EventRuntimeHints from sqlspec.loader import SQLFileLoader @@ -1351,6 +1352,11 @@ def _prepare_driver(self, driver: DriverT) -> DriverT: """Attach observability runtime to driver instances before returning them.""" driver.attach_observability(self.get_observability_runtime()) + if ( + driver.statement_config is self.statement_config + and self.driver_features.get("_statement_cache_holder") is not driver._cache_holder + ): + self.driver_features["_statement_cache_holder"] = driver._cache_holder return driver @staticmethod @@ -1438,6 +1444,9 @@ def _init_config_state( self.statement_config = statement_config or build_default_statement_config(default_dialect) self._storage_capabilities = None self.driver_features = seed_runtime_driver_features(driver_features, self.storage_capabilities()) + self.driver_features["_statement_cache_holder"] = StatementCacheHolder( + self.statement_config, resolve_statement_cache_size(self.driver_features) + ) self._attach_lifecycle_hooks() self._configure_observability_extensions() diff --git a/sqlspec/core/compiler.py b/sqlspec/core/compiler.py index aedc4a8e1..fddba3b28 100644 --- a/sqlspec/core/compiler.py +++ b/sqlspec/core/compiler.py @@ -503,23 +503,8 @@ def _apply_dynamic_sqlcommenter(self, result: CompiledSQL) -> CompiledSQL: def _materialize_cached_result(self, cached_result: CompiledSQL, parameters: Any, is_many: bool) -> CompiledSQL: """Return cached compilation metadata with parameters for the current call.""" - cloned_expr = cached_result.expression.copy() if cached_result.expression is not None else None if self._parameter_config.needs_static_script_compilation: - if cloned_expr is None: - return cached_result - return CompiledSQL( - compiled_sql=cached_result.compiled_sql, - execution_parameters=cached_result.execution_parameters, - operation_type=cached_result.operation_type, - expression=cloned_expr, - parameter_style=cached_result.parameter_style, - supports_many=cached_result.supports_many, - parameter_casts=cached_result.parameter_casts, - parameter_profile=cached_result.parameter_profile, - operation_profile=cached_result.operation_profile, - input_named_parameters=cached_result.input_named_parameters, - applied_wrap_types=cached_result.applied_wrap_types, - ) + return cached_result processed_params = self._parameter_processor.transform_cached_parameters( parameters, @@ -539,7 +524,7 @@ def _materialize_cached_result(self, cached_result: CompiledSQL, parameters: Any compiled_sql=final_sql, execution_parameters=processed_params, operation_type=cached_result.operation_type, - expression=cloned_expr, + expression=cached_result.expression, parameter_style=cached_result.parameter_style, supports_many=cached_result.supports_many, parameter_casts=cached_result.parameter_casts, @@ -638,11 +623,7 @@ def _unpack_parse_cache_entry( """ cached_expression, cached_operation, cached_profile = parse_cache_entry operation_profile = OperationProfile(returns_rows=cached_profile[0], modifies_rows=cached_profile[1]) - return ( - cached_expression.copy() if cached_expression is not None else None, - cached_operation, - operation_profile, - ) + return (cached_expression, cached_operation, operation_profile) def _resolve_expression( self, sqlglot_sql: str, dialect_str: "str | None", expression_override: "exp.Expr | None" diff --git a/sqlspec/core/parameters/_processor.py b/sqlspec/core/parameters/_processor.py index eedfd26be..e5d5ab58f 100644 --- a/sqlspec/core/parameters/_processor.py +++ b/sqlspec/core/parameters/_processor.py @@ -30,7 +30,7 @@ ) from sqlspec.core.parameters._validator import ParameterValidator, unescape_qmark_operator from sqlspec.exceptions import SQLSpecError -from sqlspec.utils.dispatch import TypeDispatcher +from sqlspec.utils.dispatch import _NO_MATCH, TypeDispatcher _OCCURRENCE_KEYED_STYLE_VALUES: "frozenset[str]" = frozenset({ ParameterStyle.QMARK.value, @@ -50,8 +50,15 @@ _EXECUTE_MANY_SAMPLE_THRESHOLD: Final[int] = 10 _EXECUTE_MANY_SAMPLE_SIZE: Final[int] = 3 +_FALLBACK_CACHE_MAX_SIZE: Final[int] = 256 _TYPE_COERCION_DISPATCHERS: Final[dict[tuple[TypeCoercionFallback, ...], TypeDispatcher[Callable[[Any], Any]]]] = {} +_TYPE_COERCION_DISPATCHERS_BY_ID: Final[ + dict[int, tuple[tuple[TypeCoercionFallback, ...], TypeDispatcher[Callable[[Any], Any]]]] +] = {} +_TYPE_COERCION_FALLBACKS_CACHE: Final[ + dict[int, tuple[dict[type, Callable[[Any], Any]], int, tuple[TypeCoercionFallback, ...]]] +] = {} def structural_fingerprint(parameters: "ParameterPayload", is_many: bool = False) -> Any: @@ -954,13 +961,24 @@ def type_coercion_dispatcher( fallback_items: "tuple[TypeCoercionFallback, ...]", ) -> "TypeDispatcher[Callable[[Any], Any]]": """Return the process-wide dispatcher registered for the fallback items.""" + items_id = id(fallback_items) + cached_by_id = _TYPE_COERCION_DISPATCHERS_BY_ID.get(items_id) + if cached_by_id is not None and cached_by_id[0] is fallback_items: + return cached_by_id[1] + dispatcher = _TYPE_COERCION_DISPATCHERS.get(fallback_items) if dispatcher is not None: + if len(_TYPE_COERCION_DISPATCHERS_BY_ID) >= _FALLBACK_CACHE_MAX_SIZE: + _TYPE_COERCION_DISPATCHERS_BY_ID.clear() + _TYPE_COERCION_DISPATCHERS_BY_ID[items_id] = (fallback_items, dispatcher) return dispatcher dispatcher = TypeDispatcher["Callable[[Any], Any]"]() dispatcher.register_all(fallback_items) _TYPE_COERCION_DISPATCHERS[fallback_items] = dispatcher + if len(_TYPE_COERCION_DISPATCHERS_BY_ID) >= _FALLBACK_CACHE_MAX_SIZE: + _TYPE_COERCION_DISPATCHERS_BY_ID.clear() + _TYPE_COERCION_DISPATCHERS_BY_ID[items_id] = (fallback_items, dispatcher) return dispatcher @@ -970,7 +988,16 @@ def type_coercion_fallbacks( """Return the ordered fallback items for a coercion map, empty when none is configured.""" if not type_coercion_map: return () - return tuple(type_coercion_map.items()) + map_id = id(type_coercion_map) + map_len = len(type_coercion_map) + cached = _TYPE_COERCION_FALLBACKS_CACHE.get(map_id) + if cached is not None and cached[0] is type_coercion_map and cached[1] == map_len: + return cached[2] + if len(_TYPE_COERCION_FALLBACKS_CACHE) >= _FALLBACK_CACHE_MAX_SIZE: + _TYPE_COERCION_FALLBACKS_CACHE.clear() + items = tuple(type_coercion_map.items()) + _TYPE_COERCION_FALLBACKS_CACHE[map_id] = (type_coercion_map, map_len, items) + return items def apply_type_coercion( @@ -1041,9 +1068,22 @@ def _coerce_sequence_if_needed( seq_value: "Sequence[Any]", type_coercion_map: "dict[type, Callable[[Any], Any]]", fallback_items: "tuple[TypeCoercionFallback, ...]", + dispatcher: "TypeDispatcher[Callable[[Any], Any]] | None" = None, ) -> "Sequence[Any] | list[Any]": + if dispatcher is None and fallback_items: + dispatcher = type_coercion_dispatcher(fallback_items) + dispatcher_cache = dispatcher._cache if dispatcher is not None else None updated_seq: list[Any] | None = None for idx, item in enumerate(seq_value): + if updated_seq is None: + if item is None: + continue + item_type = type(item) + if item_type is not TypedParameter and item_type not in type_coercion_map: + if dispatcher_cache is None or dispatcher_cache.get(item_type) is _NO_MATCH: + continue + if dispatcher is not None and dispatcher.resolve_type(item_type) is None: + continue coerced_value = _coerce_parameter_value(item, type_coercion_map, fallback_items) if updated_seq is None: if coerced_value is item: @@ -1059,9 +1099,22 @@ def _coerce_mapping_if_needed( mapping: "Mapping[Any, Any]", type_coercion_map: "dict[type, Callable[[Any], Any]]", fallback_items: "tuple[TypeCoercionFallback, ...]", + dispatcher: "TypeDispatcher[Callable[[Any], Any]] | None" = None, ) -> "Mapping[Any, Any] | dict[Any, Any]": + if dispatcher is None and fallback_items: + dispatcher = type_coercion_dispatcher(fallback_items) + dispatcher_cache = dispatcher._cache if dispatcher is not None else None updated_mapping: dict[Any, Any] | None = None for key, val in mapping.items(): + if updated_mapping is None: + if val is None: + continue + val_type = type(val) + if val_type is not TypedParameter and val_type not in type_coercion_map: + if dispatcher_cache is None or dispatcher_cache.get(val_type) is _NO_MATCH: + continue + if dispatcher is not None and dispatcher.resolve_type(val_type) is None: + continue coerced_value = _coerce_parameter_value(val, type_coercion_map, fallback_items) if updated_mapping is None: if coerced_value is val: @@ -1077,26 +1130,29 @@ def _coerce_parameter_set( param_set: object, type_coercion_map: "dict[type, Callable[[Any], Any]]", fallback_items: "tuple[TypeCoercionFallback, ...]", + dispatcher: "TypeDispatcher[Callable[[Any], Any]] | None" = None, ) -> object: param_type = type(param_set) if param_type is list: - return _coerce_sequence_if_needed(cast("list[Any]", param_set), type_coercion_map, fallback_items) + return _coerce_sequence_if_needed(cast("list[Any]", param_set), type_coercion_map, fallback_items, dispatcher) if param_type is tuple: seq_value = cast("tuple[Any, ...]", param_set) - coerced_seq = _coerce_sequence_if_needed(seq_value, type_coercion_map, fallback_items) + coerced_seq = _coerce_sequence_if_needed(seq_value, type_coercion_map, fallback_items, dispatcher) if coerced_seq is seq_value: return seq_value return tuple(cast("list[Any]", coerced_seq)) if param_type is dict: - return _coerce_mapping_if_needed(cast("dict[Any, Any]", param_set), type_coercion_map, fallback_items) + return _coerce_mapping_if_needed( + cast("dict[Any, Any]", param_set), type_coercion_map, fallback_items, dispatcher + ) if isinstance(param_set, Sequence) and not isinstance(param_set, (str, bytes)): seq_fallback = param_set - coerced_seq = _coerce_sequence_if_needed(seq_fallback, type_coercion_map, fallback_items) + coerced_seq = _coerce_sequence_if_needed(seq_fallback, type_coercion_map, fallback_items, dispatcher) if coerced_seq is seq_fallback: return param_set return coerced_seq if isinstance(param_set, Mapping): - coerced_mapping = _coerce_mapping_if_needed(param_set, type_coercion_map, fallback_items) + coerced_mapping = _coerce_mapping_if_needed(param_set, type_coercion_map, fallback_items, dispatcher) if coerced_mapping is param_set: return param_set return coerced_mapping @@ -1109,13 +1165,14 @@ def _coerce_parameters_payload( fallback_items: "tuple[TypeCoercionFallback, ...]", is_many: bool, ) -> object: + dispatcher = type_coercion_dispatcher(fallback_items) if fallback_items else None param_type = type(parameters) if param_type is list: seq_params = cast("list[Any]", parameters) if is_many: updated_many: list[Any] | None = None for idx, param_set in enumerate(seq_params): - coerced_set = _coerce_parameter_set(param_set, type_coercion_map, fallback_items) + coerced_set = _coerce_parameter_set(param_set, type_coercion_map, fallback_items, dispatcher) if updated_many is None: if coerced_set is param_set: continue @@ -1124,23 +1181,28 @@ def _coerce_parameters_payload( if updated_many is None: return seq_params return updated_many - return _coerce_sequence_if_needed(seq_params, type_coercion_map, fallback_items) + return _coerce_sequence_if_needed(seq_params, type_coercion_map, fallback_items, dispatcher) if param_type is tuple: tuple_params = cast("tuple[Any, ...]", parameters) if is_many: - return [_coerce_parameter_set(param_set, type_coercion_map, fallback_items) for param_set in tuple_params] - coerced_tuple = _coerce_sequence_if_needed(tuple_params, type_coercion_map, fallback_items) + return [ + _coerce_parameter_set(param_set, type_coercion_map, fallback_items, dispatcher) + for param_set in tuple_params + ] + coerced_tuple = _coerce_sequence_if_needed(tuple_params, type_coercion_map, fallback_items, dispatcher) return list(coerced_tuple) if param_type is dict: dict_params = cast("dict[Any, Any]", parameters) - return _coerce_mapping_if_needed(dict_params, type_coercion_map, fallback_items) + return _coerce_mapping_if_needed(dict_params, type_coercion_map, fallback_items, dispatcher) if is_many and isinstance(parameters, Sequence) and not isinstance(parameters, (str, bytes)): - return [_coerce_parameter_set(param_set, type_coercion_map, fallback_items) for param_set in parameters] + return [ + _coerce_parameter_set(param_set, type_coercion_map, fallback_items, dispatcher) for param_set in parameters + ] if isinstance(parameters, Sequence) and not isinstance(parameters, (str, bytes)): - coerced_sequence = _coerce_sequence_if_needed(parameters, type_coercion_map, fallback_items) + coerced_sequence = _coerce_sequence_if_needed(parameters, type_coercion_map, fallback_items, dispatcher) return list(coerced_sequence) if isinstance(parameters, Mapping): - coerced_mapping = _coerce_mapping_if_needed(parameters, type_coercion_map, fallback_items) + coerced_mapping = _coerce_mapping_if_needed(parameters, type_coercion_map, fallback_items, dispatcher) return dict(coerced_mapping) return _coerce_parameter_value(parameters, type_coercion_map, fallback_items) diff --git a/sqlspec/core/result/_base.py b/sqlspec/core/result/_base.py index bef1fe69a..fef16e5c4 100644 --- a/sqlspec/core/result/_base.py +++ b/sqlspec/core/result/_base.py @@ -15,7 +15,7 @@ from mypy_extensions import mypyc_attr from typing_extensions import TypeVar -from sqlspec.core.statement import SQL +from sqlspec.core.statement import SQL, ProcessedState from sqlspec.exceptions import MultipleResultsFoundError from sqlspec.storage import ( AsyncStoragePipeline, @@ -44,6 +44,7 @@ from collections.abc import Iterator, Sequence from sqlspec.core.compiler import OperationType + from sqlspec.core.statement import StatementConfig from sqlspec.typing import ArrowReturnFormat, ArrowTable, PandasDataFrame, PolarsDataFrame, SchemaT @@ -75,20 +76,25 @@ class StatementResult: """ __slots__ = ( + "_lazy_cached_state", + "_lazy_compiled_sql", + "_lazy_config", + "_lazy_params", + "_lazy_sql", "_operation_type", + "_statement", "data", "execution_time", "last_inserted_id", "metadata", "rows_affected", - "statement", ) _operation_type: "OperationType" def __init__( self, - statement: "SQL", + statement: "SQL | None" = None, data: Any = None, rows_affected: int = 0, last_inserted_id: int | str | None = None, @@ -105,14 +111,54 @@ def __init__( execution_time: Time taken to execute the statement in seconds. metadata: Additional metadata about the operation. """ - self.statement = statement - self._operation_type = statement.operation_type + self._statement: SQL | None = statement + self._lazy_sql: str | None = None + self._lazy_config: StatementConfig | None = None + self._lazy_cached_state: ProcessedState | None = None + self._lazy_compiled_sql: str | None = None + self._lazy_params: Any = None + self._operation_type = statement.operation_type if statement is not None else "SELECT" self.data = data self.rows_affected = rows_affected self.last_inserted_id = last_inserted_id self.execution_time = execution_time self.metadata = metadata if metadata is not None else {} + @property + def statement(self) -> "SQL": + """Return the executed SQL statement, materializing it on demand for fast-path results.""" + stmt = self._statement + if stmt is not None: + return stmt + raw_sql = self._lazy_sql + config = self._lazy_config + cached_state = self._lazy_cached_state + if raw_sql is not None and config is not None and cached_state is not None: + direct_state = ProcessedState( + compiled_sql=self._lazy_compiled_sql or cached_state.compiled_sql, + execution_parameters=self._lazy_params, + parsed_expression=cached_state.parsed_expression, + operation_type=cached_state.operation_type, + input_named_parameters=cached_state.input_named_parameters, + applied_wrap_types=cached_state.applied_wrap_types, + filter_hash=cached_state.filter_hash, + parameter_fingerprint=cached_state.parameter_fingerprint, + parameter_casts=cached_state.parameter_casts, + validation_errors=cached_state.validation_errors, + parameter_profile=cached_state.parameter_profile, + operation_profile=cached_state.operation_profile, + is_many=False, + expression_is_shared=True, + ) + stmt = SQL._create_cached_direct(raw_sql, config, direct_state, pooled=False) + self._statement = stmt + return stmt + return _EMPTY_RESULT_STATEMENT + + @statement.setter + def statement(self, value: "SQL") -> None: + self._statement = value + @abstractmethod def __iter__(self) -> "Iterator[Any]": """Iterate over result rows.""" @@ -161,6 +207,9 @@ def set_metadata(self, key: str, value: Any) -> None: key: The metadata key to set. value: The value to set. """ + if self.metadata is _DEFAULT_DML_METADATA: + self.metadata = {key: value} + return self.metadata[key] = value @@ -197,7 +246,7 @@ class SQLResult(StatementResult): def __init__( self, - statement: "SQL", + statement: "SQL | None" = None, data: "Sequence[Any] | None" = None, rows_affected: int = 0, last_inserted_id: int | str | None = None, @@ -331,6 +380,39 @@ def num_columns(self) -> int: """ return len(self.column_names) if self.column_names else 0 + @classmethod + def create_cached_select( + cls, + sql: str, + config: "StatementConfig", + cached_state: "ProcessedState", + compiled_sql: str, + params: Any, + data: "list[Any]", + column_names: "list[str]", + rows_affected: int, + row_format: RowFormat = "dict", + last_inserted_id: int | str | None = None, + ) -> "SQLResult": + """Construct a fast-path SELECT result with deferred SQL statement materialization.""" + result = cls( + statement=None, + data=data, + rows_affected=rows_affected, + last_inserted_id=last_inserted_id, + metadata=_DEFAULT_DML_METADATA, + operation_type="SELECT", + column_names=column_names, + total_count=rows_affected, + row_format=row_format, + ) + result._lazy_sql = sql + result._lazy_config = config + result._lazy_cached_state = cached_state + result._lazy_compiled_sql = compiled_sql + result._lazy_params = params + return result + def is_success(self) -> bool: """Check if the operation was successful. @@ -965,7 +1047,12 @@ class DMLResult(SQLResult): def __init__( self, op_type: "OperationType", rows_affected: int = 0, last_inserted_id: int | str | None = None ) -> None: - self.statement = _EMPTY_RESULT_STATEMENT + self._statement = _EMPTY_RESULT_STATEMENT + self._lazy_sql = None + self._lazy_config = None + self._lazy_cached_state = None + self._lazy_compiled_sql = None + self._lazy_params = None self.data = _EMPTY_RESULT_DATA self.rows_affected = rows_affected self.last_inserted_id = last_inserted_id diff --git a/sqlspec/core/statement.py b/sqlspec/core/statement.py index 0e0bba94f..9fbc98501 100644 --- a/sqlspec/core/statement.py +++ b/sqlspec/core/statement.py @@ -117,6 +117,7 @@ PROCESSED_STATE_SLOTS: Final = ( "compiled_sql", "execution_parameters", + "expression_is_shared", "parsed_expression", "operation_type", "input_named_parameters", @@ -178,6 +179,7 @@ def __init__( parameter_profile: "ParameterProfile | None" = None, operation_profile: "OperationProfile | None" = None, is_many: bool = False, + expression_is_shared: bool = False, ) -> None: self.compiled_sql = compiled_sql self.execution_parameters = execution_parameters @@ -192,6 +194,7 @@ def __init__( self.parameter_profile = parameter_profile or ParameterProfile.empty() self.operation_profile = operation_profile or OperationProfile.empty() self.is_many = is_many + self.expression_is_shared = expression_is_shared def __hash__(self) -> int: return hash((self.compiled_sql, str(self.execution_parameters), self.operation_type)) @@ -214,9 +217,19 @@ def __reduce__(self) -> "tuple[Any, ...]": self.parameter_profile, self.operation_profile, self.is_many, + self.expression_is_shared, ), ) + def ensure_owned_expression(self) -> "exp.Expr | None": + """Return an owned SQLGlot expression, copying once if currently shared with a cache.""" + expr = self.parsed_expression + if expr is not None and self.expression_is_shared: + expr = expr.copy() + self.parsed_expression = expr + self.expression_is_shared = False + return expr + def reset(self) -> None: """Reset processing state for reuse.""" self.compiled_sql = "" @@ -232,6 +245,7 @@ def reset(self) -> None: self.parameter_profile = ParameterProfile.empty() self.operation_profile = OperationProfile.empty() self.is_many = False + self.expression_is_shared = False @mypyc_attr(allow_interpreted_subclasses=False) @@ -388,7 +402,17 @@ def parameters(self) -> Any: return mixed[1] if self._named_parameters: return self._named_parameters - return self._positional_parameters or [] + if self._positional_parameters: + return self._positional_parameters + if self._is_cache_direct and self._processed_state is not Empty: + exec_params = self._processed_state.execution_parameters + if isinstance(exec_params, dict): + return exec_params + if isinstance(exec_params, tuple): + return list(exec_params) + if isinstance(exec_params, list): + return exec_params + return [] @property def positional_parameters(self) -> "list[Any]": @@ -431,7 +455,7 @@ def expression(self) -> "exp.Expr | None": """ state = self._processed_state if state is not Empty: - return state.parsed_expression + return state.ensure_owned_expression() return self._raw_expression @property @@ -466,7 +490,7 @@ def statement_expression(self) -> "exp.Expr | None": """ state = self._processed_state if state is not Empty: - return state.parsed_expression + return state.ensure_owned_expression() return self._raw_expression @property @@ -629,6 +653,7 @@ def compile(self) -> "tuple[str, Any]": operation_profile=compiled_result.operation_profile, validation_errors=[], is_many=self._is_many, + expression_is_shared=compiled_result.expression is not None, ) except sqlspec.exceptions.SQLSpecError: raise @@ -1139,7 +1164,9 @@ def builder(self, dialect: "DialectType | None" = None) -> "QueryBuilder": return builder @classmethod - def _create_cached_direct(cls, sql: str, config: "StatementConfig", processed_state: "ProcessedState") -> "SQL": + def _create_cached_direct( + cls, sql: str, config: "StatementConfig", processed_state: "ProcessedState", *, pooled: bool = True + ) -> "SQL": """Create a minimal SQL object for direct (fast-path) execution. Bypasses standard __init__ and parameter processing. @@ -1150,7 +1177,7 @@ def _create_cached_direct(cls, sql: str, config: "StatementConfig", processed_st stmt._statement_config = config stmt._dialect = stmt._normalize_dialect(config.dialect) stmt._processed_state = processed_state - stmt._pooled = True + stmt._pooled = pooled stmt._is_cache_direct = True stmt._is_many = False stmt._is_script = False @@ -1360,11 +1387,7 @@ def _rebind_cached_parameters(self, state: "ProcessedState") -> "tuple[str, Any] mixed = self._resolve_mixed_parameter_inputs() params = mixed[1] if mixed is not None else self._named_parameters or self._positional_parameters if self._rebind_processor is None: - self._rebind_processor = ParameterProcessor( - converter=self._statement_config.parameter_converter, - validator=self._statement_config.parameter_validator, - cache_max_size=0, - ) + self._rebind_processor = _get_rebind_processor(self._statement_config) processor = self._rebind_processor rebound_params = processor.transform_cached_parameters( params, @@ -1378,10 +1401,12 @@ def _rebind_cached_parameters(self, state: "ProcessedState") -> "tuple[str, Any] output_transformer = self._statement_config.output_transformer if output_transformer: compiled_sql, rebound_params = output_transformer(compiled_sql, rebound_params) + if state.parsed_expression is not None: + state.expression_is_shared = True self._processed_state = self._build_processed_state( compiled_sql=compiled_sql, execution_parameters=rebound_params, - parsed_expression=state.parsed_expression.copy() if state.parsed_expression is not None else None, + parsed_expression=state.parsed_expression, operation_type=state.operation_type, input_named_parameters=state.input_named_parameters, applied_wrap_types=state.applied_wrap_types, @@ -1392,6 +1417,7 @@ def _rebind_cached_parameters(self, state: "ProcessedState") -> "tuple[str, Any] operation_profile=state.operation_profile, validation_errors=state.validation_errors.copy(), is_many=state.is_many, + expression_is_shared=state.parsed_expression is not None, ) self._compiled_from_cache = False return compiled_sql, rebound_params @@ -1423,15 +1449,21 @@ def _create_empty_copy(self) -> "SQL": new_sql._pooled = True source_state = self._processed_state - is_processed = source_state is not Empty - new_sql._compiled_from_cache = is_processed - new_sql._processed_state = source_state if is_processed else Empty + if source_state is not Empty: + if source_state.parsed_expression is not None: + source_state.expression_is_shared = True + new_sql._compiled_from_cache = True + new_sql._processed_state = source_state + else: + new_sql._compiled_from_cache = False + new_sql._processed_state = Empty new_sql._hash = None new_sql._filters = self._filters.copy() new_sql._named_parameters = {} new_sql._positional_parameters = [] new_sql._sql_param_counters = self._sql_param_counters.copy() new_sql._declared_parameters = self._declared_parameters + new_sql._rebind_processor = self._rebind_processor return new_sql @@ -1451,6 +1483,7 @@ def _build_processed_state( operation_profile: "OperationProfile | None", validation_errors: "list[str] | None", is_many: bool, + expression_is_shared: bool = False, ) -> "ProcessedState": state = get_processed_state_pool().acquire() ProcessedState.__init__( @@ -1468,6 +1501,7 @@ def _build_processed_state( parameter_profile=parameter_profile, operation_profile=operation_profile, is_many=is_many, + expression_is_shared=expression_is_shared, ) return state @@ -1522,6 +1556,13 @@ def _next_parameter_name(self, base_name: str) -> str: self._sql_param_counters[prefixed_base] = next_index return candidate + def _peek_expression(self) -> "exp.Expr | None": + """Return the parsed or raw SQLGlot expression for read-only inspection without copying.""" + state = self._processed_state + if state is not Empty: + return state.parsed_expression + return self._raw_expression + def _current_expression(self) -> exp.Expr: """Get the current expression or parse the raw SQL. @@ -1936,6 +1977,8 @@ def _compare_parameter_configs(self, config1: Any, config2: Any) -> bool: _DEFAULT_CONFIG: "StatementConfig | None" = None +_REBIND_PROCESSORS: "dict[tuple[int, int], tuple[Any, Any, ParameterProcessor]]" = {} +_REBIND_PROCESSOR_CACHE_MAX_SIZE: Final[int] = 256 def get_default_config() -> StatementConfig: @@ -1962,6 +2005,9 @@ def get_default_parameter_config() -> ParameterStyleConfig: ) +Statement: TypeAlias = str | exp.Expr | SQL + + def _rebuild_sql( raw_sql: str, original_parameters: "tuple[Any, ...]", @@ -1989,7 +2035,19 @@ def _rebuild_sql( return new_sql -Statement: TypeAlias = str | exp.Expr | SQL +def _get_rebind_processor(config: "StatementConfig") -> ParameterProcessor: + """Return a shared stateless ParameterProcessor for cached-statement rebinding.""" + converter = config.parameter_converter + validator = config.parameter_validator + key = (id(converter), id(validator)) + cached = _REBIND_PROCESSORS.get(key) + if cached is not None and cached[0] is converter and cached[1] is validator: + return cached[2] + if len(_REBIND_PROCESSORS) >= _REBIND_PROCESSOR_CACHE_MAX_SIZE: + _REBIND_PROCESSORS.clear() + processor = ParameterProcessor(converter=converter, validator=validator, cache_max_size=0) + _REBIND_PROCESSORS[key] = (converter, validator, processor) + return processor def _parse_order_item(order_item: str, dialect: "str | None", enable_parsing: bool) -> exp.Expr: diff --git a/sqlspec/driver/_async.py b/sqlspec/driver/_async.py index fc1dcb4d0..30516b8b6 100644 --- a/sqlspec/driver/_async.py +++ b/sqlspec/driver/_async.py @@ -1,6 +1,7 @@ """Asynchronous driver protocol implementation.""" import logging +import sys from abc import abstractmethod from inspect import isawaitable from time import perf_counter @@ -30,6 +31,7 @@ ExecutionResult, StackExecutionObserver, _raise_database_exception, + _resolve_fast_params, describe_stack_statement, handle_single_row_error, validate_savepoint_name, @@ -55,7 +57,7 @@ from sqlglot.dialects.dialect import DialectType from sqlspec.builder import QueryBuilder - from sqlspec.core import ArrowResult, SQLResult, Statement, StatementConfig, StatementFilter + from sqlspec.core import ArrowResult, OperationType, SQLResult, Statement, StatementConfig, StatementFilter from sqlspec.data_dictionary import ( ColumnMetadata, ForeignKeyMetadata, @@ -247,82 +249,34 @@ async def dispatch_statement_execution(self, statement: "SQL", connection: "Any" if runtime is None or runtime.is_idle: exc_handler = self.handle_database_exceptions() - result = await self._run_with_exception_handler( - exc_handler, - self._dispatch_statement_with_cursor, - connection, - statement, - has_execution_parameters=bool(execution_parameters), - ) + if sys.exc_info()[1] is None: + async with exc_handler: + result = await self._dispatch_statement_with_cursor( + connection, statement, has_execution_parameters=bool(execution_parameters) + ) + else: + result = await self._run_with_exception_handler( + exc_handler, + self._dispatch_statement_with_cursor, + connection, + statement, + has_execution_parameters=bool(execution_parameters), + ) self._check_pending_exception(exc_handler) assert result is not None return result - operation = statement.operation_type - query_context = { - "sql": compiled_sql, - "parameters": execution_parameters, - "driver": type(self).__name__, - "operation": operation, - "is_many": statement.is_many, - "is_script": statement.is_script, - } - await runtime.emit_query_start_async(**query_context) - sql_hash = None - if runtime.span_manager.is_enabled or ( - runtime.has_statement_observers and runtime.config.logging and runtime.config.logging.include_sql_hash - ): - sql_hash = observability_runtime.compute_sql_hash(compiled_sql) - span = runtime.start_query_span(compiled_sql, operation, type(self).__name__, sql_hash=sql_hash) - started = perf_counter() - - exc_handler = self.handle_database_exceptions() - try: - result = await self._run_with_exception_handler( - exc_handler, - self._dispatch_statement_with_cursor, - connection, - statement, - has_execution_parameters=bool(execution_parameters), - ) - except Exception as exc: # pragma: no cover - pending_exception = exc_handler.pending_exception - if pending_exception is not None: - mapped_exc = pending_exception - runtime.span_manager.end_span(span, error=mapped_exc) - await runtime.emit_error_async(mapped_exc, **query_context) - _raise_database_exception(exc_handler, exc) - runtime.span_manager.end_span(span, error=exc) - await runtime.emit_error_async(exc, **query_context) - _raise_database_exception(exc_handler, exc) - - pending_exception = exc_handler.pending_exception - if pending_exception is not None: - mapped_exc = pending_exception - runtime.span_manager.end_span(span, error=mapped_exc) - await runtime.emit_error_async(mapped_exc, **query_context) - _raise_database_exception(exc_handler, None) - - assert result is not None - - runtime.span_manager.end_span(span) - duration = perf_counter() - started - await runtime.emit_query_complete_async(**{**query_context, "rows_affected": result.rows_affected}) - runtime.emit_statement_event( - sql=compiled_sql, - parameters=execution_parameters, - driver=type(self).__name__, - operation=operation, - execution_mode=self.statement_config.execution_mode, + return await self._execute_with_observability_async( + compiled_sql=compiled_sql, + execution_parameters=execution_parameters, + operation=statement.operation_type, is_many=statement.is_many, is_script=statement.is_script, - rows_affected=result.rows_affected, - duration_s=duration, - storage_backend=(result.metadata or {}).get("storage_backend"), - started_at=started, - precomputed_sql_hash=sql_hash, + runner=self._dispatch_statement_observed_async, + connection=connection, + statement=statement, + has_execution_parameters=bool(execution_parameters), ) - return result finally: self._release_pooled_statement(statement) @@ -1524,6 +1478,90 @@ async def _run_with_exception_handler( """Run an async operation without inheriting the caller's active exception state.""" return await _run_with_async_exception_handler(exc_handler, operation, *args, **kwargs) + async def _execute_with_observability_async( + self, + *, + compiled_sql: str, + execution_parameters: Any, + operation: "OperationType", + is_many: bool, + is_script: bool, + runner: "Callable[..., Awaitable[SQLResult]]", + **runner_kwargs: Any, + ) -> "SQLResult": + """Execute an async runner while emitting spans, lifecycle hooks, and statement events.""" + runtime = self._observability + if runtime is None or runtime.is_idle: + return await runner(**runner_kwargs) + driver_name = type(self).__name__ + query_context = { + "sql": compiled_sql, + "parameters": execution_parameters, + "driver": driver_name, + "operation": operation, + "is_many": is_many, + "is_script": is_script, + } + await runtime.emit_query_start_async(**query_context) + sql_hash = None + if runtime.span_manager.is_enabled or ( + runtime.has_statement_observers and runtime.config.logging and runtime.config.logging.include_sql_hash + ): + sql_hash = observability_runtime.compute_sql_hash(compiled_sql) + span = runtime.start_query_span(compiled_sql, operation, driver_name, sql_hash=sql_hash) + started = perf_counter() + try: + result = await runner(**runner_kwargs) + except Exception as exc: + runtime.span_manager.end_span(span, error=exc) + await runtime.emit_error_async(exc, **query_context) + raise + runtime.span_manager.end_span(span) + duration = perf_counter() - started + await runtime.emit_query_complete_async(**{**query_context, "rows_affected": result.rows_affected}) + runtime.emit_statement_event( + sql=compiled_sql, + parameters=execution_parameters, + driver=driver_name, + operation=operation, + execution_mode=self.statement_config.execution_mode, + is_many=is_many, + is_script=is_script, + rows_affected=result.rows_affected, + duration_s=duration, + storage_backend=(result.metadata or {}).get("storage_backend"), + started_at=started, + precomputed_sql_hash=sql_hash, + ) + return result + + async def _dispatch_statement_observed_async( + self, connection: Any, statement: "SQL", *, has_execution_parameters: bool + ) -> "SQLResult": + """Execute an async statement inside an exception handler and raise any mapped error.""" + exc_handler = self.handle_database_exceptions() + result: SQLResult | None = None + try: + if sys.exc_info()[1] is None: + async with exc_handler: + result = await self._dispatch_statement_with_cursor( + connection, statement, has_execution_parameters=has_execution_parameters + ) + else: + result = await self._run_with_exception_handler( + exc_handler, + self._dispatch_statement_with_cursor, + connection, + statement, + has_execution_parameters=has_execution_parameters, + ) + except Exception as exc: # pragma: no cover + _raise_database_exception(exc_handler, exc) + if exc_handler.pending_exception is not None: + _raise_database_exception(exc_handler, None) + assert result is not None + return result + async def _execute_cache_hit( self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: CachedQuery ) -> "SQLResult": @@ -1544,13 +1582,34 @@ async def _execute_cache_hit( """ exc_handler = self.handle_database_exceptions() result: SQLResult | None = None - result = await self._run_with_exception_handler( - exc_handler, self._execute_cache_hit_with_cursor, sql, params, cached - ) + if sys.exc_info()[1] is None: + async with exc_handler: + result = await self._execute_cache_hit_with_cursor(sql, params, cached) + else: + result = await self._run_with_exception_handler( + exc_handler, self._execute_cache_hit_with_cursor, sql, params, cached + ) self._check_pending_exception(exc_handler) assert result is not None return result + async def _execute_cache_hit_observed( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: CachedQuery + ) -> "SQLResult": + """Execute an async fast-path cache hit wrapped in observability instrumentation.""" + obs_params = list(params) if isinstance(params, tuple) else params + return await self._execute_with_observability_async( + compiled_sql=cached.compiled_sql, + execution_parameters=obs_params, + operation=cached.operation_type, + is_many=False, + is_script=False, + runner=self._execute_cache_hit, + sql=sql, + params=params, + cached=cached, + ) + async def _cached_execution( self, statement: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]" ) -> "SQLResult | None": @@ -1577,9 +1636,13 @@ async def _execute_cached_statement(self, statement: "SQL") -> "SQLResult": exc_handler = self.handle_database_exceptions() result: SQLResult | None = None try: - result = await self._run_with_exception_handler( - exc_handler, self._execute_cached_statement_with_cursor, statement - ) + if sys.exc_info()[1] is None: + async with exc_handler: + result = await self._execute_cached_statement_with_cursor(statement) + else: + result = await self._run_with_exception_handler( + exc_handler, self._execute_cached_statement_with_cursor, statement + ) self._check_pending_exception(exc_handler) assert result is not None @@ -1587,6 +1650,21 @@ async def _execute_cached_statement(self, statement: "SQL") -> "SQLResult": finally: self._release_pooled_statement(statement) + async def _execute_cached_statement_observed( + self, statement: "SQL", compiled_sql: str, execution_parameters: Any, operation: "OperationType" + ) -> "SQLResult": + """Execute a rebound cached async statement wrapped in observability instrumentation.""" + obs_params = list(execution_parameters) if isinstance(execution_parameters, tuple) else execution_parameters + return await self._execute_with_observability_async( + compiled_sql=compiled_sql, + execution_parameters=obs_params, + operation=operation, + is_many=False, + is_script=False, + runner=self._execute_cached_statement, + statement=statement, + ) + async def _execute( self, statement: "SQL | Statement | QueryBuilder", @@ -1594,11 +1672,7 @@ async def _execute( statement_config: "StatementConfig | None", kwargs: "dict[str, Any]", ) -> "SQLResult": - fast_params: tuple[Any, ...] | list[Any] | dict[str, Any] | None = None - if not kwargs and len(parameters) == 1 and isinstance(parameters[0], (tuple, list, dict)): - fast_params = cast("tuple[Any, ...] | list[Any] | dict[str, Any]", parameters[0]) - elif not parameters and kwargs: - fast_params = kwargs + fast_params = _resolve_fast_params(parameters, kwargs) if ( self._stmt_cache_enabled and (statement_config is None or statement_config is self.statement_config) @@ -1705,35 +1779,32 @@ async def _execute_cache_hit_with_cursor( result: SQLResult | None = None try: async with self.with_cursor(self.connection) as cursor: - execute = getattr(cursor, "execute", None) - fetchall = getattr(cursor, "fetchall", None) + returns_rows = cached.operation_profile.returns_rows + execute = getattr(cursor, "execute", None) if cursor is not self.connection else None + fetchall = getattr(cursor, "fetchall", None) if execute is not None and returns_rows else None can_use_cursor_fast_path = execute is not None and ( - (cached.operation_profile.returns_rows and fetchall is not None) - or (not cached.operation_profile.returns_rows and hasattr(cursor, "rowcount")) + (returns_rows and fetchall is not None) or (not returns_rows and hasattr(cursor, "rowcount")) ) if can_use_cursor_fast_path: assert execute is not None - execute_result = execute(cached.compiled_sql, params) + execute_result = execute(cached.compiled_sql, params) if params else execute(cached.compiled_sql) if isawaitable(execute_result): await execute_result - if cached.operation_profile.returns_rows: + if returns_rows: assert fetchall is not None fetched_data = fetchall() if isawaitable(fetched_data): fetched_data = await fetched_data data, column_names, row_count = self.collect_rows(cursor, fetched_data) - execution_result = self.create_execution_result( - cursor, + result = self.build_cached_select_result( + sql, + params, + cached, selected_data=data, column_names=column_names, data_row_count=row_count, - is_select_result=True, row_format=resolve_row_format(data), ) - direct_statement = self._cached_statement( - sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql - ) - result = self.build_statement_result(direct_statement, execution_result) else: affected_rows = self.resolve_rowcount(cursor) result = DMLResult(cached.operation_type, affected_rows) @@ -1742,7 +1813,7 @@ async def _execute_cache_hit_with_cursor( sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql ) execution_result = await self.dispatch_execute(cursor, direct_statement) - if cached.operation_profile.returns_rows: + if returns_rows: result = self.build_statement_result(direct_statement, execution_result) else: affected_rows = ( diff --git a/sqlspec/driver/_common.py b/sqlspec/driver/_common.py index 14149b3cb..c54cbd1db 100644 --- a/sqlspec/driver/_common.py +++ b/sqlspec/driver/_common.py @@ -3,7 +3,6 @@ import hashlib import logging import re -from collections import OrderedDict from collections.abc import Mapping from time import perf_counter from typing import TYPE_CHECKING, Any, ClassVar, Final, Literal, NamedTuple, NoReturn, Protocol, Self, cast, overload @@ -50,7 +49,7 @@ sort_dependencies, ) from sqlspec.data_dictionary._registry import get_dialect_config -from sqlspec.driver._query_cache import STMT_CACHE_MAX_SIZE, CachedQuery, QueryCache +from sqlspec.driver._query_cache import CachedQuery, QueryCache, StatementCacheHolder, resolve_statement_cache_size from sqlspec.driver._storage_helpers import ( CAPABILITY_HINTS, arrow_table_needs_parameter_preparation, @@ -84,11 +83,11 @@ ) if TYPE_CHECKING: - from collections import abc + from collections import OrderedDict, abc from collections.abc import Awaitable from types import TracebackType - from sqlspec.core import ArrowResult, FilterTypeT, StatementFilter + from sqlspec.core import ArrowResult, FilterTypeT, OperationType, StatementFilter from sqlspec.core.parameters._types import ConvertedParameters from sqlspec.core.stack import StatementStack from sqlspec.data_dictionary._types import DialectConfig @@ -543,6 +542,7 @@ class CommonDriverAttributesMixin: """Common attributes and methods for driver adapters.""" __slots__ = ( + "_cache_holder", "_observability", "_processed_state_pool", "_statement_cache", @@ -583,15 +583,19 @@ def __init__( self.driver_features = driver_features or {} self._observability = observability self._transaction_depth = 0 - self._statement_cache: OrderedDict[str, SQL] = OrderedDict() self._stmt_cache_max_size = self._statement_cache_size() - self._stmt_cache = QueryCache(self._stmt_cache_max_size) - self._stmt_cache_rebind_processor = ParameterProcessor( - converter=self.statement_config.parameter_converter, - validator=self.statement_config.parameter_validator, - cache_max_size=0, - validator_cache_max_size=0, - ) + shared_holder = self.driver_features.get("_statement_cache_holder") + if ( + isinstance(shared_holder, StatementCacheHolder) + and shared_holder.statement_config is self.statement_config + and shared_holder.max_size == self._stmt_cache_max_size + ): + self._cache_holder = shared_holder + else: + self._cache_holder = StatementCacheHolder(self.statement_config, self._stmt_cache_max_size) + self._statement_cache: OrderedDict[str, SQL] = self._cache_holder._statement_cache + self._stmt_cache: QueryCache = self._cache_holder.query_cache + self._stmt_cache_rebind_processor: ParameterProcessor = self._cache_holder.rebind_processor self._stmt_cache_enabled = False self._statement_pool = get_sql_pool() self._processed_state_pool = get_processed_state_pool() @@ -643,12 +647,11 @@ def stmt_cache_rebind( ) -> "ConvertedParameters": """Rebind parameters for a cached query.""" config = self.statement_config.parameter_config - needs_style_remap = bool(_CACHED_NAMED_STYLES.intersection(cached.parameter_profile.styles)) if ( not cached.input_named_parameters and not cached.applied_wrap_types and not config.type_coercion_map - and not needs_style_remap + and not cached.needs_style_remap ): return params return self._stmt_cache_rebind_processor.transform_cached_parameters( @@ -826,9 +829,8 @@ def prepare_statement( statement_config = self.statement_config if isinstance(statement, str): - cached_sql = self._statement_cache.get(statement) + cached_sql = self._cache_holder.get_statement(statement) if cached_sql is not None and not kwargs: - self._statement_cache.move_to_end(statement) has_filters = any(is_statement_filter(p) for p in parameters) if not has_filters: return cached_sql.copy(parameters=parameters) @@ -846,9 +848,7 @@ def prepare_statement( else: sql_statement = self._prepare_from_string(statement, data_parameters, statement_config, kwargs) if not filters and not kwargs and isinstance(statement, str) and self._stmt_cache_max_size > 0: - if len(self._statement_cache) >= self._stmt_cache_max_size: - self._statement_cache.popitem(last=False) - self._statement_cache[statement] = sql_statement + self._cache_holder.set_statement(statement, sql_statement, max_size=self._stmt_cache_max_size) _check_declared_parameters(sql_statement) return self._apply_filters(sql_statement, filters) @@ -958,20 +958,38 @@ def find_filter( """ return _find_filter_impl(filter_type, filters) + def build_cached_select_result( + self, + sql: str, + params: "tuple[Any, ...] | list[Any] | dict[str, Any]", + cached: "CachedQuery", + *, + selected_data: "list[Any]", + column_names: "list[str]", + data_row_count: int, + row_format: "RowFormat" = "dict", + last_inserted_id: int | str | None = None, + ) -> "SQLResult": + """Build a fast-path SELECT result with deferred statement materialization.""" + return SQLResult.create_cached_select( + sql=sql, + config=self.statement_config, + cached_state=cached.processed_state, + compiled_sql=cached.compiled_sql, + params=params, + data=selected_data, + column_names=column_names, + rows_affected=data_row_count, + row_format=row_format, + last_inserted_id=last_inserted_id, + ) + def _statement_cache_size(self) -> int: """Return the configured statement-cache capacity.""" - cache_size = self.driver_features.get("sqlspec_statement_cache_size", STMT_CACHE_MAX_SIZE) - try: - resolved_size = int(cache_size) - except (TypeError, ValueError): - return STMT_CACHE_MAX_SIZE - return max(0, resolved_size) + return resolve_statement_cache_size(self.driver_features) def _refresh_statement_cache_state(self) -> None: - is_observability_idle = self._observability is None or self._observability.is_idle - self._stmt_cache_enabled = bool( - self._stmt_cache_max_size > 0 and not self.statement_config._has_transformers and is_observability_idle - ) + self._stmt_cache_enabled = bool(self._stmt_cache_max_size > 0 and not self.statement_config._has_transformers) def _require_capability(self, capability_flag: str) -> None: """Check that a storage capability is enabled. @@ -1025,9 +1043,7 @@ def _cached_statement( ) effective_compiled_sql = cached.compiled_sql if compiled_sql is None else compiled_sql cached_state = cached.processed_state - direct_state = self._processed_state_pool.acquire() - ProcessedState.__init__( - direct_state, + direct_state = ProcessedState( compiled_sql=effective_compiled_sql, execution_parameters=prepared_parameters, parsed_expression=cached_state.parsed_expression, @@ -1036,13 +1052,14 @@ def _cached_statement( applied_wrap_types=cached_state.applied_wrap_types, filter_hash=cached_state.filter_hash, parameter_fingerprint=cached_state.parameter_fingerprint, - parameter_casts=dict(cached_state.parameter_casts), - validation_errors=cached_state.validation_errors.copy(), + parameter_casts=cached_state.parameter_casts, + validation_errors=cached_state.validation_errors, parameter_profile=cached_state.parameter_profile, operation_profile=cached_state.operation_profile, is_many=False, + expression_is_shared=True, ) - return SQL._create_cached_direct(sql, self.statement_config, direct_state) + return SQL._create_cached_direct(sql, self.statement_config, direct_state, pooled=False) def _sub_statement(self, sql: str, execution_parameters: "ConvertedParameters") -> "SQL": direct_state = self._processed_state_pool.acquire() @@ -1068,21 +1085,25 @@ def _cached_execution( if cached is None or cached.param_count != len(params): return None - param_values = _cache_param_values(params) - config = self.statement_config param_config = config.parameter_config - if param_config.ast_transformer is not None and any(p is None for p in param_values): - return None + if param_config.ast_transformer is not None: + param_values = _cache_param_values(params) + if any(p is None for p in param_values): + return None + else: + param_values = None - needs_rebind = bool(cached.input_named_parameters or cached.applied_wrap_types) - if not needs_rebind: + needs_rebind = bool(cached.input_named_parameters or cached.applied_wrap_types or cached.needs_style_remap) + if not needs_rebind and params: + if param_values is None: + param_values = _cache_param_values(params) needs_rebind = parameter_values_need_processing(param_values, param_config.type_coercion_map) - if not needs_rebind and _CACHED_NAMED_STYLES.intersection(cached.parameter_profile.styles): - needs_rebind = True - if not needs_rebind and not config._has_output_transformer: + runtime = self._observability + if runtime is not None and not runtime.is_idle: + return self._execute_cache_hit_observed(statement, params, cached) return self._execute_cache_hit(statement, params, cached) if needs_rebind: @@ -1101,11 +1122,21 @@ def _cached_execution( prepared = self._cached_statement( statement, params, cached, rebound_params, params_are_simple=params_are_simple, compiled_sql=compiled_sql ) + runtime = self._observability + if runtime is not None and not runtime.is_idle: + return self._execute_cached_statement_observed( + prepared, compiled_sql, rebound_params, cached.operation_type + ) return self._execute_cached_statement(prepared) def _execute_cached_statement(self, statement: "SQL") -> "SQLResult | Awaitable[SQLResult]": raise NotImplementedError + def _execute_cached_statement_observed( + self, statement: "SQL", compiled_sql: str, execution_parameters: Any, operation: "OperationType" + ) -> "SQLResult | Awaitable[SQLResult]": + return self._execute_cached_statement(statement) + def _execute_cache_hit( self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" ) -> "SQLResult | Awaitable[SQLResult]": @@ -1120,6 +1151,11 @@ def _execute_cache_hit( ) return self._execute_cached_statement(prepared) + def _execute_cache_hit_observed( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: "CachedQuery" + ) -> "SQLResult | Awaitable[SQLResult]": + return self._execute_cache_hit(sql, params, cached) + def _cache_statement(self, statement: "SQL") -> None: """Store statement in cache if eligible. @@ -1159,6 +1195,8 @@ def _cache_statement(self, statement: "SQL") -> None: return processed = cast("ProcessedState", statement.get_processed_state()) + if processed.parsed_expression is None or processed.operation_type in {"COMMAND", "UNKNOWN"}: + return param_profile = processed.parameter_profile cached_state = _clone_processed_state(processed) cached = CachedQuery( @@ -1171,6 +1209,7 @@ def _cache_statement(self, statement: "SQL") -> None: operation_profile=processed.operation_profile, param_count=param_profile.total_count, processed_state=cached_state, + needs_style_remap=bool(_CACHED_NAMED_STYLES.intersection(param_profile.styles)), ) self._stmt_cache.set(statement.raw_sql, cached) @@ -1465,14 +1504,24 @@ def _compiled_statement( if statement.is_processed: if statement._compiled_from_cache: compiled_sql, execution_parameters = statement.compile() - prepared_parameters = self.prepare_driver_parameters( - execution_parameters, statement_config, is_many=statement.is_many, prepared_statement=statement - ) processed = statement.get_processed_state() + if ( + statement.is_many + and isinstance(execution_parameters, list) + and type(self).prepare_driver_parameters is CommonDriverAttributesMixin.prepare_driver_parameters + and _many_parameters_already_prepared( + execution_parameters, statement_config, processed.applied_wrap_types + ) + ): + prepared_parameters: Any = execution_parameters + else: + prepared_parameters = self.prepare_driver_parameters( + execution_parameters, statement_config, is_many=statement.is_many, prepared_statement=statement + ) cached_statement = CachedStatement( compiled_sql=compiled_sql, parameters=cast("tuple[Any, ...] | list[Any] | dict[str, Any] | None", prepared_parameters), - expression=statement.expression, + expression=statement._peek_expression(), input_named_parameters=processed.input_named_parameters, parameter_profile=processed.parameter_profile, applied_wrap_types=processed.applied_wrap_types, @@ -1493,12 +1542,22 @@ def _compiled_statement( ) return cached_statement, prepared_parameters - prepared_parameters = self.prepare_driver_parameters( - processed.execution_parameters, - statement_config, - is_many=statement.is_many, - prepared_statement=statement, - ) + if ( + statement.is_many + and isinstance(processed.execution_parameters, list) + and type(self).prepare_driver_parameters is CommonDriverAttributesMixin.prepare_driver_parameters + and _many_parameters_already_prepared( + processed.execution_parameters, statement_config, processed.applied_wrap_types + ) + ): + prepared_parameters = processed.execution_parameters + else: + prepared_parameters = self.prepare_driver_parameters( + processed.execution_parameters, + statement_config, + is_many=statement.is_many, + prepared_statement=statement, + ) cached_statement = CachedStatement( compiled_sql=processed.compiled_sql, parameters=cast("tuple[Any, ...] | list[Any] | dict[str, Any] | None", prepared_parameters), @@ -1561,15 +1620,23 @@ def _compiled_statement( compiled_sql, execution_parameters = statement.compile() processed = statement.get_processed_state() - prepared_parameters = self.prepare_driver_parameters( - execution_parameters, statement_config, is_many=statement.is_many, prepared_statement=statement - ) + if ( + statement.is_many + and isinstance(execution_parameters, list) + and type(self).prepare_driver_parameters is CommonDriverAttributesMixin.prepare_driver_parameters + and _many_parameters_already_prepared(execution_parameters, statement_config, processed.applied_wrap_types) + ): + prepared_parameters = execution_parameters + else: + prepared_parameters = self.prepare_driver_parameters( + execution_parameters, statement_config, is_many=statement.is_many, prepared_statement=statement + ) cached_parameters = tuple(prepared_parameters) if isinstance(prepared_parameters, list) else prepared_parameters cached_statement = CachedStatement( compiled_sql=compiled_sql, parameters=cast("tuple[Any, ...] | list[Any] | dict[str, Any] | None", cached_parameters), - expression=statement.expression, + expression=statement._peek_expression(), input_named_parameters=processed.input_named_parameters, parameter_profile=processed.parameter_profile, applied_wrap_types=processed.applied_wrap_types, @@ -1865,11 +1932,22 @@ def parameter_values_need_processing( type_coercion_map: "dict[type, Any] | None", fallback_items: "tuple[tuple[type, Any], ...] | None" = None, ) -> bool: + if not values: + return False + if not type_coercion_map: + if len(values) == 1: + return type(values[0]) is TypedParameter + return any(type(value) is TypedParameter for value in values) if fallback_items is None: fallback_items = type_coercion_fallbacks(type_coercion_map) - if len(values) == 1: - return parameter_value_needs_processing(values[0], type_coercion_map, fallback_items) - return any(parameter_value_needs_processing(value, type_coercion_map, fallback_items) for value in values) + dispatcher = type_coercion_dispatcher(fallback_items) + for value in values: + value_type = type(value) + if value_type is TypedParameter or value_type in type_coercion_map: + return True + if dispatcher.resolve_type(value_type) is not None: + return True + return False def parameter_value_needs_processing( @@ -1887,7 +1965,31 @@ def parameter_value_needs_processing( return True if fallback_items is None: fallback_items = type_coercion_fallbacks(type_coercion_map) - return type_coercion_dispatcher(fallback_items).get(value) is not None + return type_coercion_dispatcher(fallback_items).resolve_type(value_type) is not None + + +def _resolve_fast_params( + parameters: "tuple[Any, ...]", kwargs: "dict[str, Any]" +) -> "tuple[Any, ...] | list[Any] | dict[str, Any] | None": + """Normalize execute(*parameters, **kwargs) arguments for fast-path cache lookup.""" + if kwargs: + if not parameters: + return kwargs + return None + param_len = len(parameters) + if param_len == 0: + return () + if param_len == 1: + first = parameters[0] + if isinstance(first, (tuple, list, dict)): + return first + if is_statement_filter(first): + return None + return parameters + for item in parameters: + if is_statement_filter(item) or isinstance(item, (tuple, list, dict)): + return None + return parameters def _raise_database_exception( @@ -2148,6 +2250,29 @@ def _check_declared_named_row(declared: "tuple[ParameterDeclaration, ...]", supp raise SQLSpecError(msg) +def _many_parameters_already_prepared( + parameters: "list[Any]", statement_config: "StatementConfig", applied_wrap_types: bool +) -> bool: + """Return True when execute_many parameters were already coerced during compile().""" + if statement_config.parameter_config.type_coercion_map: + return True + if applied_wrap_types: + return False + if not parameters: + return True + first = parameters[0] + if isinstance(first, (tuple, list)): + return not any(type(v) is TypedParameter for v in first) + if isinstance(first, dict): + return not any(type(v) is TypedParameter for v in first.values()) + return type(first) is not TypedParameter + + +def _row_needs_declared_default(declared: "tuple[ParameterDeclaration, ...]", supplied: "dict[str, Any]") -> bool: + """Return True when any optional declared parameter is missing from supplied.""" + return any(not declaration.required and declaration.name not in supplied for declaration in declared) + + def _check_declared_parameters(sql_statement: "SQL") -> None: """Enforce declared-parameter contracts on a copy of the user params. @@ -2162,18 +2287,28 @@ def _check_declared_parameters(sql_statement: "SQL") -> None: if sql_statement.is_many: rows = sql_statement.positional_parameters if rows and isinstance(rows[0], dict): - copied_rows: list[Any] = [] - for row in rows: + copied_rows: list[Any] | None = None + for idx, row in enumerate(rows): if isinstance(row, dict): - row_copy = dict(row) - _check_declared_named_row(declared, row_copy) - copied_rows.append(row_copy) - else: + if _row_needs_declared_default(declared, row): + row_copy = dict(row) + _check_declared_named_row(declared, row_copy) + if copied_rows is None: + copied_rows = list(rows[:idx]) + copied_rows.append(row_copy) + continue + _check_declared_named_row(declared, row) + if copied_rows is not None: copied_rows.append(row) - sql_statement._positional_parameters = copied_rows + if copied_rows is not None: + sql_statement._positional_parameters = copied_rows return if sql_statement.positional_parameters: return - named_copy = dict(sql_statement.named_parameters) - _check_declared_named_row(declared, named_copy) - sql_statement._named_parameters = named_copy + named_params = sql_statement.named_parameters + if _row_needs_declared_default(declared, named_params): + named_copy = dict(named_params) + _check_declared_named_row(declared, named_copy) + sql_statement._named_parameters = named_copy + else: + _check_declared_named_row(declared, named_params) diff --git a/sqlspec/driver/_query_cache.py b/sqlspec/driver/_query_cache.py index 0ce0d3cfc..f5233b1ad 100644 --- a/sqlspec/driver/_query_cache.py +++ b/sqlspec/driver/_query_cache.py @@ -1,18 +1,29 @@ """Query cache for fast-path statement execution.""" +import threading from collections import OrderedDict from typing import TYPE_CHECKING, Final, final from mypy_extensions import mypyc_attr +from sqlspec.core.parameters import ParameterProcessor, ParameterStyle + if TYPE_CHECKING: + from collections.abc import Mapping + from sqlspec.core.compiler import OperationProfile, OperationType from sqlspec.core.parameters import ParameterProfile - from sqlspec.core.statement import ProcessedState + from sqlspec.core.statement import SQL, ProcessedState, StatementConfig -__all__ = ("STMT_CACHE_MAX_SIZE", "CachedQuery", "QueryCache") +__all__ = ("STMT_CACHE_MAX_SIZE", "CachedQuery", "QueryCache", "StatementCacheHolder", "resolve_statement_cache_size") STMT_CACHE_MAX_SIZE: Final[int] = 1024 +_CACHED_NAMED_STYLES: Final[frozenset[str]] = frozenset(( + ParameterStyle.NAMED_COLON.value, + ParameterStyle.NAMED_AT.value, + ParameterStyle.NAMED_DOLLAR.value, + ParameterStyle.NAMED_PYFORMAT.value, +)) @final @@ -29,11 +40,13 @@ class CachedQuery: operation_profile: "OperationProfile" param_count: int processed_state: "ProcessedState" + needs_style_remap: bool __slots__ = ( "applied_wrap_types", "compiled_sql", "input_named_parameters", + "needs_style_remap", "operation_profile", "operation_type", "param_count", @@ -53,6 +66,7 @@ def __init__( operation_profile: "OperationProfile", param_count: int, processed_state: "ProcessedState", + needs_style_remap: bool | None = None, ) -> None: self.compiled_sql = compiled_sql self.parameter_profile = parameter_profile @@ -63,6 +77,11 @@ def __init__( self.operation_profile = operation_profile self.param_count = param_count self.processed_state = processed_state + self.needs_style_remap = ( + bool(_CACHED_NAMED_STYLES.intersection(parameter_profile.styles)) + if needs_style_remap is None + else needs_style_remap + ) @final @@ -70,31 +89,162 @@ def __init__( class QueryCache: """LRU cache for compiled query metadata.""" - __slots__ = ("_cache", "_max_size") + __slots__ = ("_cache", "_lock", "_max_size") def __init__(self, max_size: int = STMT_CACHE_MAX_SIZE) -> None: self._cache: OrderedDict[str, CachedQuery] = OrderedDict() + self._lock = threading.Lock() self._max_size = max_size def __len__(self) -> int: - return len(self._cache) + with self._lock: + return len(self._cache) def get(self, sql: str) -> "CachedQuery | None": - entry = self._cache.get(sql) - if entry is None: - return None - self._cache.move_to_end(sql) - return entry + with self._lock: + entry = self._cache.get(sql) + if entry is None: + return None + self._cache.move_to_end(sql) + return entry def set(self, sql: str, entry: "CachedQuery") -> None: if self._max_size <= 0: return - if sql in self._cache: - self._cache.move_to_end(sql) - elif len(self._cache) >= self._max_size: - self._cache.popitem(last=False) - self._cache[sql] = entry + with self._lock: + if sql in self._cache: + self._cache.move_to_end(sql) + elif len(self._cache) >= self._max_size: + self._cache.popitem(last=False) + self._cache[sql] = entry + + def clear(self) -> None: + """Clear all cached entries.""" + with self._lock: + self._cache.clear() + + +@final +@mypyc_attr(allow_interpreted_subclasses=False) +class StatementCacheHolder: + """Shared per-config holder for statement caches, rebind processor, and execution hints.""" + + statement_config: "StatementConfig" + max_size: int + query_cache: QueryCache + rebind_processor: ParameterProcessor + _statement_cache: "OrderedDict[str, SQL]" + _row_count_hints: "OrderedDict[str, int]" + _bulk_insert_sql: "OrderedDict[str, tuple[str, str]]" + _lock: threading.Lock + + __slots__ = ( + "_bulk_insert_sql", + "_lock", + "_row_count_hints", + "_statement_cache", + "max_size", + "query_cache", + "rebind_processor", + "statement_config", + ) + + def __init__(self, statement_config: "StatementConfig", max_size: int = STMT_CACHE_MAX_SIZE) -> None: + self.statement_config = statement_config + self.max_size = max_size + self.query_cache = QueryCache(max_size) + self.rebind_processor = ParameterProcessor( + converter=statement_config.parameter_converter, + validator=statement_config.parameter_validator, + cache_max_size=0, + validator_cache_max_size=0, + ) + self._statement_cache = OrderedDict() + self._row_count_hints = OrderedDict() + self._bulk_insert_sql = OrderedDict() + self._lock = threading.Lock() + + def get_statement(self, sql: str) -> "SQL | None": + """Retrieve a cached SQL object and mark it as recently used.""" + with self._lock: + entry = self._statement_cache.get(sql) + if entry is None: + return None + self._statement_cache.move_to_end(sql) + return entry + + def set_statement(self, sql: str, stmt: "SQL", max_size: int | None = None) -> None: + """Store a prepared SQL object in the bounded LRU cache.""" + effective_max = self.max_size if max_size is None else max_size + if effective_max <= 0: + return + with self._lock: + if sql in self._statement_cache: + self._statement_cache.move_to_end(sql) + else: + while len(self._statement_cache) >= effective_max: + self._statement_cache.popitem(last=False) + self._statement_cache[sql] = stmt + + def get_row_count_hint(self, compiled_sql: str) -> int | None: + """Return the last observed row count for a compiled SQL string.""" + with self._lock: + entry = self._row_count_hints.get(compiled_sql) + if entry is None: + return None + self._row_count_hints.move_to_end(compiled_sql) + return entry + + def set_row_count_hint(self, compiled_sql: str, row_count: int) -> None: + """Record the observed row count for a compiled SQL string.""" + if self.max_size <= 0: + return + with self._lock: + if compiled_sql in self._row_count_hints: + self._row_count_hints.move_to_end(compiled_sql) + elif len(self._row_count_hints) >= self.max_size: + self._row_count_hints.popitem(last=False) + self._row_count_hints[compiled_sql] = row_count + + def get_bulk_insert_sql(self, compiled_sql: str) -> "tuple[str, str] | None": + """Return cached (table_sql, columns_sql) fragments for a bulk INSERT statement.""" + with self._lock: + entry = self._bulk_insert_sql.get(compiled_sql) + if entry is None: + return None + self._bulk_insert_sql.move_to_end(compiled_sql) + return entry + + def set_bulk_insert_sql(self, compiled_sql: str, entry: "tuple[str, str]") -> None: + """Cache (table_sql, columns_sql) fragments for a bulk INSERT statement.""" + if self.max_size <= 0: + return + with self._lock: + if compiled_sql in self._bulk_insert_sql: + self._bulk_insert_sql.move_to_end(compiled_sql) + elif len(self._bulk_insert_sql) >= self.max_size: + self._bulk_insert_sql.popitem(last=False) + self._bulk_insert_sql[compiled_sql] = entry def clear(self) -> None: """Clear all cached entries.""" - self._cache.clear() + self.query_cache.clear() + with self._lock: + self._statement_cache.clear() + self._row_count_hints.clear() + self._bulk_insert_sql.clear() + + +def resolve_statement_cache_size(driver_features: "Mapping[str, object] | None") -> int: + """Resolve the statement cache capacity from driver_features.""" + if not driver_features: + return STMT_CACHE_MAX_SIZE + cache_size = driver_features.get("sqlspec_statement_cache_size", STMT_CACHE_MAX_SIZE) + if isinstance(cache_size, int): + return max(0, cache_size) + if isinstance(cache_size, str): + try: + return max(0, int(cache_size)) + except ValueError: + return STMT_CACHE_MAX_SIZE + return STMT_CACHE_MAX_SIZE diff --git a/sqlspec/driver/_sync.py b/sqlspec/driver/_sync.py index 52c26d6f6..e8b9411c5 100644 --- a/sqlspec/driver/_sync.py +++ b/sqlspec/driver/_sync.py @@ -1,6 +1,7 @@ """Synchronous driver protocol implementation.""" import logging +import sys from abc import abstractmethod from time import perf_counter from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Literal, Self, TypeVar, cast, final, overload @@ -29,6 +30,7 @@ StackExecutionObserver, SyncExceptionHandler, _raise_database_exception, + _resolve_fast_params, describe_stack_statement, handle_single_row_error, validate_savepoint_name, @@ -54,7 +56,7 @@ from sqlglot.dialects.dialect import DialectType from sqlspec.builder import QueryBuilder - from sqlspec.core import ArrowResult, SQLResult, Statement, StatementConfig, StatementFilter + from sqlspec.core import ArrowResult, OperationType, SQLResult, Statement, StatementConfig, StatementFilter from sqlspec.data_dictionary import ( ColumnMetadata, ForeignKeyMetadata, @@ -211,81 +213,34 @@ def dispatch_statement_execution(self, statement: "SQL", connection: "Any") -> " if runtime is None or runtime.is_idle: exc_handler = self.handle_database_exceptions() - result = self._run_with_exception_handler( - exc_handler, - self._dispatch_statement_with_cursor, - connection, - statement, - has_execution_parameters=bool(execution_parameters), - ) + if sys.exc_info()[1] is None: + with exc_handler: + result = self._dispatch_statement_with_cursor( + connection, statement, has_execution_parameters=bool(execution_parameters) + ) + else: + result = self._run_with_exception_handler( + exc_handler, + self._dispatch_statement_with_cursor, + connection, + statement, + has_execution_parameters=bool(execution_parameters), + ) self._check_pending_exception(exc_handler) assert result is not None return result - operation = statement.operation_type - query_context = { - "sql": compiled_sql, - "parameters": execution_parameters, - "driver": type(self).__name__, - "operation": operation, - "is_many": statement.is_many, - "is_script": statement.is_script, - } - runtime.emit_query_start_sync(**query_context) - sql_hash = None - if runtime.span_manager.is_enabled or ( - runtime.has_statement_observers and runtime.config.logging and runtime.config.logging.include_sql_hash - ): - sql_hash = observability_runtime.compute_sql_hash(compiled_sql) - span = runtime.start_query_span(compiled_sql, operation, type(self).__name__, sql_hash=sql_hash) - started = perf_counter() - exc_handler = self.handle_database_exceptions() - try: - result = self._run_with_exception_handler( - exc_handler, - self._dispatch_statement_with_cursor, - connection, - statement, - has_execution_parameters=bool(execution_parameters), - ) - except Exception as exc: # pragma: no cover - pending_exception = exc_handler.pending_exception - if pending_exception is not None: - mapped_exc = pending_exception - runtime.span_manager.end_span(span, error=mapped_exc) - runtime.emit_error_sync(mapped_exc, **query_context) - _raise_database_exception(exc_handler, exc) - runtime.span_manager.end_span(span, error=exc) - runtime.emit_error_sync(exc, **query_context) - _raise_database_exception(exc_handler, exc) - - pending_exception = exc_handler.pending_exception - if pending_exception is not None: - mapped_exc = pending_exception - runtime.span_manager.end_span(span, error=mapped_exc) - runtime.emit_error_sync(mapped_exc, **query_context) - _raise_database_exception(exc_handler, None) - - assert result is not None - - runtime.span_manager.end_span(span) - duration = perf_counter() - started - runtime.emit_query_complete_sync(**{**query_context, "rows_affected": result.rows_affected}) - runtime.emit_statement_event( - sql=compiled_sql, - parameters=execution_parameters, - driver=type(self).__name__, - operation=operation, - execution_mode=self.statement_config.execution_mode, + return self._execute_with_observability_sync( + compiled_sql=compiled_sql, + execution_parameters=execution_parameters, + operation=statement.operation_type, is_many=statement.is_many, is_script=statement.is_script, - rows_affected=result.rows_affected, - duration_s=duration, - storage_backend=(result.metadata or {}).get("storage_backend"), - started_at=started, - precomputed_sql_hash=sql_hash, + runner=self._dispatch_statement_observed_sync, + connection=connection, + statement=statement, + has_execution_parameters=bool(execution_parameters), ) - return result finally: self._release_pooled_statement(statement) @@ -479,11 +434,7 @@ def execute( **kwargs: Any, ) -> "SQLResult": """Execute a statement with parameter handling.""" - fast_params: tuple[Any, ...] | list[Any] | dict[str, Any] | None = None - if not kwargs and len(parameters) == 1 and isinstance(parameters[0], (tuple, list, dict)): - fast_params = cast("tuple[Any, ...] | list[Any] | dict[str, Any]", parameters[0]) - elif not parameters and kwargs: - fast_params = kwargs + fast_params = _resolve_fast_params(parameters, kwargs) if ( self._stmt_cache_enabled and (statement_config is None or statement_config is self.statement_config) @@ -1508,6 +1459,90 @@ def _run_with_exception_handler( """Run a sync operation without inheriting the caller's active exception state.""" return _run_with_sync_exception_handler(exc_handler, operation, *args, **kwargs) + def _execute_with_observability_sync( + self, + *, + compiled_sql: str, + execution_parameters: Any, + operation: "OperationType", + is_many: bool, + is_script: bool, + runner: "Callable[..., SQLResult]", + **runner_kwargs: Any, + ) -> "SQLResult": + """Execute a sync runner while emitting spans, lifecycle hooks, and statement events.""" + runtime = self._observability + if runtime is None or runtime.is_idle: + return runner(**runner_kwargs) + driver_name = type(self).__name__ + query_context = { + "sql": compiled_sql, + "parameters": execution_parameters, + "driver": driver_name, + "operation": operation, + "is_many": is_many, + "is_script": is_script, + } + runtime.emit_query_start_sync(**query_context) + sql_hash = None + if runtime.span_manager.is_enabled or ( + runtime.has_statement_observers and runtime.config.logging and runtime.config.logging.include_sql_hash + ): + sql_hash = observability_runtime.compute_sql_hash(compiled_sql) + span = runtime.start_query_span(compiled_sql, operation, driver_name, sql_hash=sql_hash) + started = perf_counter() + try: + result = runner(**runner_kwargs) + except Exception as exc: + runtime.span_manager.end_span(span, error=exc) + runtime.emit_error_sync(exc, **query_context) + raise + runtime.span_manager.end_span(span) + duration = perf_counter() - started + runtime.emit_query_complete_sync(**{**query_context, "rows_affected": result.rows_affected}) + runtime.emit_statement_event( + sql=compiled_sql, + parameters=execution_parameters, + driver=driver_name, + operation=operation, + execution_mode=self.statement_config.execution_mode, + is_many=is_many, + is_script=is_script, + rows_affected=result.rows_affected, + duration_s=duration, + storage_backend=(result.metadata or {}).get("storage_backend"), + started_at=started, + precomputed_sql_hash=sql_hash, + ) + return result + + def _dispatch_statement_observed_sync( + self, connection: Any, statement: "SQL", *, has_execution_parameters: bool + ) -> "SQLResult": + """Execute a statement inside an exception handler and raise any mapped error.""" + exc_handler = self.handle_database_exceptions() + result: SQLResult | None = None + try: + if sys.exc_info()[1] is None: + with exc_handler: + result = self._dispatch_statement_with_cursor( + connection, statement, has_execution_parameters=has_execution_parameters + ) + else: + result = self._run_with_exception_handler( + exc_handler, + self._dispatch_statement_with_cursor, + connection, + statement, + has_execution_parameters=has_execution_parameters, + ) + except Exception as exc: # pragma: no cover + _raise_database_exception(exc_handler, exc) + if exc_handler.pending_exception is not None: + _raise_database_exception(exc_handler, None) + assert result is not None + return result + def _execute_cache_hit( self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: CachedQuery ) -> "SQLResult": @@ -1526,11 +1561,35 @@ def _execute_cache_hit( SQLResult or DMLResult. """ exc_handler = self.handle_database_exceptions() - result = self._run_with_exception_handler(exc_handler, self._execute_cache_hit_with_cursor, sql, params, cached) + result: SQLResult | None = None + if sys.exc_info()[1] is None: + with exc_handler: + result = self._execute_cache_hit_with_cursor(sql, params, cached) + else: + result = self._run_with_exception_handler( + exc_handler, self._execute_cache_hit_with_cursor, sql, params, cached + ) self._check_pending_exception(exc_handler) assert result is not None return result + def _execute_cache_hit_observed( + self, sql: str, params: "tuple[Any, ...] | list[Any] | dict[str, Any]", cached: CachedQuery + ) -> "SQLResult": + """Execute a fast-path cache hit wrapped in observability instrumentation.""" + obs_params = list(params) if isinstance(params, tuple) else params + return self._execute_with_observability_sync( + compiled_sql=cached.compiled_sql, + execution_parameters=obs_params, + operation=cached.operation_type, + is_many=False, + is_script=False, + runner=self._execute_cache_hit, + sql=sql, + params=params, + cached=cached, + ) + def _execute_cached_statement(self, statement: "SQL") -> "SQLResult": """Execute pre-compiled query via fast path. @@ -1538,16 +1597,36 @@ def _execute_cached_statement(self, statement: "SQL") -> "SQLResult": will hit the fast path in _compiled_statement (is_processed check). """ exc_handler = self.handle_database_exceptions() + result: SQLResult | None = None try: - result = self._run_with_exception_handler( - exc_handler, self._execute_cached_statement_with_cursor, statement - ) + if sys.exc_info()[1] is None: + with exc_handler: + result = self._execute_cached_statement_with_cursor(statement) + else: + result = self._run_with_exception_handler( + exc_handler, self._execute_cached_statement_with_cursor, statement + ) self._check_pending_exception(exc_handler) assert result is not None return result finally: self._release_pooled_statement(statement) + def _execute_cached_statement_observed( + self, statement: "SQL", compiled_sql: str, execution_parameters: Any, operation: "OperationType" + ) -> "SQLResult": + """Execute a rebound cached statement wrapped in observability instrumentation.""" + obs_params = list(execution_parameters) if isinstance(execution_parameters, tuple) else execution_parameters + return self._execute_with_observability_sync( + compiled_sql=compiled_sql, + execution_parameters=obs_params, + operation=operation, + is_many=False, + is_script=False, + runner=self._execute_cached_statement, + statement=statement, + ) + def _dispatch_statement_with_cursor( self, connection: Any, statement: "SQL", *, has_execution_parameters: bool ) -> "SQLResult": @@ -1597,31 +1676,31 @@ def _execute_cache_hit_with_cursor( result: SQLResult | None = None try: with self.with_cursor(self.connection) as cursor: - execute = getattr(cursor, "execute", None) - fetchall = getattr(cursor, "fetchall", None) returns_rows = cached.operation_profile.returns_rows + execute = getattr(cursor, "execute", None) if cursor is not self.connection else None + fetchall = getattr(cursor, "fetchall", None) if execute is not None and returns_rows else None can_use_cursor_fast_path = execute is not None and ( (returns_rows and fetchall is not None) or (not returns_rows and hasattr(cursor, "rowcount")) ) if can_use_cursor_fast_path: assert execute is not None - execute(cached.compiled_sql, params) + if params: + execute(cached.compiled_sql, params) + else: + execute(cached.compiled_sql) if returns_rows: assert fetchall is not None fetched_data = fetchall() data, column_names, row_count = self.collect_rows(cursor, fetched_data) - execution_result = self.create_execution_result( - cursor, + result = self.build_cached_select_result( + sql, + params, + cached, selected_data=data, column_names=column_names, data_row_count=row_count, - is_select_result=True, row_format=resolve_row_format(data), ) - direct_statement = self._cached_statement( - sql, params, cached, params, params_are_simple=True, compiled_sql=cached.compiled_sql - ) - result = self.build_statement_result(direct_statement, execution_result) else: affected_rows = self.resolve_rowcount(cursor) result = DMLResult(cached.operation_type, affected_rows) diff --git a/sqlspec/extensions/adk/memory/store.py b/sqlspec/extensions/adk/memory/store.py index ce0731618..8a437ac91 100644 --- a/sqlspec/extensions/adk/memory/store.py +++ b/sqlspec/extensions/adk/memory/store.py @@ -13,7 +13,7 @@ from sqlspec.utils.sync_tools import async_ if TYPE_CHECKING: - from collections.abc import Callable, Sequence + from collections.abc import Sequence from sqlspec.config import DatabaseConfigProtocol from sqlspec.extensions.adk.memory._types import StoredMemory @@ -31,9 +31,6 @@ class _ADKMemoryStoreCommon(Generic[ConfigT]): """Shared non-async ADK store state and helpers.""" - if TYPE_CHECKING: - _drop_memory_table_sql: "Callable[[], list[str]]" - __slots__ = ( "_config", "_enable_bm25", @@ -148,6 +145,10 @@ def _schema_management_flags(self) -> tuple[bool, bool]: create_schema = adk_config.get("create_schema", True) if isinstance(adk_config, dict) else True return bool(manage_schema), bool(create_schema) + def _drop_memory_table_sql(self) -> "list[str]": + """Get the DROP TABLE SQL statements for this database dialect.""" + raise NotImplementedError + def _drop_sql_for_table(self, table_name: str) -> list[str]: current_table = self._memory_table self._memory_table = table_name diff --git a/sqlspec/extensions/adk/store.py b/sqlspec/extensions/adk/store.py index e4caa6fcb..3b5af519a 100644 --- a/sqlspec/extensions/adk/store.py +++ b/sqlspec/extensions/adk/store.py @@ -14,8 +14,6 @@ from sqlspec.utils.sync_tools import async_ if TYPE_CHECKING: - from collections.abc import Callable - from sqlspec.config import DatabaseConfigProtocol from sqlspec.extensions.adk._types import SessionOrderBy, StoredEvent, StoredSession @@ -38,9 +36,6 @@ class _ADKStoreCommon(Generic[ConfigT]): """Shared non-async ADK store state and helpers.""" - if TYPE_CHECKING: - _drop_tables_sql: "Callable[[], list[str]]" - __slots__ = ( "_app_state_table", "_config", @@ -130,6 +125,10 @@ def create_schema_enabled(self) -> bool: manage_schema, create_schema = self._schema_management_flags() return manage_schema and create_schema + def _drop_tables_sql(self) -> "list[str]": + """Get the DROP TABLE SQL statements for this database dialect.""" + raise NotImplementedError + def _reset_drop_statements(self) -> "list[str]": """Return all table drops needed before recreating the clean-break schema.""" statements = list(self._drop_tables_sql()) diff --git a/sqlspec/utils/dispatch.py b/sqlspec/utils/dispatch.py index 862a8c630..8a117af69 100644 --- a/sqlspec/utils/dispatch.py +++ b/sqlspec/utils/dispatch.py @@ -2,7 +2,7 @@ from mypy_extensions import mypyc_attr -__all__ = ("TypeDispatcher",) +__all__ = ("_NO_MATCH", "TypeDispatcher") T = TypeVar("T") diff --git a/tests/integration/adapters/duckdb/duckdb/test_driver.py b/tests/integration/adapters/duckdb/duckdb/test_driver.py index e1b458397..da041ed14 100644 --- a/tests/integration/adapters/duckdb/duckdb/test_driver.py +++ b/tests/integration/adapters/duckdb/duckdb/test_driver.py @@ -9,6 +9,17 @@ from sqlspec.exceptions import SQLBuilderError from tests.conftest import requires_interpreted +__all__ = ( + "duckdb_session", + "test_duckdb_data_types", + "test_duckdb_error_handling_and_edge_cases", + "test_duckdb_integration_native_type_contract_parity", + "test_duckdb_schema_operations", + "test_duckdb_statement_stack_continue_on_error", + "test_duckdb_unsupported_lock_raises", + "test_duckdb_window_functions", +) + pytestmark = pytest.mark.xdist_group("duckdb") @@ -210,3 +221,31 @@ def test_duckdb_statement_stack_continue_on_error(duckdb_session: DuckDBDriver) verify = duckdb_session.execute("SELECT COUNT(*) AS total FROM test_table") assert verify.data is not None assert verify.get_data()[0]["total"] == 2 + + +@pytest.mark.parametrize( + "sql_expr", + [ + "1::HUGEINT", + "1::UHUGEINT", + "'101'::BIT", + "'12:00:00+00'::TIMETZ", + "'2026-01-01 00:00:00'::TIMESTAMP_NS", + "INTERVAL 1 DAY", + "'550e8400-e29b-41d4-a716-446655440000'::UUID", + "[1, 2, 3]::INT[3]", + "MAP {'a': 1}", + "[INTERVAL 1 DAY]", + "{'u': '550e8400-e29b-41d4-a716-446655440000'::UUID}", + "1::BIGNUM", + ], +) +def test_duckdb_integration_native_type_contract_parity(duckdb_session: DuckDBDriver, sql_expr: str) -> None: + """Integration check that first and cached DuckDB queries return raw fetchall() Python types.""" + expected = duckdb_session.connection.execute(f"SELECT {sql_expr} AS val").fetchall()[0][0] + query = f"SELECT {sql_expr} AS val WHERE ? >= 0" + for index in range(3): + result = duckdb_session.execute(query, (index,)) + actual = result.one()["val"] + assert type(actual) is type(expected) + assert actual == expected diff --git a/tests/unit/adapters/test_aiosqlite/test_pool.py b/tests/unit/adapters/test_aiosqlite/test_pool.py index cf6d81f70..403f1db80 100644 --- a/tests/unit/adapters/test_aiosqlite/test_pool.py +++ b/tests/unit/adapters/test_aiosqlite/test_pool.py @@ -5,9 +5,11 @@ from pathlib import Path from threading import Thread from typing import TYPE_CHECKING, Any, Literal, cast +from unittest.mock import AsyncMock import pytest +from sqlspec.adapters.aiosqlite import pool as pool_module from sqlspec.adapters.aiosqlite.pool import ( SQLITE_BUSY_TIMEOUT, SQLITE_DEFAULT_ENABLE_FOREIGN_KEYS, @@ -24,6 +26,28 @@ if TYPE_CHECKING: from sqlspec.adapters.aiosqlite._typing import AiosqliteConnection +__all__ = ( + "test_acquire_and_release_idle_fast_path", + "test_acquire_enforces_connect_timeout_on_pool_exhaustion", + "test_close_continues_after_a_native_connection_was_closed", + "test_concurrent_first_acquire_on_new_file_database_succeeds", + "test_default_pool_uses_native_like_shared_pragma_profile", + "test_disable_optimizations_preserves_native_memory_profile", + "test_file_pool_uses_wal_normal_and_shared_busy_timeout", + "test_file_pragma_script_fallback_replays_file_profile", + "test_optimization_and_foreign_key_flags_are_independent", + "test_post_connect_failure_closes_raw_connection", + "test_pragma_script_fallback_continues_after_optimization_failure", + "test_pragma_script_fallback_honors_independent_flags", + "test_pragma_script_fallback_surfaces_explicit_foreign_key_failure", + "test_pragma_script_fallback_uses_native_like_defaults", + "test_shared_memory_pragma_script_fallback_replays_read_uncommitted", + "test_shared_memory_preserves_read_uncommitted", + "test_sqlite_family_pragma_defaults_are_equal", + "test_wal_setup_failure_propagates_from_new_connection", + "test_wal_setup_retries_locks_and_propagates_errors", +) + pytest.importorskip("aiosqlite", reason="aiosqlite adapter requires the aiosqlite package") @@ -34,27 +58,6 @@ def test_sqlite_family_pragma_defaults_are_equal() -> None: assert SQLITE_DEFAULT_ENABLE_FOREIGN_KEYS is SYNC_SQLITE_DEFAULT_ENABLE_FOREIGN_KEYS is False -async def _read_pragma( - connection: "AiosqliteConnection", - statement: Literal[ - "PRAGMA foreign_keys", - "PRAGMA busy_timeout", - "PRAGMA cache_size", - "PRAGMA journal_mode", - "PRAGMA synchronous", - "PRAGMA temp_store", - "PRAGMA read_uncommitted", - ], -) -> int | str: - cursor = await connection.execute(statement) - try: - row = await cursor.fetchone() - assert row is not None - return cast("int | str", row[0]) - finally: - await cursor.close() - - async def test_default_pool_uses_native_like_shared_pragma_profile() -> None: pool = AiosqliteConnectionPool({"database": ":memory:", "timeout": 30.0}) pool_connection = await pool.acquire() @@ -155,44 +158,7 @@ async def test_shared_memory_preserves_read_uncommitted() -> None: await pool.close() -class _FallbackConnection: - def __init__(self, fail_statement: str | None = None, *, script_fails: bool = True) -> None: - self.executed: list[str] = [] - self.fail_statement = fail_statement - self.script_fails = script_fails - self.closed = False - - async def executescript(self, _script: str) -> None: - if self.script_fails: - raise RuntimeError("script unavailable") - - async def execute(self, statement: str) -> None: - self.executed.append(statement) - if statement == self.fail_statement: - raise RuntimeError("individual pragma failed") - - async def commit(self) -> None: - return None - - async def close(self) -> None: - self.closed = True - - -class _FallbackConnectProxy: - def __init__(self, connection: _FallbackConnection) -> None: - self._thread = Thread(target=lambda: None) - self._connection = connection - - def __await__(self) -> Any: - async def resolve() -> _FallbackConnection: - return self._connection - - return resolve().__await__() - - async def test_pragma_script_fallback_uses_native_like_defaults(monkeypatch: pytest.MonkeyPatch) -> None: - from sqlspec.adapters.aiosqlite import pool as pool_module - connection = _FallbackConnection() monkeypatch.setattr(pool_module.aiosqlite, "connect", lambda **_: _FallbackConnectProxy(connection)) pool = AiosqliteConnectionPool({"database": ":memory:"}) @@ -210,8 +176,6 @@ async def test_pragma_script_fallback_uses_native_like_defaults(monkeypatch: pyt async def test_pragma_script_fallback_honors_independent_flags(monkeypatch: pytest.MonkeyPatch) -> None: - from sqlspec.adapters.aiosqlite import pool as pool_module - connection = _FallbackConnection() monkeypatch.setattr(pool_module.aiosqlite, "connect", lambda **_: _FallbackConnectProxy(connection)) pool = AiosqliteConnectionPool({"database": ":memory:"}, enable_optimizations=False, enable_foreign_keys=True) @@ -223,8 +187,6 @@ async def test_pragma_script_fallback_honors_independent_flags(monkeypatch: pyte async def test_pragma_script_fallback_surfaces_explicit_foreign_key_failure(monkeypatch: pytest.MonkeyPatch) -> None: - from sqlspec.adapters.aiosqlite import pool as pool_module - connection = _FallbackConnection(fail_statement="PRAGMA foreign_keys = ON") monkeypatch.setattr(pool_module.aiosqlite, "connect", lambda **_: _FallbackConnectProxy(connection)) pool = AiosqliteConnectionPool({"database": ":memory:"}, enable_foreign_keys=True) @@ -244,8 +206,6 @@ async def test_pragma_script_fallback_surfaces_explicit_foreign_key_failure(monk async def test_pragma_script_fallback_continues_after_optimization_failure(monkeypatch: pytest.MonkeyPatch) -> None: - from sqlspec.adapters.aiosqlite import pool as pool_module - connection = _FallbackConnection(fail_statement="PRAGMA synchronous = OFF") monkeypatch.setattr(pool_module.aiosqlite, "connect", lambda **_: _FallbackConnectProxy(connection)) pool = AiosqliteConnectionPool({"database": ":memory:"}) @@ -263,8 +223,6 @@ async def test_pragma_script_fallback_continues_after_optimization_failure(monke async def test_file_pragma_script_fallback_replays_file_profile(monkeypatch: pytest.MonkeyPatch) -> None: - from sqlspec.adapters.aiosqlite import pool as pool_module - connection = _FallbackConnection() monkeypatch.setattr(pool_module.aiosqlite, "connect", lambda **_: _FallbackConnectProxy(connection)) pool = AiosqliteConnectionPool({"database": "profile.db", "timeout": 30.0}) @@ -280,8 +238,6 @@ async def test_file_pragma_script_fallback_replays_file_profile(monkeypatch: pyt async def test_shared_memory_pragma_script_fallback_replays_read_uncommitted(monkeypatch: pytest.MonkeyPatch) -> None: - from sqlspec.adapters.aiosqlite import pool as pool_module - connection = _FallbackConnection() monkeypatch.setattr(pool_module.aiosqlite, "connect", lambda **_: _FallbackConnectProxy(connection)) pool = AiosqliteConnectionPool({"database": "file:fallback?mode=memory&cache=shared", "uri": True, "timeout": 30.0}) @@ -303,8 +259,6 @@ async def test_shared_memory_pragma_script_fallback_replays_read_uncommitted(mon async def test_post_connect_failure_closes_raw_connection( error: BaseException, monkeypatch: pytest.MonkeyPatch ) -> None: - from sqlspec.adapters.aiosqlite import pool as pool_module - connection = _FallbackConnection(script_fails=False) monkeypatch.setattr(pool_module.aiosqlite, "connect", lambda **_: _FallbackConnectProxy(connection)) @@ -362,10 +316,6 @@ async def acquire(pool: AiosqliteConnectionPool) -> str | int: async def test_wal_setup_retries_locks_and_propagates_errors( monkeypatch: pytest.MonkeyPatch, message: str, failures: int, calls: int ) -> None: - from unittest.mock import AsyncMock - - from sqlspec.adapters.aiosqlite import pool as pool_module - connection = AsyncMock() connection.execute.side_effect = [sqlite3.OperationalError(message)] * failures + [None] monkeypatch.setattr(pool_module.asyncio, "sleep", AsyncMock()) @@ -380,10 +330,6 @@ async def test_wal_setup_retries_locks_and_propagates_errors( async def test_wal_setup_failure_propagates_from_new_connection( monkeypatch: pytest.MonkeyPatch, tmp_path: Path ) -> None: - from unittest.mock import AsyncMock - - from sqlspec.adapters.aiosqlite import pool as pool_module - monkeypatch.setattr( pool_module, "_enable_wal", AsyncMock(side_effect=sqlite3.OperationalError("database is locked")) ) @@ -407,3 +353,112 @@ async def test_close_continues_after_a_native_connection_was_closed() -> None: finally: await second.connection.close() await pool.close() + + +async def test_acquire_and_release_idle_fast_path(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Warm idle connection acquire skips asyncio.wait_for and clean release skips end_transaction.""" + pool = AiosqliteConnectionPool({"database": str(tmp_path / "fast_path.sqlite")}, pool_size=2, min_size=1) + try: + async with pool.get_connection() as conn: + cursor = await conn.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)") + await cursor.close() + await conn.commit() + + wait_for_calls = 0 + end_tx_calls = 0 + original_wait_for = pool_module.asyncio.wait_for + original_end_tx = pool_module.end_transaction + + async def spy_wait_for(aw: Any, timeout: float | None = None) -> Any: + nonlocal wait_for_calls + wait_for_calls += 1 + return await original_wait_for(aw, timeout=timeout) + + async def spy_end_tx(connection: Any, *, commit: bool, supports_autocommit: bool | None = None) -> None: + nonlocal end_tx_calls + end_tx_calls += 1 + await original_end_tx(connection, commit=commit, supports_autocommit=supports_autocommit) + + monkeypatch.setattr(pool_module.asyncio, "wait_for", spy_wait_for) + monkeypatch.setattr(pool_module, "end_transaction", spy_end_tx) + + for _ in range(10): + async with pool.get_connection() as conn: + cursor = await conn.execute("SELECT 1") + row = await cursor.fetchone() + await cursor.close() + assert row == (1,) + + assert wait_for_calls == 0 + assert end_tx_calls == 0 + + async with pool.get_connection() as conn: + cursor = await conn.execute("INSERT INTO items (name) VALUES ('dirty')") + await cursor.close() + assert conn.in_transaction is True + + assert end_tx_calls == 1 + + async with pool.get_connection() as conn: + cursor = await conn.execute("SELECT COUNT(*) FROM items") + row = await cursor.fetchone() + await cursor.close() + assert row == (0,) + finally: + await pool.close() + + +async def _read_pragma( + connection: "AiosqliteConnection", + statement: Literal[ + "PRAGMA foreign_keys", + "PRAGMA busy_timeout", + "PRAGMA cache_size", + "PRAGMA journal_mode", + "PRAGMA synchronous", + "PRAGMA temp_store", + "PRAGMA read_uncommitted", + ], +) -> int | str: + cursor = await connection.execute(statement) + try: + row = await cursor.fetchone() + assert row is not None + return cast("int | str", row[0]) + finally: + await cursor.close() + + +class _FallbackConnection: + def __init__(self, fail_statement: str | None = None, *, script_fails: bool = True) -> None: + self.executed: list[str] = [] + self.fail_statement = fail_statement + self.script_fails = script_fails + self.closed = False + + async def executescript(self, _script: str) -> None: + if self.script_fails: + raise RuntimeError("script unavailable") + + async def execute(self, statement: str) -> None: + self.executed.append(statement) + if statement == self.fail_statement: + raise RuntimeError("individual pragma failed") + + async def commit(self) -> None: + return None + + async def close(self) -> None: + self.closed = True + + +class _FallbackConnectProxy: + def __init__(self, connection: _FallbackConnection) -> None: + self._thread = Thread(target=lambda: None) + self._connection = connection + + def __await__(self) -> Any: + async def resolve() -> _FallbackConnection: + return self._connection + + return resolve().__await__() diff --git a/tests/unit/adapters/test_duckdb/test_driver.py b/tests/unit/adapters/test_duckdb/test_driver.py new file mode 100644 index 000000000..cb73ffc7e --- /dev/null +++ b/tests/unit/adapters/test_duckdb/test_driver.py @@ -0,0 +1,203 @@ +"""Unit tests for DuckDBDriver execution and materialization behavior.""" + +from pathlib import Path +from typing import Any, Self + +import duckdb +import pytest +from sqlglot import exp + +from sqlspec.adapters.duckdb import DuckDBConfig +from sqlspec.core.result import SQLResult + +__all__ = ( + "test_duckdb_execute_many_caches_bulk_insert_sql_fragments", + "test_duckdb_materialization_arrow_vs_fetchall_threshold", + "test_duckdb_native_type_contract_parity", + "test_duckdb_unparseable_select_returns_rows_across_repeated_calls", +) + +DIVERGENT_AND_STANDARD_TYPE_EXPRESSIONS: tuple[str, ...] = ( + "1::HUGEINT", + "1::UHUGEINT", + "'101'::BIT", + "'12:00:00+00'::TIMETZ", + "'2026-01-01 00:00:00'::TIMESTAMP_NS", + "INTERVAL 1 DAY", + "'550e8400-e29b-41d4-a716-446655440000'::UUID", + "[1, 2, 3]::INT[3]", + "MAP {'a': 1}", + "[INTERVAL 1 DAY]", + "{'u': '550e8400-e29b-41d4-a716-446655440000'::UUID}", + "1::BIGNUM", + "1::INTEGER", + "1::BIGINT", + "'hello'::VARCHAR", + "1.5::DOUBLE", + "TRUE::BOOLEAN", + "DATE '2026-01-01'", + "TIMESTAMP '2026-01-01 00:00:00'", + "[1, 2, 3]", + "{'a': 1}", +) + + +def test_duckdb_unparseable_select_returns_rows_across_repeated_calls(tmp_path: Path) -> None: + """Repeated SELECT queries with qmark casts that fail sqlglot parsing must return rows on every call.""" + config = DuckDBConfig(connection_config={"database": str(tmp_path / "unparseable_select.duckdb")}) + try: + with config.provide_session() as session: + for value in (1, 2, 3): + result = session.execute("SELECT ?::INTEGER AS i", (value,)) + assert isinstance(result, SQLResult) + assert result.operation_type == "SELECT" + assert result.get_data() == [{"i": value}] + finally: + config.close_pool() + + +@pytest.mark.parametrize("sql_expr", DIVERGENT_AND_STANDARD_TYPE_EXPRESSIONS) +def test_duckdb_native_type_contract_parity(tmp_path: Path, sql_expr: str) -> None: + """First and cached executions must return identical Python types matching raw duckdb fetchall().""" + raw_conn = duckdb.connect() + try: + expected = raw_conn.execute(f"SELECT {sql_expr} AS val").fetchall()[0][0] + finally: + raw_conn.close() + + config = DuckDBConfig(connection_config={"database": str(tmp_path / "type_contract.duckdb")}) + try: + with config.provide_session() as session: + parameterized_sql = f"SELECT {sql_expr} AS val WHERE ? >= 0" + for index in range(3): + result = session.execute(parameterized_sql, (index,)) + actual = result.one()["val"] + assert type(actual) is type(expected) + assert actual == expected + + unparameterized_sql = f"SELECT {sql_expr} AS val" + for _ in range(2): + result = session.execute(unparameterized_sql) + actual = result.one()["val"] + assert type(actual) is type(expected) + assert actual == expected + finally: + config.close_pool() + + +def test_duckdb_materialization_arrow_vs_fetchall_threshold(tmp_path: Path) -> None: + """Queries with >= 64 non-divergent rows use Arrow; < 64 rows after observation and divergent columns use fetchall.""" + config = DuckDBConfig(connection_config={"database": str(tmp_path / "threshold.duckdb")}) + try: + with config.provide_session() as session: + session.execute("CREATE TABLE items AS SELECT i AS id, 'item_' || i::VARCHAR AS name FROM range(200) t(i)") + raw_connection = session.connection + counts = {"arrow": 0, "fetchall": 0} + + def reset_counts() -> None: + counts["arrow"] = 0 + counts["fetchall"] = 0 + + session.connection = _SpyDuckDBCursor(raw_connection, counts) # type: ignore[assignment] + try: + large_sql = "SELECT id, name FROM items WHERE id >= ?" + reset_counts() + res1 = session.execute(large_sql, (0,)) + assert len(res1.get_data()) == 200 + assert counts == {"arrow": 1, "fetchall": 0} + + reset_counts() + res2 = session.execute(large_sql, (0,)) + assert len(res2.get_data()) == 200 + assert counts == {"arrow": 1, "fetchall": 0} + + small_sql = "SELECT id, name FROM items WHERE id = ?" + reset_counts() + small1 = session.execute(small_sql, (1,)) + assert small1.one() == {"id": 1, "name": "item_1"} + assert counts == {"arrow": 1, "fetchall": 0} + + reset_counts() + small2 = session.execute(small_sql, (2,)) + assert small2.one() == {"id": 2, "name": "item_2"} + assert counts == {"arrow": 0, "fetchall": 1} + + divergent_sql = "SELECT id::HUGEINT AS big_id FROM items WHERE id >= ?" + reset_counts() + div1 = session.execute(divergent_sql, (0,)) + assert len(div1.get_data()) == 200 + assert type(div1.get_data()[0]["big_id"]) is int + assert counts == {"arrow": 0, "fetchall": 1} + + reset_counts() + div2 = session.execute(divergent_sql, (0,)) + assert len(div2.get_data()) == 200 + assert type(div2.get_data()[0]["big_id"]) is int + assert counts == {"arrow": 0, "fetchall": 1} + finally: + session.connection = raw_connection + finally: + config.close_pool() + + +def test_duckdb_execute_many_caches_bulk_insert_sql_fragments(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Repeated execute_many bulk inserts cache (target_table, column_sql) and skip sqlglot .sql() on subsequent batches.""" + config = DuckDBConfig(connection_config={"database": str(tmp_path / "bulk_cache.duckdb")}) + try: + with config.provide_session() as session: + session.execute("CREATE TABLE bulk_items (id INTEGER, name VARCHAR)") + insert_sql = "INSERT INTO bulk_items (id, name) VALUES (?, ?)" + res1 = session.execute_many(insert_sql, [(1, "a"), (2, "b")]) + assert res1.rows_affected == 2 + cached_pair = session._cache_holder.get_bulk_insert_sql(insert_sql) + assert cached_pair == ("bulk_items", "id, name") + + def fail_sql(*_args: Any, **_kwargs: Any) -> str: + msg = "exp.Expr.sql should not be called on bulk insert cache hit" + raise AssertionError(msg) + + monkeypatch.setattr(exp.Expr, "sql", fail_sql) + res2 = session.execute_many(insert_sql, [(3, "c"), (4, "d")]) + assert res2.rows_affected == 2 + count = session.select_value("SELECT COUNT(*) FROM bulk_items") + assert count == 4 + finally: + config.close_pool() + + +class _SpyDuckDBCursor: + """Proxy cursor for counting to_arrow_table and fetchall calls.""" + + __slots__ = ("_connection", "_counts") + + def __init__(self, connection: Any, counts: dict[str, int]) -> None: + self._connection = connection + self._counts = counts + + def __enter__(self) -> Self: + return self + + def __exit__(self, *_: Any) -> None: + return None + + @property + def description(self) -> Any: + return self._connection.description + + def execute(self, sql: str, parameters: Any = None) -> Self: + if parameters is not None: + self._connection.execute(sql, parameters) + else: + self._connection.execute(sql) + return self + + def fetchall(self) -> list[Any]: + self._counts["fetchall"] += 1 + return list(self._connection.fetchall()) + + def to_arrow_table(self) -> Any: + self._counts["arrow"] += 1 + return self._connection.to_arrow_table() + + def fetchone(self) -> Any: + return self._connection.fetchone() diff --git a/tests/unit/adapters/test_sync_adapters.py b/tests/unit/adapters/test_sync_adapters.py index 27929f126..af5834764 100644 --- a/tests/unit/adapters/test_sync_adapters.py +++ b/tests/unit/adapters/test_sync_adapters.py @@ -87,7 +87,7 @@ def test_sync_driver_fast_path_flag_disabled_by_observability() -> None: driver.attach_observability(runtime) - assert driver._stmt_cache_enabled is False + assert driver._stmt_cache_enabled is True finally: conn.close() diff --git a/tests/unit/core/test_compiler.py b/tests/unit/core/test_compiler.py index 785e7b115..cdfd2e8c5 100644 --- a/tests/unit/core/test_compiler.py +++ b/tests/unit/core/test_compiler.py @@ -1265,3 +1265,35 @@ def test_compiler_cache_isolates_expression_mutations(basic_statement_config: "S res2 = processor.compile(sql, None) assert res2.expression is not None assert res2.expression.args.get("where") is None + + +@requires_interpreted +def test_shared_expression_skips_ast_copy_on_compiler_and_parse_cache_hits( + basic_statement_config: "StatementConfig", +) -> None: + """Warm SQLProcessor compiled-cache and parse-cache hits must not call exp.Expr.copy().""" + sql = "WITH cte AS (SELECT id, name FROM users WHERE active = ?) SELECT id, name FROM cte WHERE id = ?" + processor = SQLProcessor(basic_statement_config) + processor.compile(sql, [1, 2]) + + parse_only_processor = SQLProcessor(basic_statement_config, cache_enabled=False, parse_cache_size=16) + parse_only_processor.compile(sql, [1, 2]) + + copy_calls = 0 + original_copy = exp.Expression.copy + + def counting_copy(self: "exp.Expression") -> "exp.Expression": + nonlocal copy_calls + copy_calls += 1 + return original_copy(self) + + with patch.object(exp.Expression, "copy", counting_copy): + for i in range(5): + res = processor.compile(sql, [1, i]) + assert res.expression is not None + assert copy_calls == 0 + + for i in range(5): + res = parse_only_processor.compile(sql, [1, i]) + assert res.expression is not None + assert copy_calls == 0 diff --git a/tests/unit/core/test_statement.py b/tests/unit/core/test_statement.py index 050b10408..174e17eea 100644 --- a/tests/unit/core/test_statement.py +++ b/tests/unit/core/test_statement.py @@ -1785,3 +1785,72 @@ def test_statement_config_equality_checks_all_parameter_config_fields() -> None: ) ) assert cfg1 != cfg2 + + +@requires_interpreted +def test_shared_expression_skips_ast_copy_on_cache_hit_and_rebind() -> None: + """Warm compiler cache hits and parameter rebinds must not deep-copy the sqlglot AST.""" + reset_statement_pipeline_cache() + config = StatementConfig(dialect="sqlite", parameter_config=DEFAULT_PARAMETER_CONFIG) + cte_sql = ( + "WITH filtered AS (SELECT id, name FROM users WHERE id >= ?) " + "SELECT f.id, f.name FROM filtered f WHERE f.id <= ?" + ) + warm_stmt = SQL(cte_sql, 1, 10, statement_config=config) + warm_stmt.compile() + + copy_calls = 0 + original_copy = exp.Expression.copy + + def counting_copy(self: "exp.Expression") -> "exp.Expression": + nonlocal copy_calls + copy_calls += 1 + return original_copy(self) + + with patch.object(exp.Expression, "copy", counting_copy): + for i in range(5): + hit_stmt = SQL(cte_sql, i, i + 10, statement_config=config) + hit_stmt.compile() + assert hit_stmt._peek_expression() is not None + assert copy_calls == 0 + + for i in range(5): + rebound = warm_stmt.copy(parameters=(i, i + 20)) + rebound.compile() + assert rebound._peek_expression() is not None + assert copy_calls == 0 + + +def test_copy_on_access_expression_isolates_cached_and_rebound_statements() -> None: + """Public expression accessors must copy once on first access and isolate mutations.""" + reset_statement_pipeline_cache() + config = StatementConfig(dialect="sqlite", parameter_config=DEFAULT_PARAMETER_CONFIG) + sql = "SELECT id, name FROM users WHERE id = ?" + + SQL(sql, 0, statement_config=config).compile() + stmt1 = SQL(sql, 1, statement_config=config) + stmt1.compile() + stmt2 = SQL(sql, 2, statement_config=config) + stmt2.compile() + rebound = stmt1.copy(parameters=(3,)) + rebound.compile() + + assert stmt1._peek_expression() is stmt2._peek_expression() + assert stmt1._peek_expression() is rebound._peek_expression() + + owned1 = stmt1.expression + assert owned1 is not None + assert stmt1.expression is owned1 + assert stmt1.statement_expression is owned1 + assert owned1 is not stmt2._peek_expression() + + owned1.set("limit", exp.Limit(expression=exp.Literal.number(1))) + + owned2 = stmt2.expression + assert owned2 is not None + assert owned2.args.get("limit") is None + + rebound_expr = rebound.statement_expression + assert rebound_expr is not None + assert rebound_expr is rebound.expression + assert rebound_expr.args.get("limit") is None diff --git a/tests/unit/driver/test_query_cache.py b/tests/unit/driver/test_query_cache.py index 6d8a9d654..9341b3c3b 100644 --- a/tests/unit/driver/test_query_cache.py +++ b/tests/unit/driver/test_query_cache.py @@ -1,15 +1,25 @@ # pyright: reportPrivateUsage = false """Tests for SQL query caching functionality.""" +import sqlite3 +import threading from collections import UserDict from collections.abc import AsyncIterator, Generator from contextlib import asynccontextmanager, contextmanager -from typing import Any +from datetime import date +from decimal import Decimal +from typing import Any, cast from unittest.mock import AsyncMock, Mock +from uuid import UUID, uuid4 import pytest from sqlglot import parse_one +from sqlspec import ObservabilityConfig +from sqlspec.adapters.aiosqlite import AiosqliteConfig +from sqlspec.adapters.sqlite import SqliteConfig, SqliteDriver +from sqlspec.base import SQLSpec +from sqlspec.config import LifecycleConfig from sqlspec.core import ( SQL, CachedStatement, @@ -18,14 +28,22 @@ ParameterInfo, ParameterProfile, ParameterStyle, + ParameterStyleConfig, ProcessedState, + StatementConfig, + TypedParameter, clear_all_caches, get_cache, ) -from sqlspec.core.parameters._processor import ParameterProcessor +from sqlspec.core.filters import LimitOffsetFilter +from sqlspec.core.parameters._declared import ParameterDeclaration +from sqlspec.core.parameters._processor import ParameterProcessor, type_coercion_fallbacks +from sqlspec.driver._async import AsyncDriverAdapterBase +from sqlspec.driver._common import _check_declared_parameters from sqlspec.driver._query_cache import CachedQuery, QueryCache from sqlspec.driver._sync import SyncDriverAdapterBase from sqlspec.exceptions import SQLSpecError +from sqlspec.observability import LoggingConfig, StatementEvent def _make_cached( @@ -489,7 +507,7 @@ def cached_sync_cursor(sqlite_sync_driver: Any, monkeypatch: Any) -> Generator[A cursor = Mock(spec=["execute", "fetchall", "rowcount", "description"]) cursor.rowcount = 0 - def execute(sql: str, params: Any) -> None: + def execute(sql: str, params: Any = ()) -> None: raw_cursor.execute(sql, params) cursor.description = raw_cursor.description cursor.rowcount = raw_cursor.rowcount @@ -531,6 +549,9 @@ def execute_then_error(sql: str, params: Any) -> None: cursor.execute = execute_then_error if stage == "fetchall": cursor.fetchall = Mock(side_effect=error) + elif stage == "build_statement_result": + monkeypatch.setattr(driver, "build_statement_result", Mock(side_effect=error)) + monkeypatch.setattr(driver, "build_cached_select_result", Mock(side_effect=error)) elif stage != "execute": monkeypatch.setattr(driver, stage, Mock(side_effect=error)) returns_rows = stage not in {"execute", "resolve_rowcount"} @@ -621,7 +642,7 @@ def cached_async_cursor( cursor = Mock(spec=["execute", "fetchall", "rowcount", "description"]) cursor.rowcount = 0 - def execute(sql: str, params: Any) -> None: + def execute(sql: str, params: Any = ()) -> None: raw_cursor.execute(sql, params) cursor.description = raw_cursor.description cursor.rowcount = raw_cursor.rowcount @@ -711,6 +732,9 @@ async def execute_async(sql: str, params: Any) -> None: cursor.execute = execute_async if stage == "fetchall": cursor.fetchall = Mock(side_effect=error) + elif stage == "build_statement_result": + monkeypatch.setattr(driver, "build_statement_result", Mock(side_effect=error)) + monkeypatch.setattr(driver, "build_cached_select_result", Mock(side_effect=error)) elif stage != "execute": monkeypatch.setattr(driver, stage, Mock(side_effect=error)) @@ -960,3 +984,655 @@ def test_refresh_statement_cache_state_allows_none_observability(sqlite_sync_dri finally: sqlite_sync_driver._observability = original_obs sqlite_sync_driver._refresh_statement_cache_state() + + +def test_cache_statement_command_not_cached(sqlite_sync_driver: Any) -> None: + """_cache_statement must refuse statements with undetermined COMMAND operation or missing AST.""" + sqlite_sync_driver._stmt_cache_enabled = True + + unparsed = SQL("SELECT ?::INTEGER AS i", 1, statement_config=sqlite_sync_driver.statement_config) + unparsed._processed_state = ProcessedState( + compiled_sql="SELECT ?::INTEGER AS i", execution_parameters=[1], parsed_expression=None, operation_type="SELECT" + ) + sqlite_sync_driver._cache_statement(unparsed) + assert sqlite_sync_driver._stmt_cache.get("SELECT ?::INTEGER AS i") is None + + command_stmt = SQL("VACUUM", statement_config=sqlite_sync_driver.statement_config) + command_stmt.compile() + sqlite_sync_driver._cache_statement(command_stmt) + assert sqlite_sync_driver._stmt_cache.get("VACUUM") is None + + +def test_fast_path_shapes_sync(sqlite_sync_driver: Any, monkeypatch: Any) -> None: + """Parameterless and scalar/varargs positional calls route through _cached_execution while StatementFilter bypasses it.""" + sqlite_sync_driver._stmt_cache_enabled = True + sqlite_sync_driver.execute("CREATE TABLE t_shapes (id INTEGER, label TEXT)") + sqlite_sync_driver.execute("INSERT INTO t_shapes (id, label) VALUES (?, ?)", (1, "one")) + + cache_hits: list[str] = [] + original_hit = sqlite_sync_driver._execute_cache_hit + + def tracked_hit(sql: str, params: Any, cached: CachedQuery) -> Any: + cache_hits.append(sql) + return original_hit(sql, params, cached) + + monkeypatch.setattr(sqlite_sync_driver, "_execute_cache_hit", tracked_hit) + + r1_no_param = sqlite_sync_driver.execute("SELECT 1 AS v") + r2_no_param = sqlite_sync_driver.execute("SELECT 1 AS v") + assert r1_no_param.get_data() == r2_no_param.get_data() == [{"v": 1}] + assert "SELECT 1 AS v" in cache_hits + + cache_hits.clear() + assert sqlite_sync_driver.select_value("SELECT ? AS v", 42) == 42 + assert sqlite_sync_driver.select_value("SELECT ? AS v", 99) == 99 + assert "SELECT ? AS v" in cache_hits + + cache_hits.clear() + assert sqlite_sync_driver.select_value("SELECT ? AS s", "hello") == "hello" + assert sqlite_sync_driver.select_value("SELECT ? AS s", "world") == "world" + assert "SELECT ? AS s" in cache_hits + + cache_hits.clear() + assert sqlite_sync_driver.select_value("SELECT ? AS b", b"bytes") == b"bytes" + assert sqlite_sync_driver.select_value("SELECT ? AS b", b"more") == b"more" + assert "SELECT ? AS b" in cache_hits + + cache_hits.clear() + assert sqlite_sync_driver.select_value("SELECT ? AS n", None) is None + assert sqlite_sync_driver.select_value("SELECT ? AS n", None) is None + assert "SELECT ? AS n" in cache_hits + + cache_hits.clear() + assert sqlite_sync_driver.select_one("SELECT ? AS a, ? AS b", 1, "two") == {"a": 1, "b": "two"} + assert sqlite_sync_driver.select_one("SELECT ? AS a, ? AS b", 3, "four") == {"a": 3, "b": "four"} + assert "SELECT ? AS a, ? AS b" in cache_hits + + cache_hits.clear() + r1_filtered = sqlite_sync_driver.execute("SELECT id, label FROM t_shapes", LimitOffsetFilter(10, 0)) + r2_filtered = sqlite_sync_driver.execute("SELECT id, label FROM t_shapes", LimitOffsetFilter(10, 0)) + assert r1_filtered.get_data() == r2_filtered.get_data() == [{"id": 1, "label": "one"}] + assert cache_hits == [] + + +@pytest.mark.anyio +async def test_fast_path_shapes_async(aiosqlite_async_driver: Any, monkeypatch: Any) -> None: + """Async parameterless and scalar/varargs positional calls route through _cached_execution while StatementFilter bypasses it.""" + aiosqlite_async_driver._stmt_cache_enabled = True + await aiosqlite_async_driver.execute("CREATE TABLE t_shapes (id INTEGER, label TEXT)") + await aiosqlite_async_driver.execute("INSERT INTO t_shapes (id, label) VALUES (?, ?)", (1, "one")) + + cache_hits: list[str] = [] + original_hit = aiosqlite_async_driver._execute_cache_hit + + async def tracked_hit(sql: str, params: Any, cached: CachedQuery) -> Any: + cache_hits.append(sql) + return await original_hit(sql, params, cached) + + monkeypatch.setattr(aiosqlite_async_driver, "_execute_cache_hit", tracked_hit) + + r1_no_param = await aiosqlite_async_driver.execute("SELECT 1 AS v") + r2_no_param = await aiosqlite_async_driver.execute("SELECT 1 AS v") + assert r1_no_param.get_data() == r2_no_param.get_data() == [{"v": 1}] + assert "SELECT 1 AS v" in cache_hits + + cache_hits.clear() + assert await aiosqlite_async_driver.select_value("SELECT ? AS v", 42) == 42 + assert await aiosqlite_async_driver.select_value("SELECT ? AS v", 99) == 99 + assert "SELECT ? AS v" in cache_hits + + cache_hits.clear() + assert await aiosqlite_async_driver.select_value("SELECT ? AS s", "hello") == "hello" + assert await aiosqlite_async_driver.select_value("SELECT ? AS s", "world") == "world" + assert "SELECT ? AS s" in cache_hits + + cache_hits.clear() + assert await aiosqlite_async_driver.select_value("SELECT ? AS b", b"bytes") == b"bytes" + assert await aiosqlite_async_driver.select_value("SELECT ? AS b", b"more") == b"more" + assert "SELECT ? AS b" in cache_hits + + cache_hits.clear() + assert await aiosqlite_async_driver.select_value("SELECT ? AS n", None) is None + assert await aiosqlite_async_driver.select_value("SELECT ? AS n", None) is None + assert "SELECT ? AS n" in cache_hits + + cache_hits.clear() + assert await aiosqlite_async_driver.select_one("SELECT ? AS a, ? AS b", 1, "two") == {"a": 1, "b": "two"} + assert await aiosqlite_async_driver.select_one("SELECT ? AS a, ? AS b", 3, "four") == {"a": 3, "b": "four"} + assert "SELECT ? AS a, ? AS b" in cache_hits + + cache_hits.clear() + r1_filtered = await aiosqlite_async_driver.execute("SELECT id, label FROM t_shapes", LimitOffsetFilter(10, 0)) + r2_filtered = await aiosqlite_async_driver.execute("SELECT id, label FROM t_shapes", LimitOffsetFilter(10, 0)) + assert r1_filtered.get_data() == r2_filtered.get_data() == [{"id": 1, "label": "one"}] + assert cache_hits == [] + + +def test_statement_cache_shared_across_sessions(tmp_path: Any, monkeypatch: Any) -> None: + """Sessions opened from the same database config share StatementCacheHolder, QueryCache, and rebind processor.""" + db_file = tmp_path / "shared_cache.db" + spec = SQLSpec() + config = SqliteConfig(connection_config={"database": str(db_file)}) + spec.add_config(config) + + try: + with spec.provide_session(config) as session1: + session1.execute("CREATE TABLE items (id INTEGER PRIMARY KEY, name TEXT)") + session1.execute("INSERT INTO items (id, name) VALUES (?, ?)", (1, "alpha")) + session1.execute("INSERT INTO items (id, name) VALUES (?, ?)", (2, "beta")) + r1 = session1.select_one("SELECT id, name FROM items WHERE id = ?", 1) + assert r1 == {"id": 1, "name": "alpha"} + cache1 = session1._stmt_cache + processor1 = session1._stmt_cache_rebind_processor + holder1 = session1._cache_holder + + with spec.provide_session(config) as session2: + assert session2._stmt_cache is cache1 + assert session2._stmt_cache_rebind_processor is processor1 + assert session2._cache_holder is holder1 + + hit_calls: list[str] = [] + original_hit = type(session2)._execute_cache_hit + + def tracked_hit(self_driver: Any, sql: str, params: Any, cached: CachedQuery) -> Any: + hit_calls.append(sql) + return original_hit(self_driver, sql, params, cached) + + monkeypatch.setattr(type(session2), "_execute_cache_hit", tracked_hit) + r2 = session2.select_one("SELECT id, name FROM items WHERE id = ?", 2) + assert r2 == {"id": 2, "name": "beta"} + assert hit_calls == ["SELECT id, name FROM items WHERE id = ?"] + finally: + config.close_pool() + + +def test_statement_cache_shared_across_sessions_isolated_and_thread_safe(tmp_path: Any) -> None: + """Different configs have isolated StatementCacheHolders, standalone drivers work, and concurrent access is thread-safe.""" + spec = SQLSpec() + cfg_a = SqliteConfig(connection_config={"database": str(tmp_path / "a.db")}) + cfg_b = SqliteConfig(connection_config={"database": str(tmp_path / "b.db")}) + spec.add_config(cfg_a) + spec.add_config(cfg_b) + + try: + with spec.provide_session(cfg_a) as s_a, spec.provide_session(cfg_b) as s_b: + assert s_a._cache_holder is not s_b._cache_holder + assert s_a._stmt_cache is not s_b._stmt_cache + s_a.execute("SELECT 1 AS a") + assert s_b._stmt_cache.get("SELECT 1 AS a") is None + + errors: list[Exception] = [] + holder = s_a._cache_holder + + def worker(tid: int) -> None: + try: + for idx in range(50): + sql = f"SELECT {idx % 16} AS v" + stmt = SQL(sql, statement_config=s_a.statement_config) + holder.set_statement(sql, stmt) + _ = holder.get_statement(sql) + holder.query_cache.set(sql, _make_cached(compiled_sql=sql)) + _ = holder.query_cache.get(sql) + holder.set_row_count_hint(sql, tid + idx) + _ = holder.get_row_count_hint(sql) + holder.set_bulk_insert_sql(sql, ("t", "c")) + _ = holder.get_bulk_insert_sql(sql) + except Exception as exc: + errors.append(exc) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(4)] + for t in threads: + t.start() + for t in threads: + t.join() + assert errors == [] + finally: + cfg_a.close_pool() + cfg_b.close_pool() + + raw_conn = sqlite3.connect(":memory:") + try: + standalone = SqliteDriver(connection=raw_conn) + assert standalone._cache_holder is not None + assert standalone.select_value("SELECT ? AS v", 7) == 7 + assert standalone._stmt_cache.get("SELECT ? AS v") is not None + finally: + raw_conn.close() + + +def test_fast_path_with_observability_sync(tmp_path: Any, monkeypatch: Any) -> None: + """Fast path stays enabled with observability active and emits matching events and error hooks.""" + events: list[StatementEvent] = [] + starts: list[dict[str, Any]] = [] + completes: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + + obs = ObservabilityConfig( + statement_observers=(events.append,), + lifecycle=cast( + "LifecycleConfig", + {"on_query_start": [starts.append], "on_query_complete": [completes.append], "on_error": [errors.append]}, + ), + logging=LoggingConfig(include_sql_hash=True), + ) + spec = SQLSpec() + config = SqliteConfig(connection_config={"database": str(tmp_path / "obs_sync.db")}, observability_config=obs) + spec.add_config(config) + + try: + with spec.provide_session(config) as session: + assert session._stmt_cache_enabled is True + session.execute("CREATE TABLE obs_items (id INTEGER PRIMARY KEY, label TEXT)") + + events.clear() + starts.clear() + completes.clear() + errors.clear() + + hit_calls: list[str] = [] + original_hit = type(session)._execute_cache_hit + + def tracked_hit(self_driver: Any, sql: str, params: Any, cached: CachedQuery) -> Any: + hit_calls.append(sql) + return original_hit(self_driver, sql, params, cached) + + monkeypatch.setattr(type(session), "_execute_cache_hit", tracked_hit) + + insert_sql = "INSERT INTO obs_items (id, label) VALUES (?, ?)" + session.execute(insert_sql, (1, "alpha")) + session.execute(insert_sql, (2, "beta")) + assert hit_calls == [insert_sql] + assert len(events) == 2 + assert len(starts) == 2 + assert len(completes) == 2 + + ev1, ev2 = events[0], events[1] + assert ev1.sql == ev2.sql == insert_sql + assert ev1.driver == ev2.driver == "SqliteDriver" + assert ev1.operation == ev2.operation == "INSERT" + assert ev1.execution_mode == ev2.execution_mode + assert ev1.is_many == ev2.is_many is False + assert ev1.is_script == ev2.is_script is False + assert ev1.rows_affected == ev2.rows_affected == 1 + assert ev1.sql_hash is not None + assert ev1.sql_hash == ev2.sql_hash + assert type(ev1.parameters) is type(ev2.parameters) + assert starts[0]["sql"] == starts[1]["sql"] == insert_sql + assert starts[0]["operation"] == starts[1]["operation"] == "INSERT" + assert completes[0]["rows_affected"] == completes[1]["rows_affected"] == 1 + + events.clear() + starts.clear() + completes.clear() + hit_calls.clear() + + select_sql = "SELECT id, label FROM obs_items WHERE id = ?" + r1 = session.select_one(select_sql, (1,)) + r2 = session.select_one(select_sql, (1,)) + assert r1 == r2 == {"id": 1, "label": "alpha"} + assert hit_calls == [select_sql] + assert len(events) == 2 + s_ev1, s_ev2 = events[0], events[1] + assert s_ev1.sql == s_ev2.sql == select_sql + assert s_ev1.parameters == s_ev2.parameters == [1] + assert s_ev1.driver == s_ev2.driver == "SqliteDriver" + assert s_ev1.operation == s_ev2.operation == "SELECT" + assert s_ev1.execution_mode == s_ev2.execution_mode + assert s_ev1.is_many == s_ev2.is_many is False + assert s_ev1.is_script == s_ev2.is_script is False + assert s_ev1.rows_affected == s_ev2.rows_affected == 1 + assert s_ev1.sql_hash == s_ev2.sql_hash + assert starts[0] == starts[1] + assert completes[0] == completes[1] + + with pytest.raises(SQLSpecError): + session.execute(insert_sql, (1, "duplicate")) + assert len(errors) == 1 + assert isinstance(errors[0]["exception"], SQLSpecError) + assert errors[0]["sql"] == insert_sql + + transformed_cfg = session.statement_config.replace( + statement_transformers=((lambda expr, ctx: (expr, ctx)),) + ) + transformed_drv = SqliteDriver(connection=session.connection, statement_config=transformed_cfg) + assert transformed_drv._stmt_cache_enabled is False + finally: + config.close_pool() + + +@pytest.mark.anyio +async def test_fast_path_with_observability_async(tmp_path: Any, monkeypatch: Any) -> None: + """Async fast path stays enabled with observability active and emits matching events and error hooks.""" + events: list[StatementEvent] = [] + starts: list[dict[str, Any]] = [] + completes: list[dict[str, Any]] = [] + errors: list[dict[str, Any]] = [] + + obs = ObservabilityConfig( + statement_observers=(events.append,), + lifecycle=cast( + "LifecycleConfig", + {"on_query_start": [starts.append], "on_query_complete": [completes.append], "on_error": [errors.append]}, + ), + logging=LoggingConfig(include_sql_hash=True), + ) + spec = SQLSpec() + config = AiosqliteConfig(connection_config={"database": str(tmp_path / "obs_async.db")}, observability_config=obs) + spec.add_config(config) + + try: + async with spec.provide_session(config) as session: + assert session._stmt_cache_enabled is True + await session.execute("CREATE TABLE obs_items (id INTEGER PRIMARY KEY, label TEXT)") + + events.clear() + starts.clear() + completes.clear() + errors.clear() + + hit_calls: list[str] = [] + original_hit = type(session)._execute_cache_hit + + async def tracked_hit(self_driver: Any, sql: str, params: Any, cached: CachedQuery) -> Any: + hit_calls.append(sql) + return await original_hit(self_driver, sql, params, cached) + + monkeypatch.setattr(type(session), "_execute_cache_hit", tracked_hit) + + insert_sql = "INSERT INTO obs_items (id, label) VALUES (?, ?)" + await session.execute(insert_sql, (1, "alpha")) + await session.execute(insert_sql, (2, "beta")) + assert hit_calls == [insert_sql] + assert len(events) == 2 + + select_sql = "SELECT id, label FROM obs_items WHERE id = ?" + events.clear() + starts.clear() + completes.clear() + hit_calls.clear() + + r1 = await session.select_one(select_sql, (1,)) + r2 = await session.select_one(select_sql, (1,)) + assert r1 == r2 == {"id": 1, "label": "alpha"} + assert hit_calls == [select_sql] + assert len(events) == 2 + s_ev1, s_ev2 = events[0], events[1] + assert s_ev1.sql == s_ev2.sql == select_sql + assert s_ev1.parameters == s_ev2.parameters == [1] + assert s_ev1.driver == s_ev2.driver == "AiosqliteDriver" + assert s_ev1.operation == s_ev2.operation == "SELECT" + assert s_ev1.rows_affected == s_ev2.rows_affected == 1 + assert s_ev1.sql_hash == s_ev2.sql_hash + assert starts[0] == starts[1] + assert completes[0] == completes[1] + + with pytest.raises(SQLSpecError): + await session.execute(insert_sql, (1, "duplicate")) + assert len(errors) == 1 + assert isinstance(errors[0]["exception"], SQLSpecError) + assert errors[0]["sql"] == insert_sql + finally: + await config.close_pool() + + +def test_fast_path_select_lazy_statement_materialization_sync(sqlite_sync_driver: Any) -> None: + sqlite_sync_driver._stmt_cache_enabled = True + sql = "SELECT ? AS val" + + warm_result = sqlite_sync_driver.execute(sql, (0,)) + assert warm_result.one() == {"val": 0} + assert warm_result._statement is not None + + r1 = sqlite_sync_driver.execute(sql, (1,)) + r2 = sqlite_sync_driver.execute(sql, (2,)) + assert r1._statement is None + assert r2._statement is None + assert r1.one() == {"val": 1} + assert r2.one() == {"val": 2} + assert r1._statement is None + assert r2._statement is None + + stmt1 = r1.statement + stmt2 = r2.statement + assert stmt1 is not stmt2 + assert r1._statement is stmt1 + assert r2._statement is stmt2 + assert stmt1.raw_sql == sql + assert stmt2.raw_sql == sql + assert stmt1.operation_type == "SELECT" + assert stmt2.operation_type == "SELECT" + assert stmt1._processed_state.execution_parameters == (1,) + assert stmt2._processed_state.execution_parameters == (2,) + assert stmt1.parameters == [1] + assert stmt2.parameters == [2] + + +@pytest.mark.anyio +async def test_fast_path_select_lazy_statement_materialization_async(aiosqlite_async_driver: Any) -> None: + aiosqlite_async_driver._stmt_cache_enabled = True + sql = "SELECT ? AS val" + + warm_result = await aiosqlite_async_driver.execute(sql, (0,)) + assert warm_result.one() == {"val": 0} + assert warm_result._statement is not None + + r1 = await aiosqlite_async_driver.execute(sql, (1,)) + r2 = await aiosqlite_async_driver.execute(sql, (2,)) + assert r1._statement is None + assert r2._statement is None + assert r1.one() == {"val": 1} + assert r2.one() == {"val": 2} + assert r1._statement is None + assert r2._statement is None + + stmt1 = r1.statement + stmt2 = r2.statement + assert stmt1 is not stmt2 + assert stmt1.raw_sql == sql + assert stmt2.raw_sql == sql + assert stmt1.operation_type == "SELECT" + assert stmt2.operation_type == "SELECT" + assert stmt1._processed_state.execution_parameters == (1,) + assert stmt2._processed_state.execution_parameters == (2,) + + +def test_fast_path_ambient_exception_isolation_sync(cached_sync_cursor: Any, monkeypatch: Any) -> None: + driver, _cursor, _statements, _fallback = cached_sync_cursor + sql = "SELECT ? AS value" + cached = _make_cached( + compiled_sql=sql, + param_count=1, + operation_type="SELECT", + operation_profile=OperationProfile(returns_rows=True, modifies_rows=False), + ) + + helper_calls = 0 + original_helper = driver._run_with_exception_handler + + def tracked_helper(exc_handler: Any, fn: Any, *args: Any, **kwargs: Any) -> Any: + nonlocal helper_calls + helper_calls += 1 + return original_helper(exc_handler, fn, *args, **kwargs) + + monkeypatch.setattr(driver, "_run_with_exception_handler", tracked_helper) + + res_normal = SyncDriverAdapterBase._execute_cache_hit(driver, sql, ("normal",), cached) + assert res_normal.one() == {"value": "normal"} + assert helper_calls == 0 + + try: + raise ValueError("ambient") + except ValueError: + res_ambient = SyncDriverAdapterBase._execute_cache_hit(driver, sql, ("ambient",), cached) + assert res_ambient.one() == {"value": "ambient"} + assert helper_calls == 1 + + +@pytest.mark.anyio +async def test_fast_path_ambient_exception_isolation_async(cached_async_cursor: Any, monkeypatch: Any) -> None: + driver, _cursor, _statements, _fallback = cached_async_cursor + sql = "SELECT ? AS value" + cached = _make_cached( + compiled_sql=sql, + param_count=1, + operation_type="SELECT", + operation_profile=OperationProfile(returns_rows=True, modifies_rows=False), + ) + + helper_calls = 0 + original_helper = driver._run_with_exception_handler + + async def tracked_helper(exc_handler: Any, fn: Any, *args: Any, **kwargs: Any) -> Any: + nonlocal helper_calls + helper_calls += 1 + return await original_helper(exc_handler, fn, *args, **kwargs) + + monkeypatch.setattr(driver, "_run_with_exception_handler", tracked_helper) + + res_normal = await AsyncDriverAdapterBase._execute_cache_hit(driver, sql, ("normal",), cached) + assert res_normal.one() == {"value": "normal"} + assert helper_calls == 0 + + try: + raise ValueError("ambient") + except ValueError: + res_ambient = await AsyncDriverAdapterBase._execute_cache_hit(driver, sql, ("ambient",), cached) + assert res_ambient.one() == {"value": "ambient"} + assert helper_calls == 1 + + +def test_precomputed_fast_path_coercion_and_rebind_processor(sqlite_sync_driver: Any) -> None: + coercion_map: dict[type, Any] = {int: str, float: str} + fallbacks_1 = type_coercion_fallbacks(coercion_map) + fallbacks_2 = type_coercion_fallbacks(coercion_map) + assert fallbacks_1 is fallbacks_2 + + coercion_map[bool] = int + fallbacks_3 = type_coercion_fallbacks(coercion_map) + assert fallbacks_3 is not fallbacks_1 + assert len(fallbacks_3) == 3 + + sqlite_sync_driver._stmt_cache_enabled = True + sqlite_sync_driver.execute("SELECT :val AS v", {"val": 10}) + cached_named = sqlite_sync_driver._stmt_cache.get("SELECT :val AS v") + assert cached_named is not None + assert cached_named.needs_style_remap is True + + sqlite_sync_driver.execute("SELECT ? AS v", (10,)) + cached_qmark = sqlite_sync_driver._stmt_cache.get("SELECT ? AS v") + assert cached_qmark is not None + assert cached_qmark.needs_style_remap is False + + base_stmt = SQL("SELECT * FROM users WHERE id = ?", 1, statement_config=sqlite_sync_driver.statement_config) + base_stmt.compile() + copy_1 = base_stmt.copy(parameters=(2,)) + copy_2 = base_stmt.copy(parameters=(3,)) + copy_1.compile() + copy_2.compile() + assert copy_1._rebind_processor is not None + assert copy_1._rebind_processor is copy_2._rebind_processor + + +def test_execute_many_single_pass_parameter_preparation(sqlite_sync_driver: Any) -> None: + coercion_calls = 0 + + def coerce_decimal(val: Decimal) -> str: + nonlocal coercion_calls + coercion_calls += 1 + return str(val) + + def coerce_uuid(val: UUID) -> str: + nonlocal coercion_calls + coercion_calls += 1 + return val.hex + + def coerce_date(val: date) -> str: + nonlocal coercion_calls + coercion_calls += 1 + return val.isoformat() + + custom_cfg = StatementConfig( + dialect="sqlite", + parameter_config=ParameterStyleConfig( + default_parameter_style=ParameterStyle.QMARK, + default_execution_parameter_style=ParameterStyle.QMARK, + supported_parameter_styles={ParameterStyle.QMARK, ParameterStyle.NAMED_COLON}, + supported_execution_parameter_styles={ParameterStyle.QMARK}, + type_coercion_map={Decimal: coerce_decimal, UUID: coerce_uuid, date: coerce_date}, + ), + ) + + sample_uuid = uuid4() + sample_date = date(2026, 10, 13) + tuple_rows = [ + (Decimal("10.50"), sample_uuid, sample_date, TypedParameter(7, int)), + (Decimal("20.75"), sample_uuid, sample_date, TypedParameter(8, int)), + ] + stmt_tuples = SQL("INSERT INTO items VALUES (?, ?, ?, ?)", tuple_rows, statement_config=custom_cfg, is_many=True) + stmt_tuples.compile() + _, prepared_tuples = sqlite_sync_driver._compiled_statement(stmt_tuples, custom_cfg) + assert coercion_calls == 6 + assert prepared_tuples == [("10.50", sample_uuid.hex, "2026-10-13", 7), ("20.75", sample_uuid.hex, "2026-10-13", 8)] + + coercion_calls = 0 + list_rows = [[Decimal("1.00"), sample_uuid, sample_date], [Decimal("2.00"), sample_uuid, sample_date]] + stmt_lists = SQL("INSERT INTO items VALUES (?, ?, ?)", list_rows, statement_config=custom_cfg, is_many=True) + stmt_lists.compile() + _, prepared_lists = sqlite_sync_driver._compiled_statement(stmt_lists, custom_cfg) + assert coercion_calls == 6 + assert prepared_lists == [["1.00", sample_uuid.hex, "2026-10-13"], ["2.00", sample_uuid.hex, "2026-10-13"]] + + coercion_calls = 0 + dict_rows = [{"price": Decimal("3.50"), "uid": sample_uuid}, {"price": Decimal("4.50"), "uid": sample_uuid}] + stmt_dicts = SQL("INSERT INTO items VALUES (:price, :uid)", dict_rows, statement_config=custom_cfg, is_many=True) + stmt_dicts.compile() + _, prepared_dicts = sqlite_sync_driver._compiled_statement(stmt_dicts, custom_cfg) + assert coercion_calls == 4 + assert prepared_dicts == [("3.50", sample_uuid.hex), ("4.50", sample_uuid.hex)] + + override_calls = 0 + + class CustomDriver(SqliteDriver): + def prepare_driver_parameters( + self, + parameters: Any, + statement_config: StatementConfig, + is_many: bool = False, + prepared_statement: Any | None = None, + ) -> Any: + nonlocal override_calls + override_calls += 1 + return super().prepare_driver_parameters( + parameters, statement_config, is_many=is_many, prepared_statement=prepared_statement + ) + + custom_driver = CustomDriver( + connection=sqlite_sync_driver.connection, + statement_config=custom_cfg, + driver_features=sqlite_sync_driver.driver_features, + ) + stmt_override = SQL("INSERT INTO items VALUES (?, ?, ?)", list_rows, statement_config=custom_cfg, is_many=True) + stmt_override.compile() + custom_driver._compiled_statement(stmt_override, custom_cfg) + assert override_calls == 1 + + +def test_declared_parameters_execute_many_copies_only_when_default_missing() -> None: + declarations = ( + ParameterDeclaration(name="id", type_str="int", required=True), + ParameterDeclaration(name="note", type_str="str", required=False), + ) + row_complete = {"id": 1, "note": "present"} + row_missing = {"id": 2} + stmt = SQL( + "INSERT INTO items VALUES (:id, :note)", + [row_complete, row_missing], + is_many=True, + declared_parameters=declarations, + ) + _check_declared_parameters(stmt) + rows = stmt.positional_parameters + assert rows[0] is row_complete + assert rows[1] is not row_missing + assert rows[1] == {"id": 2, "note": None} + assert "note" not in row_missing diff --git a/tests/unit/observability/test_observability_core.py b/tests/unit/observability/test_observability_core.py index baabb45fc..49bf65fec 100644 --- a/tests/unit/observability/test_observability_core.py +++ b/tests/unit/observability/test_observability_core.py @@ -21,6 +21,7 @@ from sqlspec.core import SQL, ArrowResult, StatementConfig from sqlspec.data_dictionary import ColumnMetadata, ForeignKeyMetadata, IndexMetadata, TableMetadata from sqlspec.driver import SyncDataDictionaryBase, SyncDriverAdapterBase +from sqlspec.exceptions import SQLSpecError from sqlspec.observability import ( GCPLogFormatter, LifecycleDispatcher, @@ -1026,3 +1027,38 @@ def test_observability_config_diagnostics_and_logging(caplog) -> None: truncated_batch, was_truncated = _maybe_truncate_parameters([[1, 2, 3, 4], [5, 6, 7, 8]], max_items=2) assert was_truncated is True assert truncated_batch == [[1, 2], [5, 6]] + + +def test_fast_path_with_observability_spans_and_error_recording(tmp_path: Path) -> None: + """Fast-path cached query execution records spans and span exceptions when observability is active.""" + spec = SQLSpec() + config = SqliteConfig( + connection_config={"database": str(tmp_path / "obs_spans.db")}, + observability_config=ObservabilityConfig(print_sql=True), + ) + spec.add_config(config) + + try: + with spec.provide_session(config) as session: + span_manager = _FakeSpanManager() + assert session._observability is not None + session._observability.span_manager = cast("Any", span_manager) + assert session._stmt_cache_enabled is True + + session.execute("CREATE TABLE t_span (id INTEGER PRIMARY KEY)") + span_manager.started.clear() + span_manager.finished.clear() + + session.execute("INSERT INTO t_span (id) VALUES (?)", (1,)) + session.execute("INSERT INTO t_span (id) VALUES (?)", (2,)) + assert len(span_manager.finished) == 2 + assert span_manager.finished[0].attributes["operation"] == "INSERT" + assert span_manager.finished[1].attributes["operation"] == "INSERT" + assert span_manager.finished[1].exception is None + + with pytest.raises(SQLSpecError): + session.execute("INSERT INTO t_span (id) VALUES (?)", (1,)) + assert len(span_manager.finished) == 3 + assert isinstance(span_manager.finished[2].exception, SQLSpecError) + finally: + config.close_pool() diff --git a/tests/unit/tools/test_bench_compare.py b/tests/unit/tools/test_bench_compare.py new file mode 100644 index 000000000..01409a253 --- /dev/null +++ b/tests/unit/tools/test_bench_compare.py @@ -0,0 +1,223 @@ +"""Unit tests for benchmark comparison, metadata parity, and environment collection.""" + +import json +from pathlib import Path +from typing import Any + +from click.testing import CliRunner +from tools.scripts.bench import ( + EXTENDED_SCENARIOS_BY_DRIVER, + SCENARIO_REGISTRY, + _collect_environment_metadata, + _write_json_results, +) +from tools.scripts.bench_compare import compute_comparison_rows, format_run_args, validate_metadata_parity +from tools.scripts.bench_compare import main as bench_compare_main +from tools.scripts.bench_subsystems import _build_stub_driver_benchmarks, _make_temp_db, _setup_test_table +from tools.scripts.bench_subsystems import run_benchmarks as run_subsystem_benchmarks + +__all__ = ( + "test_bench_compare_cli_exits_2_on_hard_metadata_mismatch", + "test_collect_environment_metadata_and_write_json_results", + "test_compute_comparison_rows_ratio_mode_and_fallback", + "test_extended_scenarios_registered_for_all_benchmarked_drivers", + "test_format_run_args_and_cli_print_run_args", + "test_stub_driver_microbenchmarks_run_cleanly", + "test_validate_metadata_parity_detects_hard_and_soft_mismatches", +) + + +def test_stub_driver_microbenchmarks_run_cleanly() -> None: + """All 9 stub-connection driver micro-benchmarks should build and run with positive timings.""" + stub_benches = _build_stub_driver_benchmarks(10) + expected_names = { + "stub driver: fast-path execute(SELECT, tuple)", + "stub driver: fast-path execute(INSERT, tuple)", + "stub driver: fast-path fetch_one_or_none(SELECT, tuple)", + "stub driver: parameterless execute(SELECT)", + "stub driver: scalar-param execute(SELECT, int)", + "stub driver: complex CTE+2-join execute(SELECT, int)", + "stub driver: session-per-request simple SELECT", + "stub driver: session-per-request complex CTE SELECT", + "stub driver: execute_many 100 rows", + } + assert {b.name for b in stub_benches} == expected_names + + db_path = _make_temp_db() + try: + _setup_test_table(db_path) + results = run_subsystem_benchmarks(db_path, iterations=10, warmup=2) + finally: + db_path.unlink(missing_ok=True) + + by_name = {r["name"]: r for r in results} + for name in expected_names: + assert name in by_name + assert by_name[name]["time_per_op_us"] > 0.0 + + +def test_extended_scenarios_registered_for_all_benchmarked_drivers() -> None: + """All hot-path extended scenarios should be registered for local, Postgres, and Oracle drivers.""" + expected_scenarios = ( + "scalar_param_select", + "no_param_select", + "complex_query", + "session_per_request", + "observability_select", + ) + target_drivers = ("sqlite", "aiosqlite", "duckdb", "asyncpg", "psycopg_sync", "psycopg_async", "psqlpy", "oracle") + for drv in target_drivers: + assert drv in EXTENDED_SCENARIOS_BY_DRIVER + driver_scenarios = set(EXTENDED_SCENARIOS_BY_DRIVER[drv]) + for scenario in expected_scenarios: + assert ("raw", scenario) in driver_scenarios + assert ("sqlspec", scenario) in driver_scenarios + assert callable(SCENARIO_REGISTRY["raw", drv, scenario]) + assert callable(SCENARIO_REGISTRY["sqlspec", drv, scenario]) + assert ("raw", "rows") in set(EXTENDED_SCENARIOS_BY_DRIVER["psqlpy"]) + assert ("sqlspec", "rows") in set(EXTENDED_SCENARIOS_BY_DRIVER["psqlpy"]) + + +def test_validate_metadata_parity_detects_hard_and_soft_mismatches() -> None: + """Hard metadata keys should be reported separately from soft metadata keys.""" + baseline = _sample_payload(rows=1000, pool_size=1, iterations=3, warmup=1, mypyc_compiled=False) + current = _sample_payload(rows=10000, pool_size=2, iterations=7, warmup=3, mypyc_compiled=True) + + hard_mismatches, soft_warnings = validate_metadata_parity(baseline, current) + + assert len(hard_mismatches) == 3 + assert any("rows:" in item for item in hard_mismatches) + assert any("pool_size:" in item for item in hard_mismatches) + assert any("mypyc_compiled:" in item for item in hard_mismatches) + assert len(soft_warnings) == 2 + assert any("iterations:" in item for item in soft_warnings) + assert any("warmup:" in item for item in soft_warnings) + + +def test_bench_compare_cli_exits_2_on_hard_metadata_mismatch(tmp_path: Path) -> None: + """CLI should exit with status 2 on hard metadata mismatch unless overridden.""" + base_path = tmp_path / "base.json" + curr_path = tmp_path / "curr.json" + base_path.write_text(json.dumps(_sample_payload(rows=1000)), encoding="utf-8") + curr_path.write_text(json.dumps(_sample_payload(rows=10000)), encoding="utf-8") + + runner = CliRunner() + res = runner.invoke(bench_compare_main, [str(base_path), str(curr_path)]) + assert res.exit_code == 2 + assert "Metadata mismatch" in res.output + + res_allowed = runner.invoke(bench_compare_main, [str(base_path), str(curr_path), "--allow-metadata-mismatch"]) + assert res_allowed.exit_code == 0 + + +def test_format_run_args_and_cli_print_run_args(tmp_path: Path) -> None: + """format_run_args and --print-run-args should emit matching CLI flags from baseline metadata.""" + payload = _sample_payload(rows=1000, iterations=3, pool_size=1, warmup=2) + assert format_run_args(payload) == "--rows 1000 --iterations 3 --pool-size 1 --warmup 2" + + base_path = tmp_path / "base.json" + base_path.write_text(json.dumps(payload), encoding="utf-8") + + runner = CliRunner() + res = runner.invoke(bench_compare_main, [str(base_path), "--print-run-args"]) + assert res.exit_code == 0 + assert res.output.strip() == "--rows 1000 --iterations 3 --pool-size 1 --warmup 2" + + +def test_compute_comparison_rows_ratio_mode_and_fallback() -> None: + """Ratio mode should compare sqlspec/raw ratios and fall back when raw is absent.""" + baseline = _sample_payload(raw_time=1.0, sqlspec_time=1.2) + current_same_ratio = _sample_payload(raw_time=2.0, sqlspec_time=2.4) + + rows = compute_comparison_rows(baseline, current_same_ratio, mode="ratio", threshold=5.0) + sqlspec_row = next(r for r in rows if r["library_key"] == "sqlspec") + assert sqlspec_row["metric_kind"] == "ratio" + assert abs(sqlspec_row["pct_change"]) < 1e-6 + assert sqlspec_row["status_text"] == "unchanged" + + current_regressed_ratio = _sample_payload(raw_time=1.0, sqlspec_time=1.8) + reg_rows = compute_comparison_rows(baseline, current_regressed_ratio, mode="ratio", threshold=10.0) + reg_sqlspec_row = next(r for r in reg_rows if r["library_key"] == "sqlspec") + assert reg_sqlspec_row["status_text"] == "REGRESSED" + assert reg_sqlspec_row["pct_change"] > 40.0 + + no_raw_base = _sample_payload(sqlspec_time=1.0, include_raw=False) + no_raw_curr = _sample_payload(sqlspec_time=1.5, include_raw=False) + fb_rows = compute_comparison_rows(no_raw_base, no_raw_curr, mode="ratio", threshold=10.0) + assert fb_rows[0]["metric_kind"] == "abs-fallback" + assert fb_rows[0]["status_text"] == "REGRESSED" + + +def test_collect_environment_metadata_and_write_json_results(tmp_path: Path) -> None: + """bench.py should record warmup and environment metadata in JSON output.""" + env_meta = _collect_environment_metadata() + assert "python_version" in env_meta + assert "platform" in env_meta + assert isinstance(env_meta["packages"], dict) + for pkg in ("duckdb", "sqlglot", "aiosqlite", "pyarrow", "sqlspec"): + assert pkg in env_meta["packages"] + + out_file = tmp_path / "out.json" + _write_json_results( + [ + { + "driver": "sqlite", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "initialization", + "time": 0.01, + } + ], + str(out_file), + rows=500, + pool_size=1, + iterations=3, + warmup=2, + ) + loaded = json.loads(out_file.read_text(encoding="utf-8")) + assert loaded["metadata"]["warmup"] == 2 + assert "environment" in loaded["metadata"] + assert "python_version" in loaded["metadata"]["environment"] + + +def _sample_payload( + *, + rows: int = 1000, + pool_size: int = 1, + iterations: int = 3, + warmup: int = 1, + mypyc_compiled: bool = False, + raw_time: float = 0.50, + sqlspec_time: float = 0.55, + include_raw: bool = True, +) -> dict[str, Any]: + """Create a synthetic benchmark JSON payload for comparison tests.""" + results: list[dict[str, Any]] = [] + if include_raw: + results.append({ + "driver": "duckdb", + "library": "raw", + "library_key": "raw", + "scenario": "repeated_queries", + "time": raw_time, + "stddev": 0.01, + }) + results.append({ + "driver": "duckdb", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "repeated_queries", + "time": sqlspec_time, + "stddev": 0.01, + }) + return { + "metadata": { + "rows": rows, + "pool_size": pool_size, + "iterations": iterations, + "warmup": warmup, + "mypyc_compiled": mypyc_compiled, + "timestamp": "2026-10-13T18:00:00+0000", + }, + "results": results, + } diff --git a/tools/perf_baselines/README.md b/tools/perf_baselines/README.md index bb04d65dc..d57683f2b 100644 --- a/tools/perf_baselines/README.md +++ b/tools/perf_baselines/README.md @@ -1,32 +1,74 @@ # Adapter benchmark baselines -These JSON files are produced by `tools/scripts/bench.py --extended` and are -compared by the opt-in `make bench-gate-adapters` command. They are development -and management snapshots, not an enabled CI gate: service and host conditions -can produce materially different timings. Re-capture a baseline only after a +These JSON files are produced by `tools/scripts/bench.py --extended` (and +`tools/scripts/bench_subsystems.py` for `subsystems.json`) and are compared by +the opt-in `make bench-gate-adapters` command. They are development and +management snapshots, not an enabled CI gate: service and host conditions can +produce materially different timings. Re-capture a baseline only after a deliberate performance review and run `bench_compare.py` against the previous file first. The checked-in snapshots are interpreted/uncompiled SQLSpec runs. Each file -records `metadata.mypyc_compiled: false`; no compiled-performance claim is made +records `metadata.mypyc_compiled: false`, `metadata.warmup`, and host +`metadata.environment` details (`python_version`, `platform`, `cpu_count`, +`sqlglot_version`, and `driver_versions`); no compiled-performance claim is made by these baselines. The snapshots use `pool_size: 1` so pooled SQLSpec paths are compared with the single raw connection used by the matching scenario. Use `--pool-size` only when deliberately measuring a different pool topology. -The current inventory is: +## Gate execution and comparison modes -- `sqlite.json`, `aiosqlite.json`, and `duckdb.json` for local SQLite-family - paths -- `postgres.json`, `psycopg_sync.json`, `psycopg_async.json`, and `asyncpg.json` - for PostgreSQL +- **Automatic parameter propagation**: `make bench-gate-adapters` invokes + `tools/scripts/bench_compare.py --print-run-args` before running + `tools/scripts/bench.py`, propagating `--rows`, `--iterations`, `--pool-size`, + and `--warmup` directly from the baseline's `metadata` block so fresh runs + always match the recorded baseline topology. +- **Metadata parity checks**: `bench_compare.py` validates `rows`, `pool_size`, + `iterations`, `warmup`, and `mypyc_compiled` between baseline and current JSON + files and exits with code `2` on any mismatch unless + `--allow-metadata-mismatch` is passed. +- **Ratio comparison mode (`--mode ratio`)**: `make bench-gate-adapters` + defaults to `BENCHMARK_MODE=ratio` (`--mode ratio`), which compares the + within-run `sqlspec / raw` median ratio (`current_ratio <= baseline_ratio * + (1 + max_regression_pct / 100)`) to insulate comparisons from host CPU + frequency scaling. Scenarios without a matching `raw` entry in the same JSON + file fall back to absolute median comparison (`abs-fallback`). Pass + `BENCHMARK_MODE=absolute` (or `--mode absolute`) to compare absolute median + timings across all scenarios. + +## Baseline inventory + +- `sqlite.json`, `aiosqlite.json`, and `duckdb.json` for local SQLite-family and + DuckDB paths +- `subsystems.json` for pure-Python subsystem and stub-driver hot-path + micro-benchmarks (`tools/scripts/bench_subsystems.py`) +- `postgres.json`, `psycopg_sync.json`, `psycopg_async.json`, `asyncpg.json`, + and `psqlpy.json` for PostgreSQL - `cockroach.json`, `cockroach_psycopg_sync.json`, `cockroach_psycopg_async.json`, and `cockroach_asyncpg.json` for CockroachDB - `mysql.json` and `mysqlconnector.json` for MySQL Connector/Python -- `oracle.json` for the Oracle sync and async LOB paths -- `adbc.json`, `spanner.json`, and `duckdb.json` for the other registered C8 - scenarios +- `oracle.json` for the Oracle sync and async paths +- `adbc.json` and `spanner.json` for ADBC and Cloud Spanner paths + +## Extended hot-path scenarios + +In addition to core read/write, iterative insert, repeated query, dict-key, and +schema-mapping scenarios, `--extended` captures five hot-path scenarios across +local and service-backed drivers: + +- `scalar_param_select`: repeated `SELECT` calls passing a bare scalar parameter + (`int` or `str`) rather than a wrapping tuple or list +- `no_param_select`: repeated parameterless `SELECT` queries +- `complex_query`: repeated `WITH ... JOIN ...` CTE + multi-join `SELECT` + queries exercising SQLGlot AST caching +- `session_per_request`: repeated `provide_session()` acquire/release cycles + executing a query per session +- `observability_select`: repeated `SELECT` queries with statement observers + enabled via `ObservabilityConfig` + +## Service-backed environment variables The service-backed scenarios use these environment variables: @@ -37,11 +79,13 @@ The service-backed scenarios use these environment variables: `SQLSPEC_BENCH_MYSQL_DATABASE` - `SQLSPEC_BENCH_ADBC_URI`, `SQLSPEC_BENCH_ADBC_DRIVER_NAME`, and optional ADBC username, password, backend, and TLS variables -- `SQLSPEC_BENCH_POSTGRES_DSN` for PostgreSQL psycopg and asyncpg runs -- `SQLSPEC_BENCH_COCKROACH_DSN` for CockroachDB psycopg and asyncpg runs +- `SQLSPEC_BENCH_POSTGRES_DSN` for PostgreSQL `psycopg`, `asyncpg`, and `psqlpy` + runs +- `SQLSPEC_BENCH_COCKROACH_DSN` for CockroachDB `psycopg` and `asyncpg` runs - `SQLSPEC_BENCH_ORACLE_HOST`, `SQLSPEC_BENCH_ORACLE_PORT`, `SQLSPEC_BENCH_ORACLE_SERVICE_NAME`, `SQLSPEC_BENCH_ORACLE_USER`, and - `SQLSPEC_BENCH_ORACLE_PASSWORD` for Oracle LOB runs + `SQLSPEC_BENCH_ORACLE_PASSWORD` for Oracle sync and async runs The Spanner baseline expects `sqlspec_bench_strings` to contain 1,000 rows. -The MySQL and ADBC scenarios create their benchmark tables during the run. +The MySQL, PostgreSQL, Oracle, and ADBC scenarios create their benchmark tables +during the run. diff --git a/tools/perf_baselines/adbc.json b/tools/perf_baselines/adbc.json index 1a0871280..f783cdb23 100644 --- a/tools/perf_baselines/adbc.json +++ b/tools/perf_baselines/adbc.json @@ -3,8 +3,20 @@ "rows": 1000, "pool_size": 1, "iterations": 3, + "warmup": 1, "mypyc_compiled": false, - "timestamp": "2026-07-09T20:51:07+0000" + "environment": { + "python_version": "3.11.9", + "platform": "Linux-7.1.6-1rodete1-amd64-x86_64-with-glibc2.43", + "packages": { + "duckdb": "1.5.6", + "sqlglot": "30.22.0", + "aiosqlite": "0.22.1", + "pyarrow": "26.0.0", + "sqlspec": "0.66.0" + } + }, + "timestamp": "2026-10-13T21:45:22+0000" }, "results": [ { @@ -12,15 +24,15 @@ "library": "raw", "library_key": "raw", "scenario": "rows", - "time": 0.007363428943790495, + "time": 0.6950249669898767, "times": [ - 0.0083366819890216, - 0.007363428943790495, - 0.007219261955469847 + 0.7066386119986419, + 0.6950249669898767, + 0.6572044789936626 ], - "stddev": 0.00060781482130057, - "iqr": 0.0005587100167758763, - "noise_ratio": 0.08254507865023049, + "stddev": 0.025848918638295637, + "iqr": 0.024717066502489615, + "noise_ratio": 0.037191352636217076, "noisy": false }, { @@ -28,16 +40,16 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "rows", - "time": 0.0073847780004143715, + "time": 0.18574406299740076, "times": [ - 0.0073847780004143715, - 0.007760587963275611, - 0.0070952450623735785 + 0.18574406299740076, + 0.18721129800542258, + 0.1832569239923032 ], - "stddev": 0.0003336024645634962, - "iqr": 0.0003326714504510164, - "noise_ratio": 0.045174338963849314, + "stddev": 0.0019989877786468296, + "iqr": 0.0019771870065596886, + "noise_ratio": 0.010762054767127618, "noisy": false } ] -} +} \ No newline at end of file diff --git a/tools/perf_baselines/aiosqlite.json b/tools/perf_baselines/aiosqlite.json index 2a93c7b83..4c3aa72c7 100644 --- a/tools/perf_baselines/aiosqlite.json +++ b/tools/perf_baselines/aiosqlite.json @@ -2,9 +2,21 @@ "metadata": { "rows": 1000, "pool_size": 1, - "iterations": 3, + "iterations": 5, + "warmup": 2, "mypyc_compiled": false, - "timestamp": "2026-07-09T20:51:17+0000" + "environment": { + "python_version": "3.11.9", + "platform": "Linux-7.1.6-1rodete1-amd64-x86_64-with-glibc2.43", + "packages": { + "duckdb": "1.5.6", + "sqlglot": "30.22.0", + "aiosqlite": "0.22.1", + "pyarrow": "26.0.0", + "sqlspec": "0.66.0" + } + }, + "timestamp": "2026-10-13T21:38:07+0000" }, "results": [ { @@ -12,15 +24,17 @@ "library": "raw", "library_key": "raw", "scenario": "initialization", - "time": 0.002717631054110825, + "time": 0.0029059329972369596, "times": [ - 0.002773132990114391, - 0.002717631054110825, - 0.002691857982426882 + 0.003000014999997802, + 0.003127224001218565, + 0.0029059329972369596, + 0.002840783999999985, + 0.0028367930062813684 ], - "stddev": 4.1533807512222796e-05, - "iqr": 4.063750384375453e-05, - "noise_ratio": 0.01528309276912209, + "stddev": 0.00012276764386653932, + "iqr": 0.00015923099999781698, + "noise_ratio": 0.04224723831666805, "noisy": false }, { @@ -28,31 +42,35 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "initialization", - "time": 0.002668604953214526, + "time": 0.005097148008644581, "times": [ - 0.003399751032702625, - 0.002668604953214526, - 0.0024395829532295465 + 0.005097148008644581, + 0.005182039996725507, + 0.005494557990459725, + 0.004662189996452071, + 0.004768411003169604 ], - "stddev": 0.0005014891834770644, - "iqr": 0.0004800840397365391, - "noise_ratio": 0.18792185140516385, - "noisy": true + "stddev": 0.00033412625793214127, + "iqr": 0.00041362899355590343, + "noise_ratio": 0.06555160991312692, + "noisy": false }, { "driver": "aiosqlite", "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "initialization", - "time": 0.004618455073796213, + "time": 0.004718267999123782, "times": [ - 0.004618455073796213, - 0.004851963021792471, - 0.0037743370048701763 + 0.004149066997342743, + 0.004839800007175654, + 0.006165113998576999, + 0.004497543006436899, + 0.004718267999123782 ], - "stddev": 0.0005669125606318043, - "iqr": 0.0005388130084611475, - "noise_ratio": 0.12274939380666562, + "stddev": 0.0007680568290529902, + "iqr": 0.00034225700073875487, + "noise_ratio": 0.16278363780853997, "noisy": true }, { @@ -60,15 +78,17 @@ "library": "raw", "library_key": "raw", "scenario": "write_heavy", - "time": 0.005042461096309125, + "time": 0.0046610529971076176, "times": [ - 0.004879332962445915, - 0.005042461096309125, - 0.005161065026186407 + 0.004841490997932851, + 0.0046610529971076176, + 0.004445350001333281, + 0.0052874989924021065, + 0.004659361991798505 ], - "stddev": 0.00014145119062742995, - "iqr": 0.00014086603187024593, - "noise_ratio": 0.02805201426957293, + "stddev": 0.0003170265794053061, + "iqr": 0.00018212900613434613, + "noise_ratio": 0.06801608555020393, "noisy": false }, { @@ -76,31 +96,35 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "write_heavy", - "time": 0.005528997047804296, + "time": 0.006773177010472864, "times": [ - 0.005528997047804296, - 0.0051234320271760225, - 0.00662932894192636 + 0.00658124199253507, + 0.007037850009510294, + 0.00727463599469047, + 0.006773177010472864, + 0.0067275190085638314 ], - "stddev": 0.0007792024534777046, - "iqr": 0.0007529484573751688, - "noise_ratio": 0.14093016269327643, - "noisy": true + "stddev": 0.0002759374595596812, + "iqr": 0.00031033100094646215, + "noise_ratio": 0.04073973840238037, + "noisy": false }, { "driver": "aiosqlite", "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "write_heavy", - "time": 0.010806650971062481, + "time": 0.008619442000053823, "times": [ - 0.010487912921234965, - 0.011826605070382357, - 0.010806650971062481 + 0.008513795997714624, + 0.008568481003749184, + 0.009109195991186425, + 0.008622344001196325, + 0.008619442000053823 ], - "stddev": 0.0006992849895090613, - "iqr": 0.0006693460745736957, - "noise_ratio": 0.06470876050143308, + "stddev": 0.00024033295090948416, + "iqr": 5.3862997447140515e-05, + "noise_ratio": 0.02788265770661064, "noisy": false }, { @@ -108,31 +132,35 @@ "library": "raw", "library_key": "raw", "scenario": "read_heavy", - "time": 0.006153721013106406, + "time": 0.006024611997418106, "times": [ - 0.006340676103718579, - 0.006153721013106406, - 0.005774626974016428 + 0.006055681995349005, + 0.00700543200946413, + 0.005383481999160722, + 0.00551886999164708, + 0.006024611997418106 ], - "stddev": 0.0002884083082529355, - "iqr": 0.0002830245648510754, - "noise_ratio": 0.04686730315506238, - "noisy": false + "stddev": 0.0006376032983220404, + "iqr": 0.0005368120037019253, + "noise_ratio": 0.10583308910105585, + "noisy": true }, { "driver": "aiosqlite", "library": "sqlspec", "library_key": "sqlspec", "scenario": "read_heavy", - "time": 0.0073613920249044895, + "time": 0.007915085996501148, "times": [ - 0.007489705923944712, - 0.006590571021661162, - 0.0073613920249044895 + 0.007869371998822317, + 0.007915085996501148, + 0.008159139993949793, + 0.007586259001982398, + 0.00825892599823419 ], - "stddev": 0.00048632517383475043, - "iqr": 0.00044956745114177465, - "noise_ratio": 0.06606429493083005, + "stddev": 0.00026405556292787306, + "iqr": 0.00028976799512747675, + "noise_ratio": 0.03336104788306766, "noisy": false }, { @@ -140,15 +168,17 @@ "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "read_heavy", - "time": 0.010083899949677289, + "time": 0.00960170099278912, "times": [ - 0.010083899949677289, - 0.01029131596442312, - 0.010013505001552403 + 0.00975704999291338, + 0.009748195996508002, + 0.00960170099278912, + 0.009501220993115567, + 0.008871644007740542 ], - "stddev": 0.00014442748064092192, - "iqr": 0.00013890548143535852, - "noise_ratio": 0.014322581675906451, + "stddev": 0.0003649555713030961, + "iqr": 0.0002469750033924356, + "noise_ratio": 0.03800947056955615, "noisy": false }, { @@ -156,31 +186,35 @@ "library": "raw", "library_key": "raw", "scenario": "iterative_inserts", - "time": 0.04226128803566098, + "time": 0.03316815300786402, "times": [ - 0.039598928997293115, - 0.048317581997253, - 0.04226128803566098 + 0.032772011996712536, + 0.0340141839988064, + 0.03274309399421327, + 0.03316815300786402, + 0.03575262399681378 ], - "stddev": 0.004468067485541233, - "iqr": 0.004359326499979943, - "noise_ratio": 0.1057248298199284, - "noisy": true + "stddev": 0.0012620588593239793, + "iqr": 0.0012421720020938665, + "noise_ratio": 0.038050320710494516, + "noisy": false }, { "driver": "aiosqlite", "library": "sqlspec", "library_key": "sqlspec", "scenario": "iterative_inserts", - "time": 0.15077839605510235, + "time": 0.04602402899763547, "times": [ - 0.14538006496150047, - 0.1511205150745809, - 0.15077839605510235 + 0.046554497006582096, + 0.04533095200895332, + 0.04602402899763547, + 0.04580913099925965, + 0.05507768401002977 ], - "stddev": 0.003220036012231414, - "iqr": 0.002870225056540221, - "noise_ratio": 0.021356083474018676, + "stddev": 0.0041146534881857455, + "iqr": 0.0007453660073224455, + "noise_ratio": 0.0894022878439682, "noisy": false }, { @@ -188,15 +222,17 @@ "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "iterative_inserts", - "time": 0.3442953589837998, + "time": 0.20613000499724876, "times": [ - 0.3184149160515517, - 0.3442953589837998, - 0.34637885307893157 + 0.21550685999682173, + 0.20613000499724876, + 0.20384249999187887, + 0.20573702300316654, + 0.21198704600101337 ], - "stddev": 0.015578404112130502, - "iqr": 0.013981968513689935, - "noise_ratio": 0.045247209134943685, + "stddev": 0.00490152622854084, + "iqr": 0.006250022997846827, + "noise_ratio": 0.02377881002140499, "noisy": false }, { @@ -204,31 +240,35 @@ "library": "raw", "library_key": "raw", "scenario": "repeated_queries", - "time": 0.27755877596791834, + "time": 0.1802124670066405, "times": [ - 0.2943803749512881, - 0.27755877596791834, - 0.27284023095853627 + 0.1802124670066405, + 0.21900316599931102, + 0.19717532000504434, + 0.17829404000076465, + 0.1753226220025681 ], - "stddev": 0.011322607639816268, - "iqr": 0.010770071996375918, - "noise_ratio": 0.040793549403478395, - "noisy": false + "stddev": 0.018309284325445305, + "iqr": 0.018881280004279688, + "noise_ratio": 0.1015983224111273, + "noisy": true }, { "driver": "aiosqlite", "library": "sqlspec", "library_key": "sqlspec", "scenario": "repeated_queries", - "time": 0.36091821803711355, + "time": 0.16138672298984602, "times": [ - 0.356790196034126, - 0.36091821803711355, - 0.36353744997177273 + 0.17164194499491714, + 0.16138672298984602, + 0.1611260450008558, + 0.1605991310061654, + 0.17284013899916317 ], - "stddev": 0.0034016265051633136, - "iqr": 0.0033736269688233733, - "noise_ratio": 0.009424923251764257, + "stddev": 0.006157686442135458, + "iqr": 0.010515899994061328, + "noise_ratio": 0.03815485145282293, "noisy": false }, { @@ -236,15 +276,17 @@ "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "repeated_queries", - "time": 0.45875454996712506, + "time": 0.4177528580039507, "times": [ - 0.4920030679786578, - 0.45875454996712506, - 0.4536135320086032 + 0.4177528580039507, + 0.4196554600057425, + 0.4444802340003662, + 0.402017619999242, + 0.4157857789978152 ], - "stddev": 0.0208392677718819, - "iqr": 0.019194767985027283, - "noise_ratio": 0.045425746237013384, + "stddev": 0.015374954324982959, + "iqr": 0.0038696810079272836, + "noise_ratio": 0.0368039476700302, "noisy": false }, { @@ -252,32 +294,216 @@ "library": "raw", "library_key": "raw", "scenario": "worker_hops", - "time": 0.08135498594492674, + "time": 0.06030553000164218, "times": [ - 0.08067048096563667, - 0.0959955370053649, - 0.08135498594492674 + 0.06227472001046408, + 0.05850649099738803, + 0.06533589299942832, + 0.057062860010773875, + 0.06030553000164218 ], - "stddev": 0.008657093671673539, - "iqr": 0.007662528019864112, - "noise_ratio": 0.10641134739466318, - "noisy": true + "stddev": 0.0032468092242301373, + "iqr": 0.003768229013076052, + "noise_ratio": 0.05383932823642746, + "noisy": false }, { "driver": "aiosqlite", "library": "sqlspec", "library_key": "sqlspec", "scenario": "worker_hops", - "time": 0.19313774502370507, + "time": 0.049655034992611036, + "times": [ + 0.047336261995951645, + 0.05104221199871972, + 0.049655034992611036, + 0.04931402600777801, + 0.05952784999681171 + ], + "stddev": 0.004745972212920856, + "iqr": 0.0017281859909417108, + "noise_ratio": 0.09557887158124215, + "noisy": false + }, + { + "driver": "aiosqlite", + "library": "raw", + "library_key": "raw", + "scenario": "scalar_param_select", + "time": 0.06830905401147902, + "times": [ + 0.0773702599981334, + 0.06864907201088499, + 0.05593628800124861, + 0.06162153200421017, + 0.06830905401147902 + ], + "stddev": 0.00808346849937315, + "iqr": 0.007027540006674826, + "noise_ratio": 0.11833670684437762, + "noisy": true + }, + { + "driver": "aiosqlite", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "scalar_param_select", + "time": 0.05039896701055113, + "times": [ + 0.04913739000039641, + 0.04926970601081848, + 0.05420183399110101, + 0.05039896701055113, + 0.0699791500082938 + ], + "stddev": 0.008839931812373117, + "iqr": 0.00493212798028253, + "noise_ratio": 0.17539906741585515, + "noisy": true + }, + { + "driver": "aiosqlite", + "library": "raw", + "library_key": "raw", + "scenario": "no_param_select", + "time": 0.06404038700566161, + "times": [ + 0.06529682999826036, + 0.059004966999054886, + 0.07815034200029913, + 0.06404038700566161, + 0.06270276800205465 + ], + "stddev": 0.007273892648509703, + "iqr": 0.00259406199620571, + "noise_ratio": 0.11358289649103215, + "noisy": true + }, + { + "driver": "aiosqlite", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "no_param_select", + "time": 0.04174996200890746, + "times": [ + 0.04366827699413989, + 0.04182001001026947, + 0.04174996200890746, + 0.041243350991862826, + 0.04117086100450251 + ], + "stddev": 0.0010141370775172025, + "iqr": 0.0005766590184066445, + "noise_ratio": 0.024290730547271728, + "noisy": false + }, + { + "driver": "aiosqlite", + "library": "raw", + "library_key": "raw", + "scenario": "complex_query", + "time": 0.03486508499190677, + "times": [ + 0.03542514001310337, + 0.03402129700407386, + 0.03471905700280331, + 0.03794261999428272, + 0.03486508499190677 + ], + "stddev": 0.0015096393757468506, + "iqr": 0.0007060830103000626, + "noise_ratio": 0.04329946065231974, + "noisy": false + }, + { + "driver": "aiosqlite", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "complex_query", + "time": 0.027161341000464745, + "times": [ + 0.027984411994111724, + 0.027161341000464745, + 0.026085546996910125, + 0.026560199999948964, + 0.02716834300372284 + ], + "stddev": 0.0007163733449434427, + "iqr": 0.0006081430037738755, + "noise_ratio": 0.026374741399225656, + "noisy": false + }, + { + "driver": "aiosqlite", + "library": "raw", + "library_key": "raw", + "scenario": "session_per_request", + "time": 0.04309646999172401, + "times": [ + 0.04221086901088711, + 0.042075720004504547, + 0.04317534499568865, + 0.04309646999172401, + 0.04548183400765993 + ], + "stddev": 0.0013656641765787503, + "iqr": 0.0009644759848015383, + "noise_ratio": 0.0316885391504456, + "noisy": false + }, + { + "driver": "aiosqlite", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "session_per_request", + "time": 0.04295089899096638, + "times": [ + 0.056219097998109646, + 0.042206051002722234, + 0.04096175999438856, + 0.04295089899096638, + 0.04709633499442134 + ], + "stddev": 0.006217772989444468, + "iqr": 0.004890283991699107, + "noise_ratio": 0.14476467630519718, + "noisy": true + }, + { + "driver": "aiosqlite", + "library": "raw", + "library_key": "raw", + "scenario": "observability_select", + "time": 0.0833016100077657, + "times": [ + 0.0833016100077657, + 0.0820087269967189, + 0.08787153899902478, + 0.0839166999940062, + 0.0827749780000886 + ], + "stddev": 0.0022883482606882656, + "iqr": 0.0011417219939175993, + "noise_ratio": 0.027470636647658274, + "noisy": false + }, + { + "driver": "aiosqlite", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "observability_select", + "time": 0.08435021199693438, "times": [ - 0.19621139706578106, - 0.18743419996462762, - 0.19313774502370507 + 0.08169943900429644, + 0.08435021199693438, + 0.08069263699871954, + 0.09623752698826138, + 0.0894551550009055 ], - "stddev": 0.004453780268288593, - "iqr": 0.004388598550576717, - "noise_ratio": 0.02306012358041123, + "stddev": 0.006421525646337266, + "iqr": 0.007755715996609069, + "noise_ratio": 0.07612933618436726, "noisy": false } ] -} +} \ No newline at end of file diff --git a/tools/perf_baselines/asyncpg.json b/tools/perf_baselines/asyncpg.json index a97f953fb..f2d65aa57 100644 --- a/tools/perf_baselines/asyncpg.json +++ b/tools/perf_baselines/asyncpg.json @@ -3,8 +3,20 @@ "rows": 1000, "pool_size": 1, "iterations": 3, + "warmup": 1, "mypyc_compiled": false, - "timestamp": "2026-07-09T20:52:56+0000" + "environment": { + "python_version": "3.11.9", + "platform": "Linux-7.1.6-1rodete1-amd64-x86_64-with-glibc2.43", + "packages": { + "duckdb": "1.5.6", + "sqlglot": "30.22.0", + "aiosqlite": "0.22.1", + "pyarrow": "26.0.0", + "sqlspec": "0.66.0" + } + }, + "timestamp": "2026-10-13T21:40:08+0000" }, "results": [ { @@ -12,15 +24,15 @@ "library": "raw", "library_key": "raw", "scenario": "initialization", - "time": 0.04940926597919315, + "time": 0.017716923000989482, "times": [ - 0.08479561505373567, - 0.04940926597919315, - 0.04036029998678714 + 0.025503010998363607, + 0.017716923000989482, + 0.017171163999591954 ], - "stddev": 0.023482527942621793, - "iqr": 0.022217657533474267, - "noise_ratio": 0.4752656708664843, + "stddev": 0.004660842084169627, + "iqr": 0.004165923499385826, + "noise_ratio": 0.26307288708707044, "noisy": true }, { @@ -28,47 +40,47 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "initialization", - "time": 0.07451956707518548, + "time": 0.022952098996029235, "times": [ - 0.09807045001070946, - 0.07451956707518548, - 0.043184979003854096 + 0.022945840988541022, + 0.0236565170052927, + 0.022952098996029235 ], - "stddev": 0.027534570465408085, - "iqr": 0.027442735503427684, - "noise_ratio": 0.36949450387476707, - "noisy": true + "stddev": 0.0004085144418537434, + "iqr": 0.0003553380083758384, + "noise_ratio": 0.017798565696515046, + "noisy": false }, { "driver": "asyncpg", "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "initialization", - "time": 0.06410804996266961, + "time": 0.021034752993728034, "times": [ - 0.07677872304338962, - 0.05704159999731928, - 0.06410804996266961 + 0.021034752993728034, + 0.02037326899881009, + 0.02301413299574051 ], - "stddev": 0.010000289142211531, - "iqr": 0.009868561523035169, - "noise_ratio": 0.1559911609857848, - "noisy": true + "stddev": 0.0013741463361048821, + "iqr": 0.0013204319984652102, + "noise_ratio": 0.06532742916042862, + "noisy": false }, { "driver": "asyncpg", "library": "raw", "library_key": "raw", "scenario": "write_heavy", - "time": 0.0706113880733028, + "time": 0.02644908000365831, "times": [ - 0.0719162680907175, - 0.0706113880733028, - 0.06509560893755406 + 0.02659284799301531, + 0.025781497010029852, + 0.02644908000365831 ], - "stddev": 0.003620495422310816, - "iqr": 0.0034103295765817165, - "noise_ratio": 0.051273534214513986, + "stddev": 0.00043294085541737277, + "iqr": 0.00040567549149272963, + "noise_ratio": 0.016368843655714695, "noisy": false }, { @@ -76,31 +88,31 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "write_heavy", - "time": 0.0779197090305388, + "time": 0.030817200997262262, "times": [ - 0.06527305103372782, - 0.08501916204113513, - 0.0779197090305388 + 0.030604447005316615, + 0.03229530999669805, + 0.030817200997262262 ], - "stddev": 0.010002075546728792, - "iqr": 0.009873055503703654, - "noise_ratio": 0.12836387187750808, - "noisy": true + "stddev": 0.0009209676189498793, + "iqr": 0.0008454314956907183, + "noise_ratio": 0.02988485615652428, + "noisy": false }, { "driver": "asyncpg", "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "write_heavy", - "time": 0.07024051598273218, + "time": 0.02926394098903984, "times": [ - 0.06564669101499021, - 0.07116741896606982, - 0.07024051598273218 + 0.02926394098903984, + 0.028414348009391688, + 0.03134076399146579 ], - "stddev": 0.0029563720470818433, - "iqr": 0.0027603639755398035, - "noise_ratio": 0.04208927007040542, + "stddev": 0.0015054851108868375, + "iqr": 0.0014632079910370521, + "noise_ratio": 0.05144505695424562, "noisy": false }, { @@ -108,31 +120,31 @@ "library": "raw", "library_key": "raw", "scenario": "read_heavy", - "time": 0.03612738801166415, + "time": 0.011813648990937509, "times": [ - 0.050835762987844646, - 0.03612738801166415, - 0.03557522804476321 + 0.011871584996697493, + 0.011159223009599373, + 0.011813648990937509 ], - "stddev": 0.008655683121683602, - "iqr": 0.007630267471540719, - "noise_ratio": 0.2395878472833135, - "noisy": true + "stddev": 0.0003956196714177615, + "iqr": 0.0003561809935490601, + "noise_ratio": 0.033488355013869923, + "noisy": false }, { "driver": "asyncpg", "library": "sqlspec", "library_key": "sqlspec", "scenario": "read_heavy", - "time": 0.040005053975619376, + "time": 0.016787722997833043, "times": [ - 0.040005053975619376, - 0.04019563097972423, - 0.035273249028250575 + 0.016509634006069973, + 0.01719108599354513, + 0.016787722997833043 ], - "stddev": 0.002788552244762833, - "iqr": 0.0024611909757368267, - "noise_ratio": 0.06970499893494168, + "stddev": 0.0003426397536975414, + "iqr": 0.0003407259937375784, + "noise_ratio": 0.020410138631770925, "noisy": false }, { @@ -140,15 +152,15 @@ "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "read_heavy", - "time": 0.05618906090967357, + "time": 0.015311098992242478, "times": [ - 0.06085908296518028, - 0.05618906090967357, - 0.054844368947669864 + 0.015311098992242478, + 0.014824878991930746, + 0.015347246997407638 ], - "stddev": 0.0031568467174284513, - "iqr": 0.003007357008755207, - "noise_ratio": 0.05618258547697075, + "stddev": 0.0002917147294094202, + "iqr": 0.000261184002738446, + "noise_ratio": 0.019052501035831615, "noisy": false }, { @@ -156,31 +168,31 @@ "library": "raw", "library_key": "raw", "scenario": "iterative_inserts", - "time": 0.3789590820670128, + "time": 0.7087635150091955, "times": [ - 0.4792086579836905, - 0.3789590820670128, - 0.36379395704716444 + 0.6293624990066746, + 0.7087635150091955, + 0.723250259004999 ], - "stddev": 0.06271697229167597, - "iqr": 0.05770735046826303, - "noise_ratio": 0.16549800561472092, - "noisy": true + "stddev": 0.05054585141455805, + "iqr": 0.046943879999162164, + "noise_ratio": 0.07131553803796216, + "noisy": false }, { "driver": "asyncpg", "library": "sqlspec", "library_key": "sqlspec", "scenario": "iterative_inserts", - "time": 0.47340100491419435, + "time": 0.778000054997392, "times": [ - 0.5251002670265734, - 0.47340100491419435, - 0.460795114049688 + 0.778000054997392, + 0.817270343992277, + 0.7690069039963419 ], - "stddev": 0.03407558971569178, - "iqr": 0.03215257648844272, - "noise_ratio": 0.07198039159605947, + "stddev": 0.02566577488573293, + "iqr": 0.024131719997967593, + "noise_ratio": 0.03298942554164597, "noisy": false }, { @@ -188,15 +200,15 @@ "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "iterative_inserts", - "time": 0.35054534999653697, + "time": 0.26324758399277925, "times": [ - 0.35054534999653697, - 0.34788735292386264, - 0.35071414103731513 + 0.29380316600145306, + 0.2523192360094981, + 0.26324758399277925 ], - "stddev": 0.0015855687671489063, - "iqr": 0.001413394056726247, - "noise_ratio": 0.004523148765672031, + "stddev": 0.021501894602995234, + "iqr": 0.02074196499597747, + "noise_ratio": 0.08167936159894641, "noisy": false }, { @@ -204,47 +216,47 @@ "library": "raw", "library_key": "raw", "scenario": "repeated_queries", - "time": 0.23306162597145885, + "time": 0.1380157100065844, "times": [ - 0.21710339200217277, - 0.24659188196528703, - 0.23306162597145885 + 0.16627490599057637, + 0.1380157100065844, + 0.11618138999619987 ], - "stddev": 0.014760894840657473, - "iqr": 0.01474424498155713, - "noise_ratio": 0.06333472865440799, - "noisy": false + "stddev": 0.02511533406990107, + "iqr": 0.02504675799718825, + "noise_ratio": 0.18197445833306133, + "noisy": true }, { "driver": "asyncpg", "library": "sqlspec", "library_key": "sqlspec", "scenario": "repeated_queries", - "time": 0.5471023420104757, + "time": 0.2558447600022191, "times": [ - 0.5471023420104757, - 0.6763798990286887, - 0.522445933893323 + 0.2558447600022191, + 0.2654138190118829, + 0.25346861500293016 ], - "stddev": 0.08268039956375482, - "iqr": 0.07696698256768286, - "noise_ratio": 0.15112419234018137, - "noisy": true + "stddev": 0.006323248824820633, + "iqr": 0.005972602004476357, + "noise_ratio": 0.024715178160247594, + "noisy": false }, { "driver": "asyncpg", "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "repeated_queries", - "time": 0.5908310130471364, + "time": 0.4017653000046266, "times": [ - 0.5961030019680038, - 0.5827819199766964, - 0.5908310130471364 + 0.3993142379913479, + 0.4017653000046266, + 0.4032646300038323 ], - "stddev": 0.0067086137646249185, - "iqr": 0.006660540995653719, - "noise_ratio": 0.01135453897388712, + "stddev": 0.001994212145675424, + "iqr": 0.001975196006242186, + "noise_ratio": 0.004963624647654885, "noisy": false }, { @@ -252,32 +264,192 @@ "library": "raw", "library_key": "raw", "scenario": "rows", - "time": 0.06983598996885121, + "time": 0.03334548100247048, "times": [ - 0.050157507066614926, - 0.06983598996885121, - 0.07398683298379183 + 0.031022611001390032, + 0.03566010799841024, + 0.03334548100247048 ], - "stddev": 0.012729944754628263, - "iqr": 0.011914662958588451, - "noise_ratio": 0.18228344382754755, - "noisy": true + "stddev": 0.002318749719483327, + "iqr": 0.0023187484985101037, + "noise_ratio": 0.06953715015571485, + "noisy": false }, { "driver": "asyncpg", "library": "sqlspec", "library_key": "sqlspec", "scenario": "rows", - "time": 0.06500533397775143, + "time": 0.03567721899889875, + "times": [ + 0.03413032100070268, + 0.03567721899889875, + 0.036055399003089406 + ], + "stddev": 0.0010199541531907149, + "iqr": 0.0009625390011933632, + "noise_ratio": 0.02858838726253293, + "noisy": false + }, + { + "driver": "asyncpg", + "library": "raw", + "library_key": "raw", + "scenario": "scalar_param_select", + "time": 0.055791810009395704, + "times": [ + 0.057500263006659225, + 0.05082869800389744, + 0.055791810009395704 + ], + "stddev": 0.0034655704679281752, + "iqr": 0.0033357825013808906, + "noise_ratio": 0.06211611466529855, + "noisy": false + }, + { + "driver": "asyncpg", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "scalar_param_select", + "time": 0.0876976849976927, + "times": [ + 0.0876976849976927, + 0.09220535999338608, + 0.07946519099641591 + ], + "stddev": 0.006460198622065444, + "iqr": 0.006370084498485085, + "noise_ratio": 0.07366441454224715, + "noisy": false + }, + { + "driver": "asyncpg", + "library": "raw", + "library_key": "raw", + "scenario": "no_param_select", + "time": 0.04912416500155814, + "times": [ + 0.04912416500155814, + 0.04726709499664139, + 0.059943817992461845 + ], + "stddev": 0.0068460804572092705, + "iqr": 0.006338361497910228, + "noise_ratio": 0.139362785240057, + "noisy": true + }, + { + "driver": "asyncpg", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "no_param_select", + "time": 0.07471979600086343, "times": [ - 0.06500533397775143, - 0.09050842397846282, - 0.061101199011318386 + 0.10403719200985506, + 0.06813076500839088, + 0.07471979600086343 ], - "stddev": 0.015970988018324313, - "iqr": 0.014703612483572215, - "noise_ratio": 0.24568734657667482, + "stddev": 0.019114551778593877, + "iqr": 0.01795321350073209, + "noise_ratio": 0.2558164342200961, "noisy": true + }, + { + "driver": "asyncpg", + "library": "raw", + "library_key": "raw", + "scenario": "complex_query", + "time": 0.07143511698814109, + "times": [ + 0.07541338700684719, + 0.07138984500488732, + 0.07143511698814109 + ], + "stddev": 0.0023100350695656395, + "iqr": 0.002011771000979934, + "noise_ratio": 0.03233752763293055, + "noisy": false + }, + { + "driver": "asyncpg", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "complex_query", + "time": 0.10107815499941353, + "times": [ + 0.10925904200121295, + 0.10107815499941353, + 0.09988189100113232 + ], + "stddev": 0.005103739161440163, + "iqr": 0.004688575500040315, + "noise_ratio": 0.0504929988232351, + "noisy": false + }, + { + "driver": "asyncpg", + "library": "raw", + "library_key": "raw", + "scenario": "session_per_request", + "time": 0.06375280999054667, + "times": [ + 0.06365817600453738, + 0.06375280999054667, + 0.0648492299951613 + ], + "stddev": 0.0006620299623642798, + "iqr": 0.0005955269953119569, + "noise_ratio": 0.010384326000100173, + "noisy": false + }, + { + "driver": "asyncpg", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "session_per_request", + "time": 0.19294942199485376, + "times": [ + 0.20892111099965405, + 0.19294942199485376, + 0.18774036401009653 + ], + "stddev": 0.011036705258592586, + "iqr": 0.01059037349477876, + "noise_ratio": 0.05719999129557875, + "noisy": false + }, + { + "driver": "asyncpg", + "library": "raw", + "library_key": "raw", + "scenario": "observability_select", + "time": 0.07929844300088007, + "times": [ + 0.062196694998419844, + 0.09391782700549811, + 0.07929844300088007 + ], + "stddev": 0.015876746041641212, + "iqr": 0.015860566003539134, + "noise_ratio": 0.20021510436800138, + "noisy": true + }, + { + "driver": "asyncpg", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "observability_select", + "time": 0.1119311509974068, + "times": [ + 0.12797204099479131, + 0.1119311509974068, + 0.10658306400000583 + ], + "stddev": 0.011131041605374151, + "iqr": 0.01069448849739274, + "noise_ratio": 0.09944543146556255, + "noisy": false } ] -} +} \ No newline at end of file diff --git a/tools/perf_baselines/duckdb.json b/tools/perf_baselines/duckdb.json index 72fcec951..8149ab4c0 100644 --- a/tools/perf_baselines/duckdb.json +++ b/tools/perf_baselines/duckdb.json @@ -2,9 +2,21 @@ "metadata": { "rows": 1000, "pool_size": 1, - "iterations": 3, + "iterations": 5, + "warmup": 2, "mypyc_compiled": false, - "timestamp": "2026-07-09T20:51:31+0000" + "environment": { + "python_version": "3.11.9", + "platform": "Linux-7.1.6-1rodete1-amd64-x86_64-with-glibc2.43", + "packages": { + "duckdb": "1.5.6", + "sqlglot": "30.22.0", + "aiosqlite": "0.22.1", + "pyarrow": "26.0.0", + "sqlspec": "0.66.0" + } + }, + "timestamp": "2026-10-13T21:39:11+0000" }, "results": [ { @@ -12,63 +24,71 @@ "library": "raw", "library_key": "raw", "scenario": "initialization", - "time": 0.01500460400711745, + "time": 0.017525053990539163, "times": [ - 0.01312481495551765, - 0.01500460400711745, - 0.01760098896920681 + 0.016447093003080226, + 0.018043814998236485, + 0.017525053990539163, + 0.016505255000083707, + 0.01763647500774823 ], - "stddev": 0.0022476267317144533, - "iqr": 0.00223808700684458, - "noise_ratio": 0.14979580471755796, - "noisy": true + "stddev": 0.0007163728931166758, + "iqr": 0.001131220007664524, + "noise_ratio": 0.04087707196242631, + "noisy": false }, { "driver": "duckdb", "library": "sqlspec", "library_key": "sqlspec", "scenario": "initialization", - "time": 0.015087272971868515, + "time": 0.017974551999941468, "times": [ - 0.015491381986066699, - 0.015087272971868515, - 0.014010834973305464 + 0.027214710004045628, + 0.016826530001708306, + 0.01896449898777064, + 0.017974551999941468, + 0.016810950997751206 ], - "stddev": 0.0007652932245336722, - "iqr": 0.0007402735063806176, - "noise_ratio": 0.05072442355624012, - "noisy": false + "stddev": 0.00437298426049861, + "iqr": 0.002137968986062333, + "noise_ratio": 0.24328752452427468, + "noisy": true }, { "driver": "duckdb", "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "initialization", - "time": 0.013063417980447412, + "time": 0.022122177004348487, "times": [ - 0.014519161079078913, - 0.013063417980447412, - 0.01289885607548058 + 0.016562382006668486, + 0.024464873000397347, + 0.025259608999476768, + 0.022122177004348487, + 0.020355571003165096 ], - "stddev": 0.0008917825682741047, - "iqr": 0.0008101525017991662, - "noise_ratio": 0.06826563841169858, - "noisy": false + "stddev": 0.003488630692328315, + "iqr": 0.004109301997232251, + "noise_ratio": 0.15769834459070486, + "noisy": true }, { "driver": "duckdb", "library": "raw", "library_key": "raw", "scenario": "write_heavy", - "time": 0.5354250569362193, + "time": 0.27236090099904686, "times": [ - 0.5354250569362193, - 0.5278781009837985, - 0.5440811680164188 + 0.2730083670030581, + 0.26177087699761614, + 0.2655430360027822, + 0.2809402230050182, + 0.27236090099904686 ], - "stddev": 0.008107858168099632, - "iqr": 0.008101533516310155, - "noise_ratio": 0.01514284410687461, + "stddev": 0.007403880521261014, + "iqr": 0.00746533100027591, + "noise_ratio": 0.027184079998644608, "noisy": false }, { @@ -76,47 +96,53 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "write_heavy", - "time": 0.03507585893385112, + "time": 0.0251594190049218, "times": [ - 0.03277365898247808, - 0.03507585893385112, - 0.04870448703877628 + 0.0251594190049218, + 0.02427840800373815, + 0.025719371988088824, + 0.024111591003020294, + 0.02617697800451424 ], - "stddev": 0.008610370793388203, - "iqr": 0.007965414028149098, - "noise_ratio": 0.2454785443636985, - "noisy": true + "stddev": 0.0008942051542770471, + "iqr": 0.0014409639843506739, + "noise_ratio": 0.03554156612686957, + "noisy": false }, { "driver": "duckdb", "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "write_heavy", - "time": 0.24554307002108544, + "time": 0.16889518398966175, "times": [ - 0.24554307002108544, - 0.2868850369704887, - 0.2380791320465505 + 0.16889518398966175, + 0.17277227999875322, + 0.15966579799714964, + 0.16098608399624936, + 0.16901243200118188 ], - "stddev": 0.026289684151702783, - "iqr": 0.024402952461969107, - "noise_ratio": 0.10706750611794996, - "noisy": true + "stddev": 0.00566187591403311, + "iqr": 0.008026348004932515, + "noise_ratio": 0.033523015756208176, + "noisy": false }, { "driver": "duckdb", "library": "raw", "library_key": "raw", "scenario": "read_heavy", - "time": 0.5579370099585503, + "time": 0.27635032900434453, "times": [ - 0.5456334620248526, - 0.5679827029816806, - 0.5579370099585503 + 0.28747195399773773, + 0.27950664900708944, + 0.2708783139969455, + 0.27635032900434453, + 0.2647874040121678 ], - "stddev": 0.011193612847051988, - "iqr": 0.011174620478413999, - "noise_ratio": 0.020062502840389764, + "stddev": 0.008599082462383771, + "iqr": 0.008628335010143928, + "noise_ratio": 0.031116599330151637, "noisy": false }, { @@ -124,31 +150,35 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "read_heavy", - "time": 0.02181598509196192, + "time": 0.02813689700269606, "times": [ - 0.020501775084994733, - 0.02181598509196192, - 0.02576112002134323 + 0.02916548799839802, + 0.03059967099397909, + 0.02813689700269606, + 0.027920551001443528, + 0.026502395994612016 ], - "stddev": 0.002737150181999877, - "iqr": 0.002629672468174249, - "noise_ratio": 0.125465348938489, - "noisy": true + "stddev": 0.0015250733145710964, + "iqr": 0.0012449369969544932, + "noise_ratio": 0.054201901312179696, + "noisy": false }, { "driver": "duckdb", "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "read_heavy", - "time": 0.14476253802422434, + "time": 0.1688470560038695, "times": [ - 0.14476253802422434, - 0.1383658549748361, - 0.1516585439676419 + 0.16352912900038064, + 0.18909141200128943, + 0.1688470560038695, + 0.1749409580079373, + 0.16221145800955128 ], - "stddev": 0.006647907348249282, - "iqr": 0.0066463444964028895, - "noise_ratio": 0.045922843292073474, + "stddev": 0.010930427610856013, + "iqr": 0.011411829007556662, + "noise_ratio": 0.06473567185326297, "noisy": false }, { @@ -156,15 +186,17 @@ "library": "raw", "library_key": "raw", "scenario": "iterative_inserts", - "time": 0.728288913029246, + "time": 0.4838850100059062, "times": [ - 0.7497461149469018, - 0.728288913029246, - 0.7237348459893838 + 0.48746505301096477, + 0.5004024140071124, + 0.4831144710042281, + 0.4838850100059062, + 0.4807628340058727 ], - "stddev": 0.013890867224660907, - "iqr": 0.01300563447875902, - "noise_ratio": 0.01907329217313389, + "stddev": 0.0078016727103478395, + "iqr": 0.0043505820067366585, + "noise_ratio": 0.016122989034631625, "noisy": false }, { @@ -172,15 +204,17 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "iterative_inserts", - "time": 0.7646942570572719, + "time": 0.5211548799998127, "times": [ - 0.7265271419892088, - 0.7646942570572719, - 0.7901537410216406 + 0.5169741930003511, + 0.529034426988801, + 0.515003958993475, + 0.5343208289996255, + 0.5211548799998127 ], - "stddev": 0.03202410056188041, - "iqr": 0.03181329951621592, - "noise_ratio": 0.041878306612523654, + "stddev": 0.00818205303833578, + "iqr": 0.012060233988449909, + "noise_ratio": 0.01569984922397488, "noisy": false }, { @@ -188,31 +222,35 @@ "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "iterative_inserts", - "time": 0.4311121969949454, + "time": 0.5087755779968575, "times": [ - 0.37307053699623793, - 0.46172096696682274, - 0.4311121969949454 + 0.5150670680013718, + 0.5108952939917799, + 0.4977407469996251, + 0.5087755779968575, + 0.48492712900042534 ], - "stddev": 0.04502708412456963, - "iqr": 0.044325214985292405, - "noise_ratio": 0.10444400422541872, - "noisy": true + "stddev": 0.012190548764696415, + "iqr": 0.013154546992154792, + "noise_ratio": 0.02396056196858512, + "noisy": false }, { "driver": "duckdb", "library": "raw", "library_key": "raw", "scenario": "repeated_queries", - "time": 0.8126708290074021, + "time": 0.5912800810037879, "times": [ - 0.8529211860150099, - 0.8126708290074021, - 0.8031523550162092 + 0.5912800810037879, + 0.5926464279909851, + 0.5968218740017619, + 0.5694752499985043, + 0.5844443969981512 ], - "stddev": 0.026418519783629323, - "iqr": 0.024884415499400347, - "noise_ratio": 0.032508266373849, + "stddev": 0.010727259434074375, + "iqr": 0.008202030992833897, + "noise_ratio": 0.018142433304810843, "noisy": false }, { @@ -220,15 +258,17 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "repeated_queries", - "time": 0.31618001603055745, + "time": 0.40210071598994546, "times": [ - 0.3080287959892303, - 0.32897085207514465, - 0.31618001603055745 + 0.40191533899633214, + 0.40210071598994546, + 0.38879104499937966, + 0.4024814529984724, + 0.40937379500246607 ], - "stddev": 0.010556337653965887, - "iqr": 0.010471028042957187, - "noise_ratio": 0.033387112147358684, + "stddev": 0.007473291566128634, + "iqr": 0.0005661140021402389, + "noise_ratio": 0.018585621136559986, "noisy": false }, { @@ -236,15 +276,17 @@ "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "repeated_queries", - "time": 0.49589174101129174, + "time": 0.6077469360025134, "times": [ - 0.5090508409775794, - 0.4797938499832526, - 0.49589174101129174 + 0.6077469360025134, + 0.5911962840036722, + 0.6038923649903154, + 0.6194470179907512, + 0.6114226699864957 ], - "stddev": 0.01465307436180483, - "iqr": 0.014628495497163385, - "noise_ratio": 0.029548938104760995, + "stddev": 0.010417842280132775, + "iqr": 0.007530304996180348, + "noise_ratio": 0.017141743813069082, "noisy": false }, { @@ -252,15 +294,17 @@ "library": "raw", "library_key": "raw", "scenario": "bulk", - "time": 0.19508839305490255, + "time": 0.22836395900230855, "times": [ - 0.20273231191094965, - 0.19508839305490255, - 0.1872070439858362 + 0.24037164299807046, + 0.23213120900618378, + 0.22567248198902234, + 0.22836395900230855, + 0.22686319999047555 ], - "stddev": 0.007762936544599403, - "iqr": 0.00776263396255672, - "noise_ratio": 0.03979189342348382, + "stddev": 0.005938038295275641, + "iqr": 0.0052680090157082304, + "noise_ratio": 0.02600251949220942, "noisy": false }, { @@ -268,16 +312,198 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "bulk", - "time": 0.022142705973237753, + "time": 0.028122086005168967, + "times": [ + 0.0282050310052, + 0.02779389900388196, + 0.029596199994557537, + 0.028122086005168967, + 0.02653350900800433 + ], + "stddev": 0.001093766196653912, + "iqr": 0.0004111320013180375, + "noise_ratio": 0.03889349447451632, + "noisy": false + }, + { + "driver": "duckdb", + "library": "raw", + "library_key": "raw", + "scenario": "scalar_param_select", + "time": 0.21619637300318573, + "times": [ + 0.20428137898852583, + 0.21619637300318573, + 0.21665521399700083, + 0.21974622500420082, + 0.2149981660040794 + ], + "stddev": 0.00590839135077033, + "iqr": 0.0016570479929214343, + "noise_ratio": 0.02732881809577475, + "noisy": false + }, + { + "driver": "duckdb", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "scalar_param_select", + "time": 0.2635343500005547, + "times": [ + 0.2635343500005547, + 0.2685508900030982, + 0.26267723699857015, + 0.2684782959986478, + 0.261440753005445 + ], + "stddev": 0.0033503236702337987, + "iqr": 0.005801059000077657, + "noise_ratio": 0.012713043556662525, + "noisy": false + }, + { + "driver": "duckdb", + "library": "raw", + "library_key": "raw", + "scenario": "no_param_select", + "time": 0.19317925500217825, + "times": [ + 0.1998950720007997, + 0.20632363799086306, + 0.19317925500217825, + 0.18973850399197545, + 0.18192282700329088 + ], + "stddev": 0.00936871901324091, + "iqr": 0.010156568008824252, + "noise_ratio": 0.0484975418977326, + "noisy": false + }, + { + "driver": "duckdb", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "no_param_select", + "time": 0.22625853099452797, + "times": [ + 0.22108720098913182, + 0.22625853099452797, + 0.2388700820010854, + 0.22014602599665523, + 0.22822348900081124 + ], + "stddev": 0.007497488074424063, + "iqr": 0.007136288011679426, + "noise_ratio": 0.03313681937856031, + "noisy": false + }, + { + "driver": "duckdb", + "library": "raw", + "library_key": "raw", + "scenario": "complex_query", + "time": 0.7410493529896485, + "times": [ + 0.7508263810013887, + 0.7244813430006616, + 0.7409897610050393, + 0.7410493529896485, + 0.7442528079991462 + ], + "stddev": 0.009717183383181274, + "iqr": 0.003263046994106844, + "noise_ratio": 0.013112734454161262, + "noisy": false + }, + { + "driver": "duckdb", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "complex_query", + "time": 0.785038005997194, + "times": [ + 0.7873634440038586, + 0.785038005997194, + 0.7939347870124038, + 0.7677251519926358, + 0.7666407360084122 + ], + "stddev": 0.012276230418988668, + "iqr": 0.019638292011222802, + "noise_ratio": 0.015637752981646786, + "noisy": false + }, + { + "driver": "duckdb", + "library": "raw", + "library_key": "raw", + "scenario": "session_per_request", + "time": 0.19623292100732215, + "times": [ + 0.19154544899356551, + 0.19623292100732215, + 0.19550153099407908, + 0.20088464800210204, + 0.20162821499980055 + ], + "stddev": 0.004152442517909684, + "iqr": 0.005383117008022964, + "noise_ratio": 0.02116078431994977, + "noisy": false + }, + { + "driver": "duckdb", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "session_per_request", + "time": 0.2186708630033536, + "times": [ + 0.23873932000424247, + 0.2186708630033536, + 0.21853418899991084, + 0.21829681300732773, + 0.22194015799323097 + ], + "stddev": 0.008794532505014267, + "iqr": 0.00340596899332013, + "noise_ratio": 0.04021812684243804, + "noisy": false + }, + { + "driver": "duckdb", + "library": "raw", + "library_key": "raw", + "scenario": "observability_select", + "time": 0.340502565013594, + "times": [ + 0.3527091509895399, + 0.340502565013594, + 0.3416743720008526, + 0.33067003199539613, + 0.32623431300453376 + ], + "stddev": 0.01033974921645219, + "iqr": 0.01100434000545647, + "noise_ratio": 0.03036614192917869, + "noisy": false + }, + { + "driver": "duckdb", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "observability_select", + "time": 0.4111040999996476, "times": [ - 0.022142705973237753, - 0.023443711921572685, - 0.02160847804043442 + 0.40193469599762466, + 0.4111040999996476, + 0.4252187140082242, + 0.4178961379948305, + 0.41012621400295757 ], - "stddev": 0.0009439367351918612, - "iqr": 0.0009176169405691326, - "noise_ratio": 0.0426296919776981, + "stddev": 0.00876406819580416, + "iqr": 0.007769923991872929, + "noise_ratio": 0.021318367284129912, "noisy": false } ] -} +} \ No newline at end of file diff --git a/tools/perf_baselines/oracle.json b/tools/perf_baselines/oracle.json index 1a590b631..45ec58da6 100644 --- a/tools/perf_baselines/oracle.json +++ b/tools/perf_baselines/oracle.json @@ -3,24 +3,84 @@ "rows": 1000, "pool_size": 1, "iterations": 3, + "warmup": 1, "mypyc_compiled": false, - "timestamp": "2026-07-09T20:52:45+0000" + "environment": { + "python_version": "3.11.9", + "platform": "Linux-7.1.6-1rodete1-amd64-x86_64-with-glibc2.43", + "packages": { + "duckdb": "1.5.6", + "sqlglot": "30.22.0", + "aiosqlite": "0.22.1", + "pyarrow": "26.0.0", + "sqlspec": "0.66.0" + } + }, + "timestamp": "2026-10-13T21:45:49+0000" }, "results": [ + { + "driver": "oracle", + "library": "sqlspec_native_json", + "library_key": "sqlspec_native_json", + "scenario": "json_write", + "time": 0.07864046999020502, + "times": [ + 0.08248624700354412, + 0.07840510499954689, + 0.07864046999020502 + ], + "stddev": 0.0022913284928161013, + "iqr": 0.0020405710019986145, + "noise_ratio": 0.029136759903666588, + "noisy": false + }, + { + "driver": "oracle", + "library": "sqlspec_serialized_json", + "library_key": "sqlspec_serialized_json", + "scenario": "json_write", + "time": 0.07814407799742185, + "times": [ + 0.07814407799742185, + 0.07612711799447425, + 0.07842388199060224 + ], + "stddev": 0.0012530990105866801, + "iqr": 0.0011483819980639964, + "noise_ratio": 0.016035751431196395, + "noisy": false + }, + { + "driver": "oracle", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "json_read", + "time": 0.0880015520087909, + "times": [ + 0.08080838101159316, + 0.0880015520087909, + 0.08822829400014598 + ], + "stddev": 0.004219957142026944, + "iqr": 0.003709956494276412, + "noise_ratio": 0.04795321270703717, + "noisy": false + }, { "driver": "oracle", "library": "raw", "library_key": "raw", "scenario": "lob_fetch_1k", - "time": 0.11334440193604678, + "time": 0.13012522499775514, "times": [ - 0.09634258400183171, - 0.11334440193604678, - 0.12572037801146507 + 0.2275023889960721, + 0.1296148249966791, + 0.13012522499775514 ], - "stddev": 0.014749471051306513, - "iqr": 0.014688897004816681, - "noise_ratio": 0.13012968262542624, + "stddev": 0.05636864932781265, + "iqr": 0.04894378199969651, + "noise_ratio": 0.43318771843649145, "noisy": true }, { @@ -28,15 +88,15 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "lob_fetch_1k", - "time": 0.10900005104485899, + "time": 0.13817656799801625, "times": [ - 0.11071688507217914, - 0.09607100102584809, - 0.10900005104485899 + 0.13817656799801625, + 0.13850666300277226, + 0.13262061899877153 ], - "stddev": 0.008006349282184572, - "iqr": 0.007322942023165524, - "noise_ratio": 0.07345271131010349, + "stddev": 0.0033071399025796228, + "iqr": 0.0029430220020003617, + "noise_ratio": 0.023934158667387816, "noisy": false }, { @@ -44,15 +104,15 @@ "library": "sqlspec fetch_lobs=True", "library_key": "sqlspec_fetch_lobs_true", "scenario": "lob_fetch_1k", - "time": 0.16698699200060219, + "time": 0.13564415200380608, "times": [ - 0.13185496896039695, - 0.1696973090292886, - 0.16698699200060219 + 0.13564415200380608, + 0.09577904200705234, + 0.13576182699762285 ], - "stddev": 0.021109427448350895, - "iqr": 0.018921170034445822, - "noise_ratio": 0.1264136038109769, + "stddev": 0.023050176926506932, + "iqr": 0.019991392495285254, + "noise_ratio": 0.1699312250915186, "noisy": true }, { @@ -60,31 +120,31 @@ "library": "sqlspec async", "library_key": "sqlspec_async", "scenario": "lob_fetch_1k", - "time": 0.09288392309099436, + "time": 0.11513849400216714, "times": [ - 0.09288392309099436, - 0.08804141101427376, - 0.10262110095936805 + 0.13331291699432768, + 0.11513849400216714, + 0.09306900200317614 ], - "stddev": 0.007425518107048319, - "iqr": 0.007289844972547144, - "noise_ratio": 0.07994406200709094, - "noisy": false + "stddev": 0.020153348861527264, + "iqr": 0.02012195749557577, + "noise_ratio": 0.1750357170830108, + "noisy": true }, { "driver": "oracle", "library": "sqlspec async fetch_lobs=True", "library_key": "sqlspec_async_fetch_lobs_true", "scenario": "lob_fetch_1k", - "time": 0.12941001495346427, + "time": 0.1106022239982849, "times": [ - 0.11710295605007559, - 0.1819777189521119, - 0.12941001495346427 + 0.10332141499384306, + 0.14190397401398513, + 0.1106022239982849 ], - "stddev": 0.03445664441037059, - "iqr": 0.032437381451018155, - "noise_ratio": 0.2662594886706502, + "stddev": 0.020499689405562942, + "iqr": 0.019291279510071035, + "noise_ratio": 0.18534608676477274, "noisy": true }, { @@ -92,63 +152,63 @@ "library": "raw", "library_key": "raw", "scenario": "lob_fetch_100k", - "time": 0.3411060890648514, + "time": 0.42109400998742785, "times": [ - 0.3130992379738018, - 0.3942504409933463, - 0.3411060890648514 + 0.42109400998742785, + 0.4212912819930352, + 0.3734991649980657 ], - "stddev": 0.04121937974920549, - "iqr": 0.04057560150977224, - "noise_ratio": 0.12084035164018671, - "noisy": true + "stddev": 0.027536020750604916, + "iqr": 0.023896058497484773, + "noise_ratio": 0.06539162300462795, + "noisy": false }, { "driver": "oracle", "library": "sqlspec", "library_key": "sqlspec", "scenario": "lob_fetch_100k", - "time": 0.4587748399935663, + "time": 0.5610299410036532, "times": [ - 0.4587748399935663, - 0.7968855670187622, - 0.4368935209931806 + 0.5615002539998386, + 0.5610299410036532, + 0.497147580012097 ], - "stddev": 0.2018216741963012, - "iqr": 0.17999602301279083, - "noise_ratio": 0.4399144342770225, - "noisy": true + "stddev": 0.03701901288424286, + "iqr": 0.03217633699387079, + "noise_ratio": 0.06598402362985795, + "noisy": false }, { "driver": "oracle", "library": "sqlspec fetch_lobs=True", "library_key": "sqlspec_fetch_lobs_true", "scenario": "lob_fetch_100k", - "time": 0.4545840279897675, + "time": 0.509552031988278, "times": [ - 0.5374515409348533, - 0.4545840279897675, - 0.412251268979162 + 0.509552031988278, + 0.535218512013671, + 0.48775234499771614 ], - "stddev": 0.06368437182037287, - "iqr": 0.06260013597784564, - "noise_ratio": 0.14009372942994464, - "noisy": true + "stddev": 0.023759319438088882, + "iqr": 0.023733083507977426, + "noise_ratio": 0.046627857307093325, + "noisy": false }, { "driver": "oracle", "library": "sqlspec async", "library_key": "sqlspec_async", "scenario": "lob_fetch_100k", - "time": 0.37876010907348245, + "time": 0.49131389398826286, "times": [ - 0.37467916298191994, - 0.37876010907348245, - 0.42228385794442147 + 0.46747367799980566, + 0.49131389398826286, + 0.5094287620013347 ], - "stddev": 0.026385532081223424, - "iqr": 0.023802347481250763, - "noise_ratio": 0.06966291182502649, + "stddev": 0.02104254981017692, + "iqr": 0.020977542000764515, + "noise_ratio": 0.042829136459714305, "noisy": false }, { @@ -156,16 +216,256 @@ "library": "sqlspec async fetch_lobs=True", "library_key": "sqlspec_async_fetch_lobs_true", "scenario": "lob_fetch_100k", - "time": 0.3802608890691772, + "time": 0.5330417540099006, + "times": [ + 0.5330417540099006, + 0.4851491269946564, + 0.6489256560016656 + ], + "stddev": 0.08420761437942338, + "iqr": 0.08188826450350462, + "noise_ratio": 0.15797564402029812, + "noisy": true + }, + { + "driver": "oracle", + "library": "raw", + "library_key": "raw", + "scenario": "scalar_param_select", + "time": 0.06203392099996563, + "times": [ + 0.06339251199096907, + 0.06203392099996563, + 0.06139897400862537 + ], + "stddev": 0.00101842368800144, + "iqr": 0.000996768991171848, + "noise_ratio": 0.01641720645067727, + "noisy": false + }, + { + "driver": "oracle", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "scalar_param_select", + "time": 0.1307851369929267, + "times": [ + 0.14107338000030722, + 0.12638328499451745, + 0.1307851369929267 + ], + "stddev": 0.007539044330173677, + "iqr": 0.007345047502894886, + "noise_ratio": 0.05764450382906594, + "noisy": false + }, + { + "driver": "oracle", + "library": "sqlspec async", + "library_key": "sqlspec_async", + "scenario": "scalar_param_select", + "time": 0.1558769819966983, + "times": [ + 0.1558769819966983, + 0.14717672500410117, + 0.16428799300047103 + ], + "stddev": 0.008556041435601831, + "iqr": 0.00855563399818493, + "noise_ratio": 0.054889704214205665, + "noisy": false + }, + { + "driver": "oracle", + "library": "raw", + "library_key": "raw", + "scenario": "no_param_select", + "time": 0.06161634100135416, + "times": [ + 0.06161634100135416, + 0.06202088299323805, + 0.0611521960090613 + ], + "stddev": 0.00043468415229643367, + "iqr": 0.00043434349208837375, + "noise_ratio": 0.007054689474126361, + "noisy": false + }, + { + "driver": "oracle", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "no_param_select", + "time": 0.09843701800855342, + "times": [ + 0.09812828799476847, + 0.09843701800855342, + 0.1039000619930448 + ], + "stddev": 0.003246884118789321, + "iqr": 0.0028858869991381653, + "noise_ratio": 0.03298438112486495, + "noisy": false + }, + { + "driver": "oracle", + "library": "sqlspec async", + "library_key": "sqlspec_async", + "scenario": "no_param_select", + "time": 0.11800301499897614, + "times": [ + 0.12098435100051574, + 0.11800301499897614, + 0.11768404199392535 + ], + "stddev": 0.0018203547122749167, + "iqr": 0.001650154503295198, + "noise_ratio": 0.015426340693843382, + "noisy": false + }, + { + "driver": "oracle", + "library": "raw", + "library_key": "raw", + "scenario": "complex_query", + "time": 0.17137710101087578, + "times": [ + 0.17137710101087578, + 0.17271279399574269, + 0.1611639339971589 + ], + "stddev": 0.006317555216310423, + "iqr": 0.005774429999291897, + "noise_ratio": 0.036863473469010914, + "noisy": false + }, + { + "driver": "oracle", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "complex_query", + "time": 0.2389109599898802, + "times": [ + 0.2389109599898802, + 0.2558247559936717, + 0.2387292249914026 + ], + "stddev": 0.009818067555253171, + "iqr": 0.008547765501134563, + "noise_ratio": 0.041095090638240475, + "noisy": false + }, + { + "driver": "oracle", + "library": "sqlspec async", + "library_key": "sqlspec_async", + "scenario": "complex_query", + "time": 0.2694799119926756, + "times": [ + 0.2694799119926756, + 0.292193061002763, + 0.2636004509986378 + ], + "stddev": 0.015099627734830876, + "iqr": 0.014296305002062581, + "noise_ratio": 0.05603247983560748, + "noisy": false + }, + { + "driver": "oracle", + "library": "raw", + "library_key": "raw", + "scenario": "session_per_request", + "time": 0.09713813700363971, + "times": [ + 0.09713813700363971, + 0.0955697499884991, + 0.10476380900945514 + ], + "stddev": 0.004918357324865727, + "iqr": 0.0045970295104780234, + "noise_ratio": 0.05063260915413108, + "noisy": false + }, + { + "driver": "oracle", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "session_per_request", + "time": 0.25495219499862287, + "times": [ + 0.25495219499862287, + 0.24848666999605484, + 0.2949687799991807 + ], + "stddev": 0.025178418798011637, + "iqr": 0.023241055001562927, + "noise_ratio": 0.0987574113576376, + "noisy": false + }, + { + "driver": "oracle", + "library": "sqlspec async", + "library_key": "sqlspec_async", + "scenario": "session_per_request", + "time": 0.32401383300020825, + "times": [ + 0.32401383300020825, + 0.3523620129999472, + 0.30351131899806205 + ], + "stddev": 0.024530126695458978, + "iqr": 0.024425347000942566, + "noise_ratio": 0.07570703530871539, + "noisy": false + }, + { + "driver": "oracle", + "library": "raw", + "library_key": "raw", + "scenario": "observability_select", + "time": 0.10233181800867897, + "times": [ + 0.10233181800867897, + 0.09745152399409562, + 0.11289458000101149 + ], + "stddev": 0.007893849814973921, + "iqr": 0.007721528003457934, + "noise_ratio": 0.07713973980511543, + "noisy": false + }, + { + "driver": "oracle", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "observability_select", + "time": 0.20812175900209695, + "times": [ + 0.18384120099653956, + 0.20812175900209695, + 0.21966595099365804 + ], + "stddev": 0.018285816808642073, + "iqr": 0.017912374998559244, + "noise_ratio": 0.08786114866758277, + "noisy": false + }, + { + "driver": "oracle", + "library": "sqlspec async", + "library_key": "sqlspec_async", + "scenario": "observability_select", + "time": 0.2116879549867008, "times": [ - 0.43898606300354004, - 0.3802608890691772, - 0.3690725869964808 + 0.21655518499028403, + 0.21100878600555006, + 0.2116879549867008 ], - "stddev": 0.03755377984588838, - "iqr": 0.03495673800352961, - "noise_ratio": 0.09875793415887318, + "stddev": 0.0030252751971403713, + "iqr": 0.0027731994923669845, + "noise_ratio": 0.014291201392778502, "noisy": false } ] -} +} \ No newline at end of file diff --git a/tools/perf_baselines/postgres.json b/tools/perf_baselines/postgres.json index bf3331f01..79d2afb68 100644 --- a/tools/perf_baselines/postgres.json +++ b/tools/perf_baselines/postgres.json @@ -3,8 +3,20 @@ "rows": 1000, "pool_size": 1, "iterations": 3, + "warmup": 1, "mypyc_compiled": false, - "timestamp": "2026-07-09T20:53:14+0000" + "environment": { + "python_version": "3.11.9", + "platform": "Linux-7.1.6-1rodete1-amd64-x86_64-with-glibc2.43", + "packages": { + "duckdb": "1.5.6", + "sqlglot": "30.22.0", + "aiosqlite": "0.22.1", + "pyarrow": "26.0.0", + "sqlspec": "0.66.0" + } + }, + "timestamp": "2026-10-13T21:43:07+0000" }, "results": [ { @@ -12,47 +24,207 @@ "library": "raw", "library_key": "raw", "scenario": "rows", - "time": 0.04123174399137497, + "time": 0.030046815998503007, "times": [ - 0.04123174399137497, - 0.04227600106969476, - 0.03454155509825796 + 0.03310013300506398, + 0.030046815998503007, + 0.028812962002120912 ], - "stddev": 0.0041966406159427505, - "iqr": 0.0038672229857183993, - "noise_ratio": 0.10178178776092085, - "noisy": true + "stddev": 0.0022069955307090533, + "iqr": 0.0021435855014715344, + "noise_ratio": 0.07345189356566134, + "noisy": false }, { "driver": "psycopg_sync", "library": "sqlspec", "library_key": "sqlspec", "scenario": "rows", - "time": 0.05376478098332882, + "time": 0.03645020199473947, + "times": [ + 0.03645020199473947, + 0.03639265400124714, + 0.0367621240002336 + ], + "stddev": 0.00019879436159740794, + "iqr": 0.00018473499949323013, + "noise_ratio": 0.005453861726914384, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "raw", + "library_key": "raw", + "scenario": "scalar_param_select", + "time": 0.044167046988150105, + "times": [ + 0.04452098500041757, + 0.044167046988150105, + 0.043153007994988 + ], + "stddev": 0.0007100361825950722, + "iqr": 0.000683988502714783, + "noise_ratio": 0.016076152494088475, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "scalar_param_select", + "time": 0.07245179500023369, + "times": [ + 0.07242586999200284, + 0.07245179500023369, + 0.0825482079962967 + ], + "stddev": 0.005836665060436103, + "iqr": 0.005061169002146926, + "noise_ratio": 0.08055928856444863, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "raw", + "library_key": "raw", + "scenario": "no_param_select", + "time": 0.047224439011188224, + "times": [ + 0.04150796199974138, + 0.047224439011188224, + 0.049500175009598024 + ], + "stddev": 0.004117696616470997, + "iqr": 0.003996106504928321, + "noise_ratio": 0.08719418806638336, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "no_param_select", + "time": 0.07078680999984499, + "times": [ + 0.07078680999984499, + 0.07032083200465422, + 0.07429387599404436 + ], + "stddev": 0.002171855041253163, + "iqr": 0.0019865219946950674, + "noise_ratio": 0.030681634633032893, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "raw", + "library_key": "raw", + "scenario": "complex_query", + "time": 0.06520880499738269, "times": [ - 0.044566972996108234, - 0.05376478098332882, - 0.05916502000764012 + 0.06321685999864712, + 0.06520880499738269, + 0.0810561050020624 ], - "stddev": 0.007380890075507703, - "iqr": 0.007299023505765945, - "noise_ratio": 0.13728113349510976, + "stddev": 0.00977533822610234, + "iqr": 0.008919622501707636, + "noise_ratio": 0.1499082558941955, "noisy": true }, + { + "driver": "psycopg_sync", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "complex_query", + "time": 0.09353936200204771, + "times": [ + 0.09323644400865305, + 0.09353936200204771, + 0.09428284800378606 + ], + "stddev": 0.0005384378742457099, + "iqr": 0.0005232019975665025, + "noise_ratio": 0.005756270544521383, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "raw", + "library_key": "raw", + "scenario": "session_per_request", + "time": 0.05504183200537227, + "times": [ + 0.05480232399713714, + 0.06185186799848452, + 0.05504183200537227 + ], + "stddev": 0.0040027079363106595, + "iqr": 0.0035247720006736927, + "noise_ratio": 0.07272119750519898, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "session_per_request", + "time": 0.17403190799814183, + "times": [ + 0.18479846199625172, + 0.15965048600628506, + 0.17403190799814183 + ], + "stddev": 0.012617214958764451, + "iqr": 0.012573987994983327, + "noise_ratio": 0.07249943475250048, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "raw", + "library_key": "raw", + "scenario": "observability_select", + "time": 0.05511280300561339, + "times": [ + 0.05511280300561339, + 0.055044888009433635, + 0.055865646005258895 + ], + "stddev": 0.00045552693234181046, + "iqr": 0.00041037899791263044, + "noise_ratio": 0.008265355915492337, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "observability_select", + "time": 0.09858430500025861, + "times": [ + 0.10176454100292176, + 0.0939475999912247, + 0.09858430500025861 + ], + "stddev": 0.003931019824290434, + "iqr": 0.003908470505848527, + "noise_ratio": 0.03987470241110004, + "noisy": false + }, { "driver": "psycopg_async", "library": "raw", "library_key": "raw", "scenario": "rows", - "time": 0.20733329094946384, + "time": 0.10590479400707409, "times": [ - 0.20733329094946384, - 0.1942386640002951, - 0.2190431049093604 + 0.10598615701019298, + 0.10503190700546838, + 0.10590479400707409 ], - "stddev": 0.01240866153623211, - "iqr": 0.012402220454532653, - "noise_ratio": 0.059848862087741816, + "stddev": 0.0005290155522864491, + "iqr": 0.0004771250023622997, + "noise_ratio": 0.004995199294293633, "noisy": false }, { @@ -60,15 +232,175 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "rows", - "time": 0.214356982964091, + "time": 0.11431658400397282, "times": [ - 0.1982118720188737, - 0.214356982964091, - 0.2291014059446752 + 0.11752552099642344, + 0.11194133700337261, + 0.11431658400397282 ], - "stddev": 0.015450058913679468, - "iqr": 0.015444766962900758, - "noise_ratio": 0.07207630327707895, + "stddev": 0.002802444938609363, + "iqr": 0.0027920919965254143, + "noise_ratio": 0.024514771527042567, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "raw", + "library_key": "raw", + "scenario": "scalar_param_select", + "time": 0.0582181589998072, + "times": [ + 0.0582181589998072, + 0.060979214002145454, + 0.056073824991472065 + ], + "stddev": 0.0024591473593935957, + "iqr": 0.0024526945053366944, + "noise_ratio": 0.042240211673504474, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "scalar_param_select", + "time": 0.08660450000024866, + "times": [ + 0.08050378099142108, + 0.09873811498982832, + 0.08660450000024866 + ], + "stddev": 0.009282010571579596, + "iqr": 0.009117166999203619, + "noise_ratio": 0.10717700086661716, + "noisy": true + }, + { + "driver": "psycopg_async", + "library": "raw", + "library_key": "raw", + "scenario": "no_param_select", + "time": 0.052887986006680876, + "times": [ + 0.05260565900243819, + 0.052887986006680876, + 0.05682529001205694 + ], + "stddev": 0.0023589318636977486, + "iqr": 0.002109815504809376, + "noise_ratio": 0.044602414306337275, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "no_param_select", + "time": 0.08167915900412481, + "times": [ + 0.08225427399156615, + 0.08167915900412481, + 0.07951909299299587 + ], + "stddev": 0.0014420967094335316, + "iqr": 0.0013675904992851429, + "noise_ratio": 0.017655626319079822, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "raw", + "library_key": "raw", + "scenario": "complex_query", + "time": 0.08404070899996441, + "times": [ + 0.08615902000747155, + 0.0772992849961156, + 0.08404070899996441 + ], + "stddev": 0.004626534765092214, + "iqr": 0.004429867505677976, + "noise_ratio": 0.05505111534808891, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "complex_query", + "time": 0.11186880600871518, + "times": [ + 0.10963978800282348, + 0.11186880600871518, + 0.12448533900897019 + ], + "stddev": 0.008005579417852031, + "iqr": 0.007422775503073353, + "noise_ratio": 0.07156221384206383, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "raw", + "library_key": "raw", + "scenario": "session_per_request", + "time": 0.08126786199864, + "times": [ + 0.09097665699664503, + 0.08126786199864, + 0.07808626499900129 + ], + "stddev": 0.00671497661492969, + "iqr": 0.006445195998821873, + "noise_ratio": 0.08262770115746447, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "session_per_request", + "time": 0.23540373700961936, + "times": [ + 0.23760637899977155, + 0.23540373700961936, + 0.22950175899313763 + ], + "stddev": 0.00419066107942701, + "iqr": 0.004052310003316961, + "noise_ratio": 0.01780201594359467, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "raw", + "library_key": "raw", + "scenario": "observability_select", + "time": 0.0786619019927457, + "times": [ + 0.0786619019927457, + 0.07817396400787402, + 0.08226269800798036 + ], + "stddev": 0.0022331428206317987, + "iqr": 0.002044367000053171, + "noise_ratio": 0.02838912820640597, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "observability_select", + "time": 0.12231443398923147, + "times": [ + 0.12231443398923147, + 0.13678004599933047, + 0.11825213000702206 + ], + "stddev": 0.009738582335985667, + "iqr": 0.009263957996154204, + "noise_ratio": 0.07961924049653084, "noisy": false }, { @@ -76,31 +408,31 @@ "library": "raw", "library_key": "raw", "scenario": "initialization", - "time": 0.044801491079851985, + "time": 0.016252656001597643, "times": [ - 0.03780574502889067, - 0.044801491079851985, - 0.05556856491602957 + 0.01644538099935744, + 0.016108208990772255, + 0.016252656001597643 ], - "stddev": 0.008947887200769595, - "iqr": 0.008881409943569452, - "noise_ratio": 0.19972297763084088, - "noisy": true + "stddev": 0.0001691610805668527, + "iqr": 0.0001685860042925924, + "noise_ratio": 0.010408211467111843, + "noisy": false }, { "driver": "asyncpg", "library": "sqlspec", "library_key": "sqlspec", "scenario": "initialization", - "time": 0.03986536001320928, + "time": 0.02152997899975162, "times": [ - 0.045165335992351174, - 0.03986536001320928, - 0.0387102939421311 + 0.022655359003692865, + 0.021521306000067852, + 0.02152997899975162 ], - "stddev": 0.0034421769003095118, - "iqr": 0.003227521025110036, - "noise_ratio": 0.08634505995101902, + "stddev": 0.0006522565432436874, + "iqr": 0.0005670265018125065, + "noise_ratio": 0.03029527075949364, "noisy": false }, { @@ -108,31 +440,31 @@ "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "initialization", - "time": 0.04134847305249423, + "time": 0.020192680996842682, "times": [ - 0.04227136599365622, - 0.03727546101436019, - 0.04134847305249423 + 0.019890349998604506, + 0.020192680996842682, + 0.025548206001985818 ], - "stddev": 0.002658327325148475, - "iqr": 0.0024979524896480143, - "noise_ratio": 0.0642908221005786, - "noisy": false + "stddev": 0.0031828809452079474, + "iqr": 0.002828928001690656, + "noise_ratio": 0.15762547557234333, + "noisy": true }, { "driver": "asyncpg", "library": "raw", "library_key": "raw", "scenario": "write_heavy", - "time": 0.0446074609644711, + "time": 0.024334375993930735, "times": [ - 0.0446074609644711, - 0.04706162097863853, - 0.040620596962980926 + 0.024462239001877606, + 0.024334375993930735, + 0.02381368400529027 ], - "stddev": 0.0032507634055145293, - "iqr": 0.003220512007828802, - "noise_ratio": 0.07287488091069998, + "stddev": 0.00034353376336588887, + "iqr": 0.00032427749829366803, + "noise_ratio": 0.014117220982020251, "noisy": false }, { @@ -140,31 +472,31 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "write_heavy", - "time": 0.05116820405237377, + "time": 0.030328352993819863, "times": [ - 0.05116820405237377, - 0.048695035045966506, - 0.05957270704675466 + 0.030328352993819863, + 0.02966713800560683, + 0.030997699999716133 ], - "stddev": 0.005701987718821413, - "iqr": 0.005438836000394076, - "noise_ratio": 0.11143615111026922, - "noisy": true + "stddev": 0.0006652851387573084, + "iqr": 0.0006652809970546514, + "noise_ratio": 0.021936078721217615, + "noisy": false }, { "driver": "asyncpg", "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "write_heavy", - "time": 0.04844018898438662, + "time": 0.02888492999773007, "times": [ - 0.04844018898438662, - 0.04814719106070697, - 0.05589364701882005 + 0.02888492999773007, + 0.02834993999567814, + 0.030016777993296273 ], - "stddev": 0.0043902821542836, - "iqr": 0.003873227979056537, - "noise_ratio": 0.09063305173517569, + "stddev": 0.0008510428012803251, + "iqr": 0.0008334189988090657, + "noise_ratio": 0.029463211485961868, "noisy": false }, { @@ -172,15 +504,15 @@ "library": "raw", "library_key": "raw", "scenario": "read_heavy", - "time": 0.03530651098117232, + "time": 0.0118706149951322, "times": [ - 0.036079684970900416, - 0.03530651098117232, - 0.0308308299863711 + 0.0118706149951322, + 0.012281999006518163, + 0.011099155992269516 ], - "stddev": 0.0028337253758446428, - "iqr": 0.002624427492264658, - "noise_ratio": 0.0802607025473507, + "stddev": 0.0006004863853964159, + "iqr": 0.0005914215071243234, + "noise_ratio": 0.050585954109593996, "noisy": false }, { @@ -188,15 +520,15 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "read_heavy", - "time": 0.03375874599441886, + "time": 0.017022311993059702, "times": [ - 0.03375874599441886, - 0.03446531796362251, - 0.03358696599025279 + 0.018128079012967646, + 0.015996726011508144, + 0.017022311993059702 ], - "stddev": 0.0004655202298894747, - "iqr": 0.0004391759866848588, - "noise_ratio": 0.013789618547040713, + "stddev": 0.0010659278371995603, + "iqr": 0.0010656765007297508, + "noise_ratio": 0.062619451319783, "noisy": false }, { @@ -204,15 +536,15 @@ "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "read_heavy", - "time": 0.03504969098139554, + "time": 0.015185387004748918, "times": [ - 0.03504969098139554, - 0.03519547206815332, - 0.03410959802567959 + 0.015380247990833595, + 0.015185387004748918, + 0.014229471009457484 ], - "stddev": 0.0005893710385884947, - "iqr": 0.0005429370212368667, - "noise_ratio": 0.01681529913919453, + "stddev": 0.0006159050055023395, + "iqr": 0.0005753884906880558, + "noise_ratio": 0.040559058870855765, "noisy": false }, { @@ -220,31 +552,31 @@ "library": "raw", "library_key": "raw", "scenario": "iterative_inserts", - "time": 0.3479909528978169, + "time": 0.6901849880086957, "times": [ - 0.3479909528978169, - 0.337999137933366, - 0.40759251394774765 + 0.6400381919956999, + 0.7464155230118195, + 0.6901849880086957 ], - "stddev": 0.03762849193308965, - "iqr": 0.034796688007190824, - "noise_ratio": 0.1081306615006706, - "noisy": true + "stddev": 0.0532176517883863, + "iqr": 0.053188665508059785, + "noise_ratio": 0.0771063594731733, + "noisy": false }, { "driver": "asyncpg", "library": "sqlspec", "library_key": "sqlspec", "scenario": "iterative_inserts", - "time": 0.4137253829976544, + "time": 0.7482371899968712, "times": [ - 0.4137253829976544, - 0.41692268394399434, - 0.41216089599765837 + 0.7289663100091275, + 0.7482371899968712, + 0.7537915709981462 ], - "stddev": 0.002427103121023548, - "iqr": 0.0023808939731679857, - "noise_ratio": 0.005866459300703114, + "stddev": 0.013028888076004027, + "iqr": 0.012412630494509358, + "noise_ratio": 0.017412777993644647, "noisy": false }, { @@ -252,15 +584,15 @@ "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "iterative_inserts", - "time": 0.343022566055879, + "time": 0.24287494999589399, "times": [ - 0.3223919089650735, - 0.35162515798583627, - 0.343022566055879 + 0.24287494999589399, + 0.24870659499720205, + 0.21582980499078985 ], - "stddev": 0.01502337871575329, - "iqr": 0.01461662451038137, - "noise_ratio": 0.043797056527487915, + "stddev": 0.017542003272593636, + "iqr": 0.016438395003206097, + "noise_ratio": 0.07222648228189114, "noisy": false }, { @@ -268,15 +600,15 @@ "library": "raw", "library_key": "raw", "scenario": "repeated_queries", - "time": 0.21294607897289097, + "time": 0.10828063200460747, "times": [ - 0.21294607897289097, - 0.23929880699142814, - 0.21020107390359044 + 0.10828063200460747, + 0.10793121599999722, + 0.12519359499856364 ], - "stddev": 0.016065902805323058, - "iqr": 0.014548866543918848, - "noise_ratio": 0.07544587288394412, + "stddev": 0.009867118267840883, + "iqr": 0.00863118949928321, + "noise_ratio": 0.09112542183371283, "noisy": false }, { @@ -284,15 +616,15 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "repeated_queries", - "time": 0.4103268129983917, + "time": 0.22818390501197428, "times": [ - 0.3971549429697916, - 0.4103268129983917, - 0.4249703430105001 + 0.22818390501197428, + 0.2235217840061523, + 0.24140914900635835 ], - "stddev": 0.013914187068663035, - "iqr": 0.013907700020354241, - "noise_ratio": 0.033910011795201825, + "stddev": 0.009279011061121714, + "iqr": 0.008943682500103023, + "noise_ratio": 0.04066461681701338, "noisy": false }, { @@ -300,15 +632,15 @@ "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "repeated_queries", - "time": 0.5083378970157355, + "time": 0.36793276100070216, "times": [ - 0.5083378970157355, - 0.5062282889848575, - 0.5189653070410714 + 0.36793276100070216, + 0.3864833539992105, + 0.34751537900592666 ], - "stddev": 0.006826711248896642, - "iqr": 0.006368509028106928, - "noise_ratio": 0.013429475333186348, + "stddev": 0.019491438561558622, + "iqr": 0.01948398749664193, + "noise_ratio": 0.05297554506575027, "noisy": false }, { @@ -316,32 +648,384 @@ "library": "raw", "library_key": "raw", "scenario": "rows", - "time": 0.04551889596041292, + "time": 0.029271915002027526, + "times": [ + 0.029271915002027526, + 0.027956382997217588, + 0.03000156099733431 + ], + "stddev": 0.0010364812899346769, + "iqr": 0.0010225890000583604, + "noise_ratio": 0.03540872846422534, + "noisy": false + }, + { + "driver": "asyncpg", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "rows", + "time": 0.03579180400993209, + "times": [ + 0.03753435499675106, + 0.03579180400993209, + 0.03416556199954357 + ], + "stddev": 0.0016847311000102515, + "iqr": 0.0016843964986037463, + "noise_ratio": 0.04707030412724502, + "noisy": false + }, + { + "driver": "asyncpg", + "library": "raw", + "library_key": "raw", + "scenario": "scalar_param_select", + "time": 0.04725500299537089, + "times": [ + 0.05113000300480053, + 0.0470034790050704, + 0.04725500299537089 + ], + "stddev": 0.0023132621162593853, + "iqr": 0.0020632619998650625, + "noise_ratio": 0.04895274509846065, + "noisy": false + }, + { + "driver": "asyncpg", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "scalar_param_select", + "time": 0.08483455299574416, + "times": [ + 0.08483455299574416, + 0.07786131300963461, + 0.09271926200017333 + ], + "stddev": 0.007433632582098898, + "iqr": 0.007428974495269358, + "noise_ratio": 0.08762505747477464, + "noisy": false + }, + { + "driver": "asyncpg", + "library": "raw", + "library_key": "raw", + "scenario": "no_param_select", + "time": 0.04900928599818144, + "times": [ + 0.052870790008455515, + 0.04900928599818144, + 0.047410389001015574 + ], + "stddev": 0.002807242537644367, + "iqr": 0.0027302005037199706, + "noise_ratio": 0.05727980892740477, + "noisy": false + }, + { + "driver": "asyncpg", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "no_param_select", + "time": 0.06851341199944727, + "times": [ + 0.06921851700462867, + 0.068030488997465, + 0.06851341199944727 + ], + "stddev": 0.0005974666294698942, + "iqr": 0.0005940140035818331, + "noise_ratio": 0.008720433153653393, + "noisy": false + }, + { + "driver": "asyncpg", + "library": "raw", + "library_key": "raw", + "scenario": "complex_query", + "time": 0.08479894499760121, + "times": [ + 0.0980167999950936, + 0.08479894499760121, + 0.07594491800409742 + ], + "stddev": 0.011107605947884618, + "iqr": 0.011035940995498095, + "noise_ratio": 0.13098754882149571, + "noisy": true + }, + { + "driver": "asyncpg", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "complex_query", + "time": 0.11133583699120209, + "times": [ + 0.10561610999866389, + 0.11678107899206225, + 0.11133583699120209 + ], + "stddev": 0.005583046807682589, + "iqr": 0.0055824844966991805, + "noise_ratio": 0.05014599933464163, + "noisy": false + }, + { + "driver": "asyncpg", + "library": "raw", + "library_key": "raw", + "scenario": "session_per_request", + "time": 0.0681627669982845, "times": [ - 0.04551889596041292, - 0.04794929292984307, - 0.044716733042150736 + 0.082068016010453, + 0.06561306599178351, + 0.0681627669982845 ], - "stddev": 0.0016832378621755362, - "iqr": 0.0016162799438461661, - "noise_ratio": 0.03697888155370513, + "stddev": 0.0088564694379295, + "iqr": 0.00822747500933474, + "noise_ratio": 0.12993119011956186, + "noisy": true + }, + { + "driver": "asyncpg", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "session_per_request", + "time": 0.19334729299589526, + "times": [ + 0.19144087300810497, + 0.19334729299589526, + 0.20283524499973282 + ], + "stddev": 0.006103105541178261, + "iqr": 0.005697185995813925, + "noise_ratio": 0.03156550808967276, + "noisy": false + }, + { + "driver": "asyncpg", + "library": "raw", + "library_key": "raw", + "scenario": "observability_select", + "time": 0.06520503900537733, + "times": [ + 0.06520503900537733, + 0.06378934299573302, + 0.06650181100121699 + ], + "stddev": 0.0013566684366301508, + "iqr": 0.001356234002741985, + "noise_ratio": 0.020806190093962968, "noisy": false }, { "driver": "asyncpg", "library": "sqlspec", "library_key": "sqlspec", + "scenario": "observability_select", + "time": 0.1334093390032649, + "times": [ + 0.11765947099775076, + 0.1334093390032649, + 0.144385246996535 + ], + "stddev": 0.01343376344145005, + "iqr": 0.013362887999392115, + "noise_ratio": 0.10069582490863917, + "noisy": true + }, + { + "driver": "psqlpy", + "library": "raw", + "library_key": "raw", + "scenario": "rows", + "time": 0.03865024499827996, + "times": [ + 0.03877540898974985, + 0.03820392600027844, + 0.03865024499827996 + ], + "stddev": 0.00030040513754842437, + "iqr": 0.00028574149473570287, + "noise_ratio": 0.007772399309804976, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "sqlspec", + "library_key": "sqlspec", "scenario": "rows", - "time": 0.05420948297251016, + "time": 0.043907539002248086, + "times": [ + 0.04820335599652026, + 0.043810168994241394, + 0.043907539002248086 + ], + "stddev": 0.002508771831120053, + "iqr": 0.002196593501139432, + "noise_ratio": 0.05713760980754587, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "raw", + "library_key": "raw", + "scenario": "scalar_param_select", + "time": 0.1293873389950022, + "times": [ + 0.120815243004472, + 0.1293873389950022, + 0.13075926499732304 + ], + "stddev": 0.00538897924813555, + "iqr": 0.004972010996425524, + "noise_ratio": 0.04164997355996097, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "scalar_param_select", + "time": 0.18837299499136861, + "times": [ + 0.19009797599574085, + 0.18837299499136861, + 0.18020312799490057 + ], + "stddev": 0.005285677237673456, + "iqr": 0.004947424000420142, + "noise_ratio": 0.02805963369598519, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "raw", + "library_key": "raw", + "scenario": "no_param_select", + "time": 0.17247518899966963, + "times": [ + 0.1658194710034877, + 0.17247518899966963, + 0.18021090600814205 + ], + "stddev": 0.007202468340472587, + "iqr": 0.00719571750232717, + "noise_ratio": 0.041759445994788175, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "no_param_select", + "time": 0.18234803499944974, + "times": [ + 0.18234803499944974, + 0.18064538700855337, + 0.22009064500161912 + ], + "stddev": 0.022298475277083698, + "iqr": 0.019722628996532876, + "noise_ratio": 0.12228525126224139, + "noisy": true + }, + { + "driver": "psqlpy", + "library": "raw", + "library_key": "raw", + "scenario": "complex_query", + "time": 0.18290463100129273, + "times": [ + 0.18290463100129273, + 0.1759164170071017, + 0.1966316300095059 + ], + "stddev": 0.010538703955223563, + "iqr": 0.010357606501202099, + "noise_ratio": 0.05761857366612592, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "complex_query", + "time": 0.23078126600012183, + "times": [ + 0.22463746600260492, + 0.23078126600012183, + 0.2807858739979565 + ], + "stddev": 0.030797323530460302, + "iqr": 0.028074203997675795, + "noise_ratio": 0.13344810895718046, + "noisy": true + }, + { + "driver": "psqlpy", + "library": "raw", + "library_key": "raw", + "scenario": "session_per_request", + "time": 0.17003524499887135, + "times": [ + 0.1603259289986454, + 0.17003524499887135, + 0.18832512600056361 + ], + "stddev": 0.014217041399910025, + "iqr": 0.01399959850095911, + "noise_ratio": 0.08361232049275663, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "session_per_request", + "time": 0.3086909220000962, + "times": [ + 0.34571038599824533, + 0.3086909220000962, + 0.2691083060053643 + ], + "stddev": 0.03830818638126933, + "iqr": 0.03830103999644052, + "noise_ratio": 0.1240988433772516, + "noisy": true + }, + { + "driver": "psqlpy", + "library": "raw", + "library_key": "raw", + "scenario": "observability_select", + "time": 0.1629070619965205, + "times": [ + 0.1510089889925439, + 0.17306879900570493, + 0.1629070619965205 + ], + "stddev": 0.011041288104719679, + "iqr": 0.01102990500658052, + "noise_ratio": 0.0677766081433321, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "observability_select", + "time": 0.227817266000784, "times": [ - 0.05420948297251016, - 0.05423876002896577, - 0.05306137597654015 + 0.227817266000784, + 0.228459699996165, + 0.22098791699681897 ], - "stddev": 0.0006714710253287391, - "iqr": 0.0005886920262128115, - "noise_ratio": 0.012386597113815763, + "stddev": 0.004140858816833689, + "iqr": 0.0037358914996730164, + "noise_ratio": 0.01817622908712915, "noisy": false } ] -} +} \ No newline at end of file diff --git a/tools/perf_baselines/psqlpy.json b/tools/perf_baselines/psqlpy.json new file mode 100644 index 000000000..17727b0bc --- /dev/null +++ b/tools/perf_baselines/psqlpy.json @@ -0,0 +1,215 @@ +{ + "metadata": { + "rows": 1000, + "pool_size": 1, + "iterations": 3, + "warmup": 1, + "mypyc_compiled": false, + "environment": { + "python_version": "3.11.9", + "platform": "Linux-7.1.6-1rodete1-amd64-x86_64-with-glibc2.43", + "packages": { + "duckdb": "1.5.6", + "sqlglot": "30.22.0", + "aiosqlite": "0.22.1", + "pyarrow": "26.0.0", + "sqlspec": "0.66.0" + } + }, + "timestamp": "2026-10-13T21:42:28+0000" + }, + "results": [ + { + "driver": "psqlpy", + "library": "raw", + "library_key": "raw", + "scenario": "rows", + "time": 0.038870181000675075, + "times": [ + 0.03749169000366237, + 0.03915389100438915, + 0.038870181000675075 + ], + "stddev": 0.0008891607430483382, + "iqr": 0.0008311005003633909, + "noise_ratio": 0.02287513770601932, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "rows", + "time": 0.04553226499410812, + "times": [ + 0.04509593699185643, + 0.04553226499410812, + 0.057247064003604464 + ], + "stddev": 0.006892952749174069, + "iqr": 0.006075563505874015, + "noise_ratio": 0.15138611597876842, + "noisy": true + }, + { + "driver": "psqlpy", + "library": "raw", + "library_key": "raw", + "scenario": "scalar_param_select", + "time": 0.13926593400537968, + "times": [ + 0.1660893600055715, + 0.13099403699743561, + 0.13926593400537968 + ], + "stddev": 0.01834667163067638, + "iqr": 0.017547661504067946, + "noise_ratio": 0.13173840222812616, + "noisy": true + }, + { + "driver": "psqlpy", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "scalar_param_select", + "time": 0.1755439479893539, + "times": [ + 0.1893631630082382, + 0.17003162500623148, + 0.1755439479893539 + ], + "stddev": 0.009958788161982412, + "iqr": 0.009665769001003355, + "noise_ratio": 0.0567310253417929, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "raw", + "library_key": "raw", + "scenario": "no_param_select", + "time": 0.16983815999992657, + "times": [ + 0.1830866660020547, + 0.16983815999992657, + 0.15680248399439733 + ], + "stddev": 0.013142234614790685, + "iqr": 0.013142091003828682, + "noise_ratio": 0.07738092908446703, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "no_param_select", + "time": 0.16322541900444776, + "times": [ + 0.14927623599942308, + 0.16717540299578104, + 0.16322541900444776 + ], + "stddev": 0.009403565451492044, + "iqr": 0.008949583498178981, + "noise_ratio": 0.05761091323181596, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "raw", + "library_key": "raw", + "scenario": "complex_query", + "time": 0.15418924599362072, + "times": [ + 0.1493776040006196, + 0.16773134699906223, + 0.15418924599362072 + ], + "stddev": 0.009516654527399783, + "iqr": 0.009176871499221306, + "noise_ratio": 0.061720611356991244, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "complex_query", + "time": 0.21580600099696312, + "times": [ + 0.22752288200717885, + 0.21562466000614222, + 0.21580600099696312 + ], + "stddev": 0.006817695993374619, + "iqr": 0.0059491110005183145, + "noise_ratio": 0.031591781330819246, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "raw", + "library_key": "raw", + "scenario": "session_per_request", + "time": 0.14990542401210405, + "times": [ + 0.15869152099185158, + 0.14990542401210405, + 0.1469935929926578 + ], + "stddev": 0.006089824566406652, + "iqr": 0.00584896399959689, + "noise_ratio": 0.04062444442247088, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "session_per_request", + "time": 0.2788829950004583, + "times": [ + 0.29392264400667045, + 0.2788829950004583, + 0.26725216799241025 + ], + "stddev": 0.013371496238569232, + "iqr": 0.013335238007130101, + "noise_ratio": 0.047946617320813185, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "raw", + "library_key": "raw", + "scenario": "observability_select", + "time": 0.15503014900605194, + "times": [ + 0.15503014900605194, + 0.15803829400101677, + 0.14209970699448604 + ], + "stddev": 0.008468410148801548, + "iqr": 0.007969293503265362, + "noise_ratio": 0.05462427923275082, + "noisy": false + }, + { + "driver": "psqlpy", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "observability_select", + "time": 0.21762880399182905, + "times": [ + 0.21762880399182905, + 0.23060299998905975, + 0.21332993000396527 + ], + "stddev": 0.00899230250499523, + "iqr": 0.00863653499254724, + "noise_ratio": 0.041319450091417353, + "noisy": false + } + ] +} \ No newline at end of file diff --git a/tools/perf_baselines/psycopg_async.json b/tools/perf_baselines/psycopg_async.json index 3a4a19ad4..038669615 100644 --- a/tools/perf_baselines/psycopg_async.json +++ b/tools/perf_baselines/psycopg_async.json @@ -3,8 +3,20 @@ "rows": 1000, "pool_size": 1, "iterations": 3, + "warmup": 1, "mypyc_compiled": false, - "timestamp": "2026-07-09T20:52:38+0000" + "environment": { + "python_version": "3.11.9", + "platform": "Linux-7.1.6-1rodete1-amd64-x86_64-with-glibc2.43", + "packages": { + "duckdb": "1.5.6", + "sqlglot": "30.22.0", + "aiosqlite": "0.22.1", + "pyarrow": "26.0.0", + "sqlspec": "0.66.0" + } + }, + "timestamp": "2026-10-13T21:40:20+0000" }, "results": [ { @@ -12,15 +24,15 @@ "library": "raw", "library_key": "raw", "scenario": "rows", - "time": 0.2959300350630656, + "time": 0.10732070899393875, "times": [ - 0.28633909800555557, - 0.29886266810353845, - 0.2959300350630656 + 0.10732070899393875, + 0.10796528100036085, + 0.10532009199960157 ], - "stddev": 0.006550142723512253, - "iqr": 0.006261785048991442, - "noise_ratio": 0.02213409234421357, + "stddev": 0.0013793093851170338, + "iqr": 0.0013225945003796369, + "noise_ratio": 0.01285222021031313, "noisy": false }, { @@ -28,16 +40,176 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "rows", - "time": 0.34546393796335906, + "time": 0.114254736006842, + "times": [ + 0.11448235799616668, + 0.114254736006842, + 0.11244962899945676 + ], + "stddev": 0.0011137182657058229, + "iqr": 0.0010163644983549602, + "noise_ratio": 0.009747677029678046, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "raw", + "library_key": "raw", + "scenario": "scalar_param_select", + "time": 0.06272070900013205, + "times": [ + 0.054481760002090596, + 0.0634723289986141, + 0.06272070900013205 + ], + "stddev": 0.004987911120828305, + "iqr": 0.0044952844982617535, + "noise_ratio": 0.07952574517003314, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "scalar_param_select", + "time": 0.08722766900609713, + "times": [ + 0.08722766900609713, + 0.11313651800446678, + 0.08694001298863441 + ], + "stddev": 0.015042207713903086, + "iqr": 0.013098252507916186, + "noise_ratio": 0.17244766351433338, + "noisy": true + }, + { + "driver": "psycopg_async", + "library": "raw", + "library_key": "raw", + "scenario": "no_param_select", + "time": 0.05285361300047953, + "times": [ + 0.05285361300047953, + 0.051201517999288626, + 0.05827487500209827 + ], + "stddev": 0.0037002675324979647, + "iqr": 0.003536678501404822, + "noise_ratio": 0.07000973675090846, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "no_param_select", + "time": 0.08752505099982955, + "times": [ + 0.08850791800068691, + 0.08752505099982955, + 0.07754960999591276 + ], + "stddev": 0.0060630020766874225, + "iqr": 0.005479154002387077, + "noise_ratio": 0.06927162003823603, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "raw", + "library_key": "raw", + "scenario": "complex_query", + "time": 0.07898365501023363, "times": [ - 0.30046050203964114, - 0.3557198899798095, - 0.34546393796335906 + 0.07922200000029989, + 0.07639972800097894, + 0.07898365501023363 ], - "stddev": 0.029394142705154558, - "iqr": 0.02762969397008419, - "noise_ratio": 0.08508599444111063, + "stddev": 0.0015651787097962458, + "iqr": 0.0014111359996604733, + "noise_ratio": 0.019816488735466233, "noisy": false + }, + { + "driver": "psycopg_async", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "complex_query", + "time": 0.12235170499479864, + "times": [ + 0.10542201099451631, + 0.12449819299217779, + 0.12235170499479864 + ], + "stddev": 0.010449263684378212, + "iqr": 0.00953809099883074, + "noise_ratio": 0.08540349874832089, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "raw", + "library_key": "raw", + "scenario": "session_per_request", + "time": 0.08765066500927787, + "times": [ + 0.09017416898859665, + 0.08765066500927787, + 0.08010931899480056 + ], + "stddev": 0.005236747995994709, + "iqr": 0.005032424996898044, + "noise_ratio": 0.05974567329797665, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "session_per_request", + "time": 0.23639956300030462, + "times": [ + 0.25069692500983365, + 0.23639956300030462, + 0.2202673319989117 + ], + "stddev": 0.015224013751987231, + "iqr": 0.015214796505460981, + "noise_ratio": 0.06439950039995469, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "raw", + "library_key": "raw", + "scenario": "observability_select", + "time": 0.0797479739994742, + "times": [ + 0.07919690600829199, + 0.0797479739994742, + 0.08318662300007418 + ], + "stddev": 0.0021620138973785664, + "iqr": 0.001994858495891094, + "noise_ratio": 0.027110580858051906, + "noisy": false + }, + { + "driver": "psycopg_async", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "observability_select", + "time": 0.12852915600524284, + "times": [ + 0.12852915600524284, + 0.12195321000763215, + 0.15071062499191612 + ], + "stddev": 0.015067899371116515, + "iqr": 0.014378707492141984, + "noise_ratio": 0.11723331763340825, + "noisy": true } ] -} +} \ No newline at end of file diff --git a/tools/perf_baselines/psycopg_sync.json b/tools/perf_baselines/psycopg_sync.json index e4c89de62..1f80c8bab 100644 --- a/tools/perf_baselines/psycopg_sync.json +++ b/tools/perf_baselines/psycopg_sync.json @@ -3,8 +3,20 @@ "rows": 1000, "pool_size": 1, "iterations": 3, + "warmup": 1, "mypyc_compiled": false, - "timestamp": "2026-07-09T20:52:32+0000" + "environment": { + "python_version": "3.11.9", + "platform": "Linux-7.1.6-1rodete1-amd64-x86_64-with-glibc2.43", + "packages": { + "duckdb": "1.5.6", + "sqlglot": "30.22.0", + "aiosqlite": "0.22.1", + "pyarrow": "26.0.0", + "sqlspec": "0.66.0" + } + }, + "timestamp": "2026-10-13T21:40:14+0000" }, "results": [ { @@ -12,32 +24,192 @@ "library": "raw", "library_key": "raw", "scenario": "rows", - "time": 0.050552292028442025, + "time": 0.030215855993446894, "times": [ - 0.050552292028442025, - 0.04806464398279786, - 0.05370596796274185 + 0.030215855993446894, + 0.041531951996148564, + 0.028833802993176505 ], - "stddev": 0.0028272071302574155, - "iqr": 0.0028206619899719954, - "noise_ratio": 0.055926388632720266, - "noisy": false + "stddev": 0.006966671698637705, + "iqr": 0.0063490745014860295, + "noise_ratio": 0.23056343994188389, + "noisy": true }, { "driver": "psycopg_sync", "library": "sqlspec", "library_key": "sqlspec", "scenario": "rows", - "time": 0.06116347201168537, + "time": 0.045102126998244785, + "times": [ + 0.045102126998244785, + 0.04464112599089276, + 0.045206759008578956 + ], + "stddev": 0.0003009458691810825, + "iqr": 0.00028281650884309784, + "noise_ratio": 0.0066725427204972895, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "raw", + "library_key": "raw", + "scenario": "scalar_param_select", + "time": 0.047244686997146346, + "times": [ + 0.047244686997146346, + 0.05311737199372146, + 0.042004770002677105 + ], + "stddev": 0.005559302746887255, + "iqr": 0.005556300995522179, + "noise_ratio": 0.1176704323858267, + "noisy": true + }, + { + "driver": "psycopg_sync", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "scalar_param_select", + "time": 0.06822669900429901, + "times": [ + 0.06822669900429901, + 0.06704405600612517, + 0.06837901299877558 + ], + "stddev": 0.0007307478117505397, + "iqr": 0.0006674784963252023, + "noise_ratio": 0.010710584308123933, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "raw", + "library_key": "raw", + "scenario": "no_param_select", + "time": 0.04043869000452105, + "times": [ + 0.03942789099528454, + 0.04106943399528973, + 0.04043869000452105 + ], + "stddev": 0.0008280716595229272, + "iqr": 0.0008207715000025928, + "noise_ratio": 0.020477212773963464, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "no_param_select", + "time": 0.06454010300512891, + "times": [ + 0.08719137999287341, + 0.06454010300512891, + 0.0621460179972928 + ], + "stddev": 0.013820770297454132, + "iqr": 0.012522680997790303, + "noise_ratio": 0.21414236504016448, + "noisy": true + }, + { + "driver": "psycopg_sync", + "library": "raw", + "library_key": "raw", + "scenario": "complex_query", + "time": 0.06197537999833003, + "times": [ + 0.06197537999833003, + 0.06380819100013468, + 0.06135116799850948 + ], + "stddev": 0.001277092976227095, + "iqr": 0.0012285115008126013, + "noise_ratio": 0.020606456568100222, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "complex_query", + "time": 0.1180369280045852, + "times": [ + 0.09941287100082263, + 0.1180369280045852, + 0.12877066800137982 + ], + "stddev": 0.014854566740703628, + "iqr": 0.014678898500278592, + "noise_ratio": 0.1258467751729916, + "noisy": true + }, + { + "driver": "psycopg_sync", + "library": "raw", + "library_key": "raw", + "scenario": "session_per_request", + "time": 0.057693398994160816, + "times": [ + 0.06089284199697431, + 0.057693398994160816, + 0.05657934599730652 + ], + "stddev": 0.002239188742498882, + "iqr": 0.0021567479998338968, + "noise_ratio": 0.03881187070856254, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "session_per_request", + "time": 0.18861053900036495, + "times": [ + 0.18909936299314722, + 0.1824087370041525, + 0.18861053900036495 + ], + "stddev": 0.003729740228236546, + "iqr": 0.0033453129944973625, + "noise_ratio": 0.019774824079312605, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "raw", + "library_key": "raw", + "scenario": "observability_select", + "time": 0.05645457700302359, + "times": [ + 0.05598392299725674, + 0.05870470700028818, + 0.05645457700302359 + ], + "stddev": 0.001454147275260552, + "iqr": 0.00136039200151572, + "noise_ratio": 0.025757827840649148, + "noisy": false + }, + { + "driver": "psycopg_sync", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "observability_select", + "time": 0.11064468500262592, "times": [ - 0.06116347201168537, - 0.057821188936941326, - 0.07057346904184669 + 0.11064468500262592, + 0.09732882400567178, + 0.12210955600312445 ], - "stddev": 0.006612356465150814, - "iqr": 0.0063761400524526834, - "noise_ratio": 0.10810956683243086, + "stddev": 0.012401882246107186, + "iqr": 0.012390365998726338, + "noise_ratio": 0.11208746489551535, "noisy": true } ] -} +} \ No newline at end of file diff --git a/tools/perf_baselines/sqlite.json b/tools/perf_baselines/sqlite.json index a0527490d..0210af924 100644 --- a/tools/perf_baselines/sqlite.json +++ b/tools/perf_baselines/sqlite.json @@ -2,9 +2,21 @@ "metadata": { "rows": 1000, "pool_size": 1, - "iterations": 3, + "iterations": 5, + "warmup": 2, "mypyc_compiled": false, - "timestamp": "2026-07-09T20:51:14+0000" + "environment": { + "python_version": "3.11.9", + "platform": "Linux-7.1.6-1rodete1-amd64-x86_64-with-glibc2.43", + "packages": { + "duckdb": "1.5.6", + "sqlglot": "30.22.0", + "aiosqlite": "0.22.1", + "pyarrow": "26.0.0", + "sqlspec": "0.66.0" + } + }, + "timestamp": "2026-10-13T21:37:48+0000" }, "results": [ { @@ -12,15 +24,17 @@ "library": "raw", "library_key": "raw", "scenario": "initialization", - "time": 0.0030459959525614977, + "time": 0.002675202995305881, "times": [ - 0.002834903891198337, - 0.0030459959525614977, - 0.003190841991454363 + 0.0027365599962649867, + 0.002725298996665515, + 0.002611039992189035, + 0.002675202995305881, + 0.002634947988553904 ], - "stddev": 0.00017899355880746586, - "iqr": 0.0001779690501280129, - "noise_ratio": 0.058763557665578364, + "stddev": 5.477624766433942e-05, + "iqr": 9.035100811161101e-05, + "noise_ratio": 0.020475548121190835, "noisy": false }, { @@ -28,31 +42,35 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "initialization", - "time": 0.0009073169203475118, + "time": 0.00282091400003992, "times": [ - 0.0009711229940876365, - 0.0008872960461303592, - 0.0009073169203475118 + 0.0026993060018867254, + 0.0034525980008766055, + 0.0028972849977435544, + 0.0028015130083076656, + 0.00282091400003992 ], - "stddev": 4.377786285019206e-05, - "iqr": 4.191347397863865e-05, - "noise_ratio": 0.048249803203741295, - "noisy": false + "stddev": 0.0002982087669368678, + "iqr": 9.577198943588883e-05, + "noise_ratio": 0.10571352651397657, + "noisy": true }, { "driver": "sqlite", "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "initialization", - "time": 0.0018678000196814537, + "time": 0.0021299579966580495, "times": [ - 0.0018678000196814537, - 0.0016863120254129171, - 0.00200138206128031 + 0.002090784997562878, + 0.002119087992468849, + 0.002180947005399503, + 0.0021560580062214285, + 0.0021299579966580495 ], - "stddev": 0.00015814085567557345, - "iqr": 0.0001575350179336965, - "noise_ratio": 0.08466690973830474, + "stddev": 3.459578361278528e-05, + "iqr": 3.697001375257969e-05, + "noise_ratio": 0.016242472230469717, "noisy": false }, { @@ -60,15 +78,17 @@ "library": "raw", "library_key": "raw", "scenario": "write_heavy", - "time": 0.004611451993696392, + "time": 0.003833413007669151, "times": [ - 0.004869634984061122, - 0.004461181000806391, - 0.004611451993696392 + 0.0040214440086856484, + 0.003827246997389011, + 0.0036687200044980273, + 0.003833413007669151, + 0.0038556829968001693 ], - "stddev": 0.00020658915922448464, - "iqr": 0.00020422699162736535, - "noise_ratio": 0.044799156427711054, + "stddev": 0.0001252021174245056, + "iqr": 2.8435999411158264e-05, + "noise_ratio": 0.032660743096041424, "noisy": false }, { @@ -76,47 +96,53 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "write_heavy", - "time": 0.003128518001176417, + "time": 0.004287199990358204, "times": [ - 0.0025292159989476204, - 0.003128518001176417, - 0.0033896210370585322 + 0.004193825006950647, + 0.004254860003129579, + 0.004306560003897175, + 0.004420480006956495, + 0.004287199990358204 ], - "stddev": 0.0004411414200361265, - "iqr": 0.0004302025190554559, - "noise_ratio": 0.14100651486430446, - "noisy": true + "stddev": 8.330387668522975e-05, + "iqr": 5.1700000767596066e-05, + "noise_ratio": 0.01943083524738242, + "noisy": false }, { "driver": "sqlite", "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "write_heavy", - "time": 0.0061220829375088215, + "time": 0.005627367994748056, "times": [ - 0.0059563800459727645, - 0.0061220829375088215, - 0.008382795960642397 + 0.005526088003534824, + 0.005484861001605168, + 0.005846761996508576, + 0.005900725998799317, + 0.005627367994748056 ], - "stddev": 0.0013555918149275388, - "iqr": 0.0012132079573348165, - "noise_ratio": 0.22142656817373205, - "noisy": true + "stddev": 0.00018776813475699234, + "iqr": 0.0003206739929737523, + "noise_ratio": 0.033366955019155264, + "noisy": false }, { "driver": "sqlite", "library": "raw", "library_key": "raw", "scenario": "read_heavy", - "time": 0.0055305579444393516, + "time": 0.004371547998744063, "times": [ - 0.005601569078862667, - 0.005033050081692636, - 0.0055305579444393516 + 0.004326966998632997, + 0.004294172991649248, + 0.004471345993806608, + 0.004371547998744063, + 0.0044424379884731025 ], - "stddev": 0.0003097769371795639, - "iqr": 0.00028425949858501554, - "noise_ratio": 0.05601187805852866, + "stddev": 7.497403988753363e-05, + "iqr": 0.00011547098984010518, + "noise_ratio": 0.01715045560727539, "noisy": false }, { @@ -124,31 +150,35 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "read_heavy", - "time": 0.0045728120021522045, + "time": 0.005011429006117396, "times": [ - 0.004827670054510236, - 0.0035369839752092957, - 0.0045728120021522045 + 0.005175757993129082, + 0.005084271993837319, + 0.005011429006117396, + 0.0049597659963183105, + 0.004917177007882856 ], - "stddev": 0.0006835889217399349, - "iqr": 0.00064534303965047, - "noise_ratio": 0.1494898371982496, - "noisy": true + "stddev": 0.00010270960981180456, + "iqr": 0.00012450599751900882, + "noise_ratio": 0.02049507429645877, + "noisy": false }, { "driver": "sqlite", "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "read_heavy", - "time": 0.00874530605506152, + "time": 0.006095218996051699, "times": [ - 0.007777438033372164, - 0.008815814973786473, - 0.00874530605506152 + 0.006095218996051699, + 0.006120414996985346, + 0.006083756001316942, + 0.006076361009036191, + 0.006195608002599329 ], - "stddev": 0.0005802250548153436, - "iqr": 0.0005191884702071548, - "noise_ratio": 0.06634702675494437, + "stddev": 4.843750639106733e-05, + "iqr": 3.6658995668403804e-05, + "noise_ratio": 0.007946803293276862, "noisy": false }, { @@ -156,15 +186,17 @@ "library": "raw", "library_key": "raw", "scenario": "iterative_inserts", - "time": 0.006263092043809593, + "time": 0.004181689990218729, "times": [ - 0.006263092043809593, - 0.006623685942031443, - 0.005627415026538074 + 0.004181689990218729, + 0.00413410400506109, + 0.004149139000219293, + 0.004257650987710804, + 0.004246014999807812 ], - "stddev": 0.0005044252450033339, - "iqr": 0.0004981354577466846, - "noise_ratio": 0.08053933128795467, + "stddev": 5.592001368855386e-05, + "iqr": 9.687599958851933e-05, + "noise_ratio": 0.013372587116537753, "noisy": false }, { @@ -172,31 +204,35 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "iterative_inserts", - "time": 0.007394094951450825, + "time": 0.009428682998986915, "times": [ - 0.0071521460777148604, - 0.007394094951450825, - 0.011221560998819768 + 0.009358828014228493, + 0.00932132700108923, + 0.009531204996164888, + 0.009428682998986915, + 0.009545498003717512 ], - "stddev": 0.002282840825279452, - "iqr": 0.002034707460552454, - "noise_ratio": 0.3087383703169144, - "noisy": true + "stddev": 0.00010025793499290344, + "iqr": 0.00017237698193639517, + "noise_ratio": 0.010633291521591709, + "noisy": false }, { "driver": "sqlite", "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "iterative_inserts", - "time": 0.05394256697036326, + "time": 0.028197134990477934, "times": [ - 0.04960546805523336, - 0.05394256697036326, - 0.05660546000581235 + 0.028197134990477934, + 0.02821979699365329, + 0.028157600012491457, + 0.02819776799879037, + 0.027938569997786544 ], - "stddev": 0.003533207080119393, - "iqr": 0.003499995975289494, - "noise_ratio": 0.06549942426841834, + "stddev": 0.00011600569371612893, + "iqr": 4.0167986298911273e-05, + "noise_ratio": 0.004114095058072517, "noisy": false }, { @@ -204,31 +240,35 @@ "library": "raw", "library_key": "raw", "scenario": "repeated_queries", - "time": 0.16202167002484202, + "time": 0.10908412099524867, "times": [ - 0.12611343897879124, - 0.16344798903446645, - 0.16202167002484202 + 0.10908412099524867, + 0.10999828499916475, + 0.10901488899253309, + 0.10906870799954049, + 0.10911709500942379 ], - "stddev": 0.02115539360160705, - "iqr": 0.018667275027837604, - "noise_ratio": 0.13057138343508862, - "noisy": true + "stddev": 0.00041624358869188675, + "iqr": 4.838700988329947e-05, + "noise_ratio": 0.0038158036650450425, + "noisy": false }, { "driver": "sqlite", "library": "sqlspec", "library_key": "sqlspec", "scenario": "repeated_queries", - "time": 0.21340494696050882, + "time": 0.11235622799722478, "times": [ - 0.2195192320505157, - 0.19613285595551133, - 0.21340494696050882 + 0.1121271220035851, + 0.112780675990507, + 0.11174950200074818, + 0.11350953599321656, + 0.11235622799722478 ], - "stddev": 0.012128700100064229, - "iqr": 0.01169318804750219, - "noise_ratio": 0.056834203109212264, + "stddev": 0.0006746834775724083, + "iqr": 0.0006535539869219065, + "noise_ratio": 0.006004860519072188, "noisy": false }, { @@ -236,47 +276,53 @@ "library": "sqlalchemy", "library_key": "sqlalchemy", "scenario": "repeated_queries", - "time": 0.28744301199913025, + "time": 0.16552351499558426, "times": [ - 0.28744301199913025, - 0.31480474793352187, - 0.22830721095670015 + 0.1641665240022121, + 0.16491104500892106, + 0.16918576699390542, + 0.17900026100687683, + 0.16552351499558426 ], - "stddev": 0.044210729205570856, - "iqr": 0.04324876848841086, - "noise_ratio": 0.1538069368884314, - "noisy": true + "stddev": 0.006148746743329162, + "iqr": 0.004274721984984353, + "noise_ratio": 0.037147270244310575, + "noisy": false }, { "driver": "sqlite", "library": "raw", "library_key": "raw", "scenario": "dict_key_transform", - "time": 0.015097331022843719, + "time": 0.009565733998897485, "times": [ - 0.010837384033948183, - 0.015097331022843719, - 0.01851541001815349 + 0.009551441995427012, + 0.009336190996691585, + 0.009565733998897485, + 0.009962566997273825, + 0.009776612991117872 ], - "stddev": 0.003846697617881903, - "iqr": 0.003839012992102653, - "noise_ratio": 0.2547932221967895, - "noisy": true + "stddev": 0.0002389347908709586, + "iqr": 0.00022517099569085985, + "noise_ratio": 0.024978197271479367, + "noisy": false }, { "driver": "sqlite", "library": "sqlspec", "library_key": "sqlspec", "scenario": "dict_key_transform", - "time": 0.03478679293766618, + "time": 0.01544887199997902, "times": [ - 0.036578124971129, - 0.03478679293766618, - 0.03183783404529095 + 0.015325487009249628, + 0.01544887199997902, + 0.015402306002215482, + 0.015676584996981546, + 0.015492282997001894 ], - "stddev": 0.0023935882082698765, - "iqr": 0.0023701454629190266, - "noise_ratio": 0.06880738366882236, + "stddev": 0.0001314180127890617, + "iqr": 8.997699478641152e-05, + "noise_ratio": 0.008506641312662839, "noisy": false }, { @@ -284,47 +330,53 @@ "library": "raw", "library_key": "raw", "scenario": "schema_mapping", - "time": 0.009644347941502929, + "time": 0.00957132400071714, "times": [ - 0.015227310010232031, - 0.009596061077900231, - 0.009644347941502929 + 0.009600805002264678, + 0.00957132400071714, + 0.00926685800368432, + 0.00946096501138527, + 0.010403750988189131 ], - "stddev": 0.0032373538994048975, - "iqr": 0.0028156244661659002, - "noise_ratio": 0.335673693964675, - "noisy": true + "stddev": 0.00043551860653332256, + "iqr": 0.00013983999087940902, + "noise_ratio": 0.04550244109390623, + "noisy": false }, { "driver": "sqlite", "library": "sqlspec", "library_key": "sqlspec", "scenario": "schema_mapping", - "time": 0.015353130991570652, + "time": 0.013286437999340706, "times": [ - 0.015353130991570652, - 0.0200001330813393, - 0.01482859393581748 + 0.013902839011279866, + 0.01326507500198204, + 0.013467296987073496, + 0.013106648999382742, + 0.013286437999340706 ], - "stddev": 0.0028464769065998335, - "iqr": 0.00258576957276091, - "noise_ratio": 0.1854004182054226, - "noisy": true + "stddev": 0.0003059296050691119, + "iqr": 0.00020222198509145528, + "noise_ratio": 0.023025705240508603, + "noisy": false }, { "driver": "sqlite", "library": "raw", "library_key": "raw", "scenario": "complex_parameters", - "time": 0.009979208000004292, + "time": 0.00826842499373015, "times": [ - 0.010322440066374838, - 0.009979208000004292, - 0.00932889198884368 + 0.008301143010612577, + 0.008119215999613516, + 0.008257767010945827, + 0.00826842499373015, + 0.00859405800292734 ], - "stddev": 0.0005046214667323595, - "iqr": 0.0004967740387655795, - "noise_ratio": 0.05056728617462854, + "stddev": 0.00017435958410293485, + "iqr": 4.337599966675043e-05, + "noise_ratio": 0.021087399865772464, "noisy": false }, { @@ -332,15 +384,17 @@ "library": "sqlspec", "library_key": "sqlspec", "scenario": "complex_parameters", - "time": 0.007886267034336925, + "time": 0.009813410986680537, "times": [ - 0.007710185018368065, - 0.008266879012808204, - 0.007886267034336925 + 0.00970755000889767, + 0.00992931799555663, + 0.009813410986680537, + 0.009962814001482911, + 0.009700522001367062 ], - "stddev": 0.0002845401430229707, - "iqr": 0.00027834699722006917, - "noise_ratio": 0.036080460093993606, + "stddev": 0.00012172982103064316, + "iqr": 0.00022176798665896058, + "noise_ratio": 0.012404435236215375, "noisy": false }, { @@ -348,64 +402,252 @@ "library": "raw", "library_key": "raw", "scenario": "thin_path_stress", - "time": 0.025907220086082816, + "time": 0.018350698010181077, "times": [ - 0.02120684494730085, - 0.03086753294337541, - 0.025907220086082816 + 0.018361185997491702, + 0.018097220003255643, + 0.018584149991511367, + 0.018350698010181077, + 0.01830038399202749 ], - "stddev": 0.004830926802816081, - "iqr": 0.004830343998037279, - "noise_ratio": 0.18647028846646585, - "noisy": true + "stddev": 0.0001736922365496957, + "iqr": 6.0802005464211106e-05, + "noise_ratio": 0.009465156935901305, + "noisy": false }, { "driver": "sqlite", "library": "sqlspec", "library_key": "sqlspec", "scenario": "thin_path_stress", - "time": 0.0827907930361107, + "time": 0.0683942119940184, "times": [ - 0.0827907930361107, - 0.12025135790463537, - 0.07790373300667852 + 0.0683942119940184, + 0.06859845398867037, + 0.06788588200288359, + 0.06728459399892017, + 0.07255751600314397 ], - "stddev": 0.023167860678456675, - "iqr": 0.021173812448978424, - "noise_ratio": 0.27983619710408614, - "noisy": true + "stddev": 0.0020828111225034587, + "iqr": 0.0007125719857867807, + "noise_ratio": 0.030453031942024807, + "noisy": false }, { "driver": "sqlite", "library": "raw", "library_key": "raw", "scenario": "schema_type_numpy", - "time": 0.025361009989865124, + "time": 0.051206131000071764, "times": [ - 0.01786811393685639, - 0.025361009989865124, - 0.026038011070340872 + 0.050969773990800604, + 0.05114704099833034, + 0.051770587990176864, + 0.051206131000071764, + 0.051988815001095645 ], - "stddev": 0.0045341122044614416, - "iqr": 0.004084948566742241, - "noise_ratio": 0.17878279320395296, - "noisy": true + "stddev": 0.0004385630392862737, + "iqr": 0.0006235469918465242, + "noise_ratio": 0.008564658776615228, + "noisy": false }, { "driver": "sqlite", "library": "sqlspec", "library_key": "sqlspec", "scenario": "schema_type_numpy", - "time": 0.0007123140385374427, + "time": 0.03937012600363232, "times": [ - 0.0007123140385374427, - 0.0006974450079724193, - 0.0010529460851103067 + 0.039747274000546895, + 0.03891997299797367, + 0.039939324007718824, + 0.03937012600363232, + 0.039315785994403996 ], - "stddev": 0.00020109379862332885, - "iqr": 0.00017775053856894374, - "noise_ratio": 0.28231059300224415, - "noisy": true + "stddev": 0.00039773443787016765, + "iqr": 0.0004314880061428994, + "noise_ratio": 0.010102442593998107, + "noisy": false + }, + { + "driver": "sqlite", + "library": "raw", + "library_key": "raw", + "scenario": "scalar_param_select", + "time": 0.0013580560043919832, + "times": [ + 0.0013478469918482006, + 0.0013622740079881623, + 0.0013550139992730692, + 0.0013580560043919832, + 0.0013764250034000725 + ], + "stddev": 1.0621408665912961e-05, + "iqr": 7.260008715093136e-06, + "noise_ratio": 0.007821038772748024, + "noisy": false + }, + { + "driver": "sqlite", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "scalar_param_select", + "time": 0.007731876001344062, + "times": [ + 0.007662418996915221, + 0.007734676008112729, + 0.007731876001344062, + 0.00781970500247553, + 0.007689688005484641 + ], + "stddev": 5.966058487248296e-05, + "iqr": 4.4988002628088e-05, + "noise_ratio": 0.007716184902876342, + "noisy": false + }, + { + "driver": "sqlite", + "library": "raw", + "library_key": "raw", + "scenario": "no_param_select", + "time": 0.0013393950066529214, + "times": [ + 0.001320764989941381, + 0.0013500049972208217, + 0.0013393950066529214, + 0.0013467960088746622, + 0.001333893000264652 + ], + "stddev": 1.1588819270037595e-05, + "iqr": 1.2903008610010147e-05, + "noise_ratio": 0.008652278986015822, + "noisy": false + }, + { + "driver": "sqlite", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "no_param_select", + "time": 0.0053518780041486025, + "times": [ + 0.005385716998716816, + 0.0053518780041486025, + 0.005310234002536163, + 0.005422768997959793, + 0.005300614007865079 + ], + "stddev": 5.1246655623517794e-05, + "iqr": 7.548299618065357e-05, + "noise_ratio": 0.009575452875381883, + "noisy": false + }, + { + "driver": "sqlite", + "library": "raw", + "library_key": "raw", + "scenario": "complex_query", + "time": 0.0030574999982491136, + "times": [ + 0.003050535000511445, + 0.0030785599956288934, + 0.0030124610057100654, + 0.003082666007685475, + 0.0030574999982491136 + ], + "stddev": 2.804994003750898e-05, + "iqr": 2.802499511744827e-05, + "noise_ratio": 0.009174142290620396, + "noisy": false + }, + { + "driver": "sqlite", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "complex_query", + "time": 0.006443192993174307, + "times": [ + 0.0064240339997923, + 0.006305467002675869, + 0.006445741004426964, + 0.006443192993174307, + 0.006471240005339496 + ], + "stddev": 6.507459549662343e-05, + "iqr": 2.1707004634663463e-05, + "noise_ratio": 0.010099743336193899, + "noisy": false + }, + { + "driver": "sqlite", + "library": "raw", + "library_key": "raw", + "scenario": "session_per_request", + "time": 0.010022154005127959, + "times": [ + 0.010022154005127959, + 0.010339030995965004, + 0.01021236200176645, + 0.009826769004575908, + 0.009880614001303911 + ], + "stddev": 0.00021737530705022898, + "iqr": 0.0003317480004625395, + "noise_ratio": 0.021689479820306713, + "noisy": false + }, + { + "driver": "sqlite", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "session_per_request", + "time": 0.02374547498766333, + "times": [ + 0.023479077994124964, + 0.02374547498766333, + 0.0237338339939015, + 0.027480500008095987, + 0.024171199009288102 + ], + "stddev": 0.0016723984789667789, + "iqr": 0.00043736501538660377, + "noise_ratio": 0.07043019690427979, + "noisy": false + }, + { + "driver": "sqlite", + "library": "raw", + "library_key": "raw", + "scenario": "observability_select", + "time": 0.017145584992249496, + "times": [ + 0.017244325004867278, + 0.017175964007037692, + 0.017145584992249496, + 0.01694815600058064, + 0.017087613989133388 + ], + "stddev": 0.00011160414647370428, + "iqr": 8.835001790430397e-05, + "noise_ratio": 0.006509206103154481, + "noisy": false + }, + { + "driver": "sqlite", + "library": "sqlspec", + "library_key": "sqlspec", + "scenario": "observability_select", + "time": 0.035870573003194295, + "times": [ + 0.03587337300996296, + 0.035870573003194295, + 0.03573423300986178, + 0.036352101000375114, + 0.03557527500379365 + ], + "stddev": 0.0002903064358924952, + "iqr": 0.00013914000010117888, + "noise_ratio": 0.008093164161794774, + "noisy": false } ] -} +} \ No newline at end of file diff --git a/tools/perf_baselines/subsystems.json b/tools/perf_baselines/subsystems.json new file mode 100644 index 000000000..11610c060 --- /dev/null +++ b/tools/perf_baselines/subsystems.json @@ -0,0 +1,336 @@ +{ + "included_cases": [ + "SQL() construction (no params)", + "SQL() construction (with params)", + "SQL() construction (SELECT + param)", + "QueryBuilder.to_statement() - same shape varying values", + "SQL.compile() - INSERT", + "SQL.compile() - SELECT", + "SQL.compile() - complex JOIN", + "QC _cached_execution() - cache miss", + "QC _cached_execution() - full cycle", + "prepare_driver_parameters (tuple)", + "prepare_driver_parameters (dict)", + "_driver_parameters (3 params)", + "SQLResult.__init__() - DML", + "SQLResult.__init__() - SELECT (100 rows)", + "StorageRegistry.get() - cached alias", + "SyncStoragePipeline.write_rows() - local jsonl", + "_decode_arrow_payload() - jsonl", + "SqliteCursor context manager", + "raw sqlite3 cursor create+close", + "session.execute() - full path", + "raw sqlite3 conn.execute()", + "stub driver: fast-path execute(SELECT, tuple)", + "stub driver: fast-path execute(INSERT, tuple)", + "stub driver: fast-path fetch_one_or_none(SELECT, tuple)", + "stub driver: parameterless execute(SELECT)", + "stub driver: scalar-param execute(SELECT, int)", + "stub driver: complex CTE+2-join execute(SELECT, int)", + "stub driver: session-per-request simple SELECT", + "stub driver: session-per-request complex CTE SELECT", + "stub driver: execute_many 100 rows", + "librt ParameterConverter baseline", + "librt ParameterConverter StringWriter", + "librt splitter join baseline", + "librt splitter StringWriter", + "librt psqlpy copy baseline", + "librt psqlpy copy StringWriter" + ], + "metadata": { + "iterations": 5000, + "profile": false, + "timestamp": "2026-10-13T21:39:22+0000", + "warmup": 100 + }, + "results": [ + { + "description": "Time SQL('INSERT INTO test (value) VALUES (?)')", + "iterations": 5000, + "name": "SQL() construction (no params)", + "ops_per_sec": 913719.3186319757, + "time_per_op_us": 1.0944279929390177, + "total_time": 0.005437102998257615 + }, + { + "description": "Time SQL('INSERT ...', ('hello',))", + "iterations": 5000, + "name": "SQL() construction (with params)", + "ops_per_sec": 626649.6571693261, + "time_per_op_us": 1.5957879950292408, + "total_time": 0.008684266998898238 + }, + { + "description": "Time SQL('SELECT ... WHERE ...', param)", + "iterations": 5000, + "name": "SQL() construction (SELECT + param)", + "ops_per_sec": 637129.3471429019, + "time_per_op_us": 1.5695400070399046, + "total_time": 0.007883617014158517 + }, + { + "description": "Convert same-shape UPDATE builders while parameter values vary", + "iterations": 5000, + "name": "QueryBuilder.to_statement() - same shape varying values", + "ops_per_sec": 4120.919565822979, + "time_per_op_us": 242.66428500413895, + "total_time": 1.2694333770195954 + }, + { + "description": "Compile simple INSERT statement", + "iterations": 5000, + "name": "SQL.compile() - INSERT", + "ops_per_sec": 161767.66763378118, + "time_per_op_us": 6.1817050009267405, + "total_time": 0.030903736987966113 + }, + { + "description": "Compile simple SELECT statement", + "iterations": 5000, + "name": "SQL.compile() - SELECT", + "ops_per_sec": 162352.42156694084, + "time_per_op_us": 6.159440003102645, + "total_time": 0.03080166499421466 + }, + { + "description": "Compile complex JOIN with multiple params", + "iterations": 5000, + "name": "SQL.compile() - complex JOIN", + "ops_per_sec": 147818.3926397272, + "time_per_op_us": 6.76505800220184, + "total_time": 0.033716250996803865 + }, + { + "description": "Cache lookup with cache miss (early return, no execution)", + "iterations": 5000, + "name": "QC _cached_execution() - cache miss", + "ops_per_sec": 3329193.936325096, + "time_per_op_us": 0.3003730089403689, + "total_time": 0.0015094660047907382 + }, + { + "description": "Full stmt_cache lookup -> prepare -> execute cycle", + "iterations": 5000, + "name": "QC _cached_execution() - full cycle", + "ops_per_sec": 48080.33261572494, + "time_per_op_us": 20.79852500173729, + "total_time": 0.10446592500375118 + }, + { + "description": "Prepare a single positional parameter tuple", + "iterations": 5000, + "name": "prepare_driver_parameters (tuple)", + "ops_per_sec": 1000033.0009910618, + "time_per_op_us": 0.9999670000979677, + "total_time": 0.005002377001801506 + }, + { + "description": "Prepare a single named parameter dict", + "iterations": 5000, + "name": "prepare_driver_parameters (dict)", + "ops_per_sec": 854326.9969933727, + "time_per_op_us": 1.170511997770518, + "total_time": 0.005851471985806711 + }, + { + "description": "Format a 3-element positional parameter set", + "iterations": 5000, + "name": "_driver_parameters (3 params)", + "ops_per_sec": 635211.826417988, + "time_per_op_us": 1.574277994222939, + "total_time": 0.007899189979070798 + }, + { + "description": "Construct SQLResult for an INSERT operation", + "iterations": 5000, + "name": "SQLResult.__init__() - DML", + "ops_per_sec": 1198160.0977602866, + "time_per_op_us": 0.834613005281426, + "total_time": 0.004188569990219548 + }, + { + "description": "Construct SQLResult for a SELECT with 100 rows", + "iterations": 5000, + "name": "SQLResult.__init__() - SELECT (100 rows)", + "ops_per_sec": 1097976.8669275937, + "time_per_op_us": 0.910766000743024, + "total_time": 0.004547191012534313 + }, + { + "description": "Resolve a cached storage alias through the registry hot path", + "iterations": 5000, + "name": "StorageRegistry.get() - cached alias", + "ops_per_sec": 980211.4838925864, + "time_per_op_us": 1.020188006805256, + "total_time": 0.005175599013455212 + }, + { + "description": "Encode rows and route them through the sync local storage pipeline", + "iterations": 5000, + "name": "SyncStoragePipeline.write_rows() - local jsonl", + "ops_per_sec": 5791.079632721365, + "time_per_op_us": 172.6793730049394, + "total_time": 0.8718435269984184 + }, + { + "description": "Decode JSONL bytes through the Arrow boundary helper", + "iterations": 5000, + "name": "_decode_arrow_payload() - jsonl", + "ops_per_sec": 6869.321496958173, + "time_per_op_us": 145.57478499773424, + "total_time": 0.7582230610132683 + }, + { + "description": "Enter/exit SqliteCursor context manager", + "iterations": 5000, + "name": "SqliteCursor context manager", + "ops_per_sec": 1128520.4166203288, + "time_per_op_us": 0.8861160022206604, + "total_time": 0.004822624992812052 + }, + { + "description": "Raw sqlite3 cursor() + close() for comparison", + "iterations": 5000, + "name": "raw sqlite3 cursor create+close", + "ops_per_sec": 5109861.88069036, + "time_per_op_us": 0.19570000586099923, + "total_time": 0.0009786640148377046 + }, + { + "description": "Full sqlspec execute() including QC, dispatch, result", + "iterations": 5000, + "name": "session.execute() - full path", + "ops_per_sec": 46879.57367445652, + "time_per_op_us": 21.331252006348222, + "total_time": 0.10676726199744735 + }, + { + "description": "Raw sqlite3 execute for comparison baseline", + "iterations": 5000, + "name": "raw sqlite3 conn.execute()", + "ops_per_sec": 62133.80282324967, + "time_per_op_us": 16.09429898962844, + "total_time": 0.0820974859962007 + }, + { + "description": "Pure-Python driver overhead on warm fast-path SELECT with 1-tuple param", + "iterations": 5000, + "name": "stub driver: fast-path execute(SELECT, tuple)", + "ops_per_sec": 199214.29866762075, + "time_per_op_us": 5.019720003474504, + "total_time": 0.02853145000699442 + }, + { + "description": "Pure-Python driver overhead on warm fast-path INSERT with 1-tuple param", + "iterations": 5000, + "name": "stub driver: fast-path execute(INSERT, tuple)", + "ops_per_sec": 266848.62190703605, + "time_per_op_us": 3.747442999156192, + "total_time": 0.021824130992172286 + }, + { + "description": "Pure-Python driver overhead on warm fetch_one_or_none with 1-tuple param", + "iterations": 5000, + "name": "stub driver: fast-path fetch_one_or_none(SELECT, tuple)", + "ops_per_sec": 152632.9798998559, + "time_per_op_us": 6.551664002472535, + "total_time": 0.03708145199925639 + }, + { + "description": "Pure-Python driver overhead on parameterless SELECT 1", + "iterations": 5000, + "name": "stub driver: parameterless execute(SELECT)", + "ops_per_sec": 238372.71592313284, + "time_per_op_us": 4.195110988803208, + "total_time": 0.021016020997194573 + }, + { + "description": "Pure-Python driver overhead on SELECT with scalar positional param", + "iterations": 5000, + "name": "stub driver: scalar-param execute(SELECT, int)", + "ops_per_sec": 191849.39328270138, + "time_per_op_us": 5.21242200920824, + "total_time": 0.026079890012624674 + }, + { + "description": "Pure-Python driver overhead on CTE + 2-join query with scalar param", + "iterations": 2000, + "name": "stub driver: complex CTE+2-join execute(SELECT, int)", + "ops_per_sec": 190476.18934846655, + "time_per_op_us": 5.250000031082891, + "total_time": 0.0105269009800395 + }, + { + "description": "New driver instance per request executing simple SELECT", + "iterations": 2000, + "name": "stub driver: session-per-request simple SELECT", + "ops_per_sec": 150011.68233821707, + "time_per_op_us": 6.666147492069285, + "total_time": 0.013450521000777371 + }, + { + "description": "New driver instance per request executing CTE + 2-join SELECT", + "iterations": 2000, + "name": "stub driver: session-per-request complex CTE SELECT", + "ops_per_sec": 148770.50532131383, + "time_per_op_us": 6.721762474626303, + "total_time": 0.013813848985591903 + }, + { + "description": "Pure-Python driver overhead on execute_many with 100 parameter rows", + "iterations": 2000, + "name": "stub driver: execute_many 100 rows", + "ops_per_sec": 7149.427723435246, + "time_per_op_us": 139.87133497721516, + "total_time": 0.2836982590088155 + }, + { + "description": "Current list-join placeholder assembly shape from ParameterConverter", + "iterations": 5000, + "name": "librt ParameterConverter baseline", + "ops_per_sec": 1007553.6268856531, + "time_per_op_us": 0.9925030026352033, + "total_time": 0.00521571101853624 + }, + { + "description": "librt.strings.StringWriter candidate for ParameterConverter assembly", + "iterations": 5000, + "name": "librt ParameterConverter StringWriter", + "ops_per_sec": 1062117.9553103049, + "time_per_op_us": 0.941515012527816, + "total_time": 0.004716872004792094 + }, + { + "description": "Current list-join statement assembly shape from StatementSplitter", + "iterations": 5000, + "name": "librt splitter join baseline", + "ops_per_sec": 341651.7555122614, + "time_per_op_us": 2.9269570077303797, + "total_time": 0.014837033028015867 + }, + { + "description": "librt.strings.StringWriter candidate for StatementSplitter assembly", + "iterations": 5000, + "name": "librt splitter StringWriter", + "ops_per_sec": 302831.35208128975, + "time_per_op_us": 3.302167999208905, + "total_time": 0.020603441007551737 + }, + { + "description": "Current StringIO row assembly shape from psqlpy binary copy encoding", + "iterations": 5000, + "name": "librt psqlpy copy baseline", + "ops_per_sec": 60645.925178129015, + "time_per_op_us": 16.48915400437545, + "total_time": 0.08254189301806036 + }, + { + "description": "librt.strings.StringWriter candidate for psqlpy binary copy encoding", + "iterations": 5000, + "name": "librt psqlpy copy StringWriter", + "ops_per_sec": 90935.46218894077, + "time_per_op_us": 10.99681000050623, + "total_time": 0.05711644601251464 + } + ] +} \ No newline at end of file diff --git a/tools/scripts/bench.py b/tools/scripts/bench.py index 368f0308b..f95ef1351 100644 --- a/tools/scripts/bench.py +++ b/tools/scripts/bench.py @@ -7,9 +7,11 @@ import cProfile import gc import importlib +import importlib.metadata import inspect import json import os +import platform import pstats import sqlite3 import statistics @@ -26,7 +28,7 @@ from rich.console import Console from rich.table import Table -from sqlspec import SQLSpec +from sqlspec import ObservabilityConfig, SQLSpec from sqlspec.adapters.duckdb import DuckDBConfig from sqlspec.adapters.sqlite import SqliteConfig from sqlspec.utils.schema import _convert_numpy_recursive, to_schema, transform_dict_keys @@ -36,36 +38,77 @@ "main", "print_benchmark_table", "raw_adbc_rows", + "raw_aiosqlite_complex_query", + "raw_aiosqlite_no_param_select", + "raw_aiosqlite_observability_select", + "raw_aiosqlite_scalar_param_select", + "raw_aiosqlite_session_per_request", "raw_aiosqlite_worker_hops", + "raw_asyncpg_complex_query", "raw_asyncpg_initialization", "raw_asyncpg_iterative_inserts", + "raw_asyncpg_no_param_select", + "raw_asyncpg_observability_select", "raw_asyncpg_read_heavy", "raw_asyncpg_repeated_queries", "raw_asyncpg_rows", + "raw_asyncpg_scalar_param_select", + "raw_asyncpg_session_per_request", "raw_asyncpg_write_heavy", "raw_cockroach_asyncpg_rows", "raw_cockroach_psycopg_async_rows", "raw_cockroach_psycopg_sync_rows", "raw_duckdb_bulk", + "raw_duckdb_complex_query", "raw_duckdb_initialization", "raw_duckdb_iterative_inserts", + "raw_duckdb_no_param_select", + "raw_duckdb_observability_select", "raw_duckdb_read_heavy", "raw_duckdb_repeated_queries", + "raw_duckdb_scalar_param_select", + "raw_duckdb_session_per_request", "raw_duckdb_write_heavy", "raw_mysqlconnector_json_rows", + "raw_oracle_complex_query", "raw_oracle_lob_fetch_1k", "raw_oracle_lob_fetch_100k", + "raw_oracle_no_param_select", + "raw_oracle_observability_select", + "raw_oracle_scalar_param_select", + "raw_oracle_session_per_request", + "raw_psqlpy_complex_query", + "raw_psqlpy_no_param_select", + "raw_psqlpy_observability_select", + "raw_psqlpy_rows", + "raw_psqlpy_scalar_param_select", + "raw_psqlpy_session_per_request", + "raw_psycopg_async_complex_query", + "raw_psycopg_async_no_param_select", + "raw_psycopg_async_observability_select", "raw_psycopg_async_rows", + "raw_psycopg_async_scalar_param_select", + "raw_psycopg_async_session_per_request", + "raw_psycopg_sync_complex_query", + "raw_psycopg_sync_no_param_select", + "raw_psycopg_sync_observability_select", "raw_psycopg_sync_rows", + "raw_psycopg_sync_scalar_param_select", + "raw_psycopg_sync_session_per_request", "raw_spanner_strings", "raw_sqlite_complex_parameters", + "raw_sqlite_complex_query", "raw_sqlite_dict_key_transform", "raw_sqlite_initialization", "raw_sqlite_iterative_inserts", + "raw_sqlite_no_param_select", + "raw_sqlite_observability_select", "raw_sqlite_read_heavy", "raw_sqlite_repeated_queries", + "raw_sqlite_scalar_param_select", "raw_sqlite_schema_mapping", "raw_sqlite_schema_type_numpy", + "raw_sqlite_session_per_request", "raw_sqlite_thin_path_stress", "raw_sqlite_write_heavy", "run_benchmark", @@ -86,23 +129,44 @@ "sqlalchemy_sqlite_repeated_queries", "sqlalchemy_sqlite_write_heavy", "sqlspec_adbc_rows", + "sqlspec_aiosqlite_complex_query", + "sqlspec_aiosqlite_no_param_select", + "sqlspec_aiosqlite_observability_select", + "sqlspec_aiosqlite_scalar_param_select", + "sqlspec_aiosqlite_session_per_request", "sqlspec_aiosqlite_worker_hops", + "sqlspec_asyncpg_complex_query", "sqlspec_asyncpg_initialization", "sqlspec_asyncpg_iterative_inserts", + "sqlspec_asyncpg_no_param_select", + "sqlspec_asyncpg_observability_select", "sqlspec_asyncpg_read_heavy", "sqlspec_asyncpg_repeated_queries", "sqlspec_asyncpg_rows", + "sqlspec_asyncpg_scalar_param_select", + "sqlspec_asyncpg_session_per_request", "sqlspec_asyncpg_write_heavy", "sqlspec_cockroach_asyncpg_rows", "sqlspec_cockroach_psycopg_async_rows", "sqlspec_cockroach_psycopg_sync_rows", "sqlspec_duckdb_bulk", + "sqlspec_duckdb_complex_query", "sqlspec_duckdb_initialization", "sqlspec_duckdb_iterative_inserts", + "sqlspec_duckdb_no_param_select", + "sqlspec_duckdb_observability_select", "sqlspec_duckdb_read_heavy", "sqlspec_duckdb_repeated_queries", + "sqlspec_duckdb_scalar_param_select", + "sqlspec_duckdb_session_per_request", "sqlspec_duckdb_write_heavy", "sqlspec_mysqlconnector_json_rows", + "sqlspec_oracle_async_complex_query", + "sqlspec_oracle_async_no_param_select", + "sqlspec_oracle_async_observability_select", + "sqlspec_oracle_async_scalar_param_select", + "sqlspec_oracle_async_session_per_request", + "sqlspec_oracle_complex_query", "sqlspec_oracle_json_read", "sqlspec_oracle_lob_fetch_1k", "sqlspec_oracle_lob_fetch_100k", @@ -113,62 +177,75 @@ "sqlspec_oracle_lob_fetch_fetch_lobs_true_1k", "sqlspec_oracle_lob_fetch_fetch_lobs_true_100k", "sqlspec_oracle_native_json_write", + "sqlspec_oracle_no_param_select", + "sqlspec_oracle_observability_select", + "sqlspec_oracle_scalar_param_select", "sqlspec_oracle_serialized_json_write", + "sqlspec_oracle_session_per_request", + "sqlspec_psqlpy_complex_query", + "sqlspec_psqlpy_no_param_select", + "sqlspec_psqlpy_observability_select", + "sqlspec_psqlpy_rows", + "sqlspec_psqlpy_scalar_param_select", + "sqlspec_psqlpy_session_per_request", + "sqlspec_psycopg_async_complex_query", + "sqlspec_psycopg_async_no_param_select", + "sqlspec_psycopg_async_observability_select", "sqlspec_psycopg_async_rows", + "sqlspec_psycopg_async_scalar_param_select", + "sqlspec_psycopg_async_session_per_request", + "sqlspec_psycopg_sync_complex_query", + "sqlspec_psycopg_sync_no_param_select", + "sqlspec_psycopg_sync_observability_select", "sqlspec_psycopg_sync_rows", + "sqlspec_psycopg_sync_scalar_param_select", + "sqlspec_psycopg_sync_session_per_request", "sqlspec_spanner_strings", "sqlspec_sqlite_complex_parameters", + "sqlspec_sqlite_complex_query", "sqlspec_sqlite_dict_key_transform", "sqlspec_sqlite_initialization", "sqlspec_sqlite_iterative_inserts", + "sqlspec_sqlite_no_param_select", + "sqlspec_sqlite_observability_select", "sqlspec_sqlite_read_heavy", "sqlspec_sqlite_repeated_queries", + "sqlspec_sqlite_scalar_param_select", "sqlspec_sqlite_schema_mapping", "sqlspec_sqlite_schema_type_numpy", + "sqlspec_sqlite_session_per_request", "sqlspec_sqlite_thin_path_stress", "sqlspec_sqlite_write_heavy", ) -# Pool leak detection helper _leaked_pools: list[str] = [] +ROWS_TO_INSERT = 10_000 -class BenchmarkUnavailableError(RuntimeError): - """Signal that an optional benchmark dependency or service is unavailable.""" - - -def _benchmark_unavailable() -> NoReturn: - raise BenchmarkUnavailableError - - -def _is_compiled() -> bool: - """Detect if sqlspec driver modules are mypyc-compiled.""" - try: - from sqlspec.driver import _sync - - return hasattr(_sync, "__file__") and (_sync.__file__ or "").endswith(".so") - except ImportError: - return False - - -SQLSPEC_LABEL = "sqlspec (mypyc)" if _is_compiled() else "sqlspec" - +POOL_SIZE = 1 -ROWS_TO_INSERT = 10_000 -POOL_SIZE = 1 # Match the single raw connection used by each benchmark scenario DEFAULT_BENCH_ITERATIONS = 7 + DEFAULT_BENCH_WARMUP = 3 + NOISY_STDDEV_RATIO = 0.10 + CORE_LIBRARIES = ("raw", "sqlspec", "sqlalchemy") + CORE_SCENARIOS = ("initialization", "write_heavy", "read_heavy", "iterative_inserts", "repeated_queries") + ORACLE_LOB_ROWS = 100 + ORACLE_LOB_PAYLOAD_SIZES = {"1k": 1024, "100k": 100 * 1024} + ORACLE_JSON_ROWS = 100 + ORACLE_JSON_PAYLOAD = { "active": True, "count": 3, "items": [{"name": "alpha", "value": 1.25}, {"name": "beta", "value": 2.5}], } + ORACLE_LOB_ENV_VARS = ( "SQLSPEC_BENCH_ORACLE_HOST", "SQLSPEC_BENCH_ORACLE_PORT", @@ -176,8 +253,24 @@ def _is_compiled() -> bool: "SQLSPEC_BENCH_ORACLE_USER", "SQLSPEC_BENCH_ORACLE_PASSWORD", ) + POSTGRES_DSN_ENV = "SQLSPEC_BENCH_POSTGRES_DSN" + COCKROACH_DSN_ENV = "SQLSPEC_BENCH_COCKROACH_DSN" + +HOT_PATH_EXTENDED_SCENARIOS = ( + ("raw", "scalar_param_select"), + ("sqlspec", "scalar_param_select"), + ("raw", "no_param_select"), + ("sqlspec", "no_param_select"), + ("raw", "complex_query"), + ("sqlspec", "complex_query"), + ("raw", "session_per_request"), + ("sqlspec", "session_per_request"), + ("raw", "observability_select"), + ("sqlspec", "observability_select"), +) + SQLITE_EXTENDED_SCENARIOS = ( ("raw", "dict_key_transform"), ("sqlspec", "dict_key_transform"), @@ -189,7 +282,9 @@ def _is_compiled() -> bool: ("sqlspec", "thin_path_stress"), ("raw", "schema_type_numpy"), ("sqlspec", "schema_type_numpy"), + *HOT_PATH_EXTENDED_SCENARIOS, ) + ORACLE_EXTENDED_SCENARIOS = ( ("sqlspec_native_json", "json_write"), ("sqlspec_serialized_json", "json_write"), @@ -204,13 +299,37 @@ def _is_compiled() -> bool: ("sqlspec_fetch_lobs_true", "lob_fetch_100k"), ("sqlspec_async", "lob_fetch_100k"), ("sqlspec_async_fetch_lobs_true", "lob_fetch_100k"), + ("raw", "scalar_param_select"), + ("sqlspec", "scalar_param_select"), + ("sqlspec_async", "scalar_param_select"), + ("raw", "no_param_select"), + ("sqlspec", "no_param_select"), + ("sqlspec_async", "no_param_select"), + ("raw", "complex_query"), + ("sqlspec", "complex_query"), + ("sqlspec_async", "complex_query"), + ("raw", "session_per_request"), + ("sqlspec", "session_per_request"), + ("sqlspec_async", "session_per_request"), + ("raw", "observability_select"), + ("sqlspec", "observability_select"), + ("sqlspec_async", "observability_select"), ) -AIOSQLITE_EXTENDED_SCENARIOS = (("raw", "worker_hops"), ("sqlspec", "worker_hops")) + +AIOSQLITE_EXTENDED_SCENARIOS = (("raw", "worker_hops"), ("sqlspec", "worker_hops"), *HOT_PATH_EXTENDED_SCENARIOS) + SPANNER_EXTENDED_SCENARIOS = (("raw", "strings"), ("sqlspec", "strings")) + MYSQLCONNECTOR_EXTENDED_SCENARIOS = (("raw", "json_rows"), ("sqlspec", "json_rows")) + ADBC_EXTENDED_SCENARIOS = (("raw", "rows"), ("sqlspec", "rows")) -DUCKDB_EXTENDED_SCENARIOS = (("raw", "bulk"), ("sqlspec", "bulk")) + +DUCKDB_EXTENDED_SCENARIOS = (("raw", "bulk"), ("sqlspec", "bulk"), *HOT_PATH_EXTENDED_SCENARIOS) + SERVICE_ROWS_SCENARIOS = (("raw", "rows"), ("sqlspec", "rows")) + +POSTGRES_EXTENDED_SCENARIOS = (*SERVICE_ROWS_SCENARIOS, *HOT_PATH_EXTENDED_SCENARIOS) + EXTENDED_SCENARIOS_BY_DRIVER = { "sqlite": SQLITE_EXTENDED_SCENARIOS, "aiosqlite": AIOSQLITE_EXTENDED_SCENARIOS, @@ -219,14 +338,245 @@ def _is_compiled() -> bool: "adbc": ADBC_EXTENDED_SCENARIOS, "duckdb": DUCKDB_EXTENDED_SCENARIOS, "oracle": ORACLE_EXTENDED_SCENARIOS, - "psycopg_sync": SERVICE_ROWS_SCENARIOS, - "psycopg_async": SERVICE_ROWS_SCENARIOS, - "asyncpg": SERVICE_ROWS_SCENARIOS, + "psycopg_sync": POSTGRES_EXTENDED_SCENARIOS, + "psycopg_async": POSTGRES_EXTENDED_SCENARIOS, + "asyncpg": POSTGRES_EXTENDED_SCENARIOS, + "psqlpy": POSTGRES_EXTENDED_SCENARIOS, "cockroach_psycopg_sync": SERVICE_ROWS_SCENARIOS, "cockroach_psycopg_async": SERVICE_ROWS_SCENARIOS, "cockroach_asyncpg": SERVICE_ROWS_SCENARIOS, } +SCALAR_PARAM_SELECT_SQL = "SELECT ?;" + +SCALAR_PARAM_SELECT_ASYNCPG_SQL = "SELECT $1::int;" + +SCALAR_PARAM_SELECT_PG_SQL = "SELECT %s::int;" + +SCALAR_PARAM_SELECT_ORACLE_SQL = "SELECT :1 FROM DUAL" + +NO_PARAM_SELECT_SQL = "SELECT 1;" + +NO_PARAM_SELECT_ORACLE_SQL = "SELECT 1 FROM DUAL" + +COMPLEX_SCHEMA_DDL = ( + "CREATE TABLE users (id INTEGER, org_id INTEGER, status TEXT, name TEXT);", + "CREATE TABLE orgs (id INTEGER, tier TEXT, region TEXT);", + "CREATE TABLE events (id INTEGER, user_id INTEGER, kind TEXT, ts INTEGER, amount FLOAT);", +) + +COMPLEX_QUERY_SQL = """ +WITH recent_events AS ( + SELECT user_id, kind, COUNT(*) AS cnt, SUM(amount) AS total_amount + FROM events + WHERE ts >= ? + GROUP BY user_id, kind +) +SELECT u.id, u.name, o.tier, o.region, r.kind, r.cnt, r.total_amount +FROM users u +JOIN orgs o ON o.id = u.org_id +LEFT JOIN recent_events r ON r.user_id = u.id +WHERE u.status = 'active' AND o.region = 'us' +ORDER BY r.total_amount DESC +LIMIT 25 +""" + +POSTGRES_COMPLEX_DROP_DDL = ( + "DROP TABLE IF EXISTS sqlspec_bench_events", + "DROP TABLE IF EXISTS sqlspec_bench_users", + "DROP TABLE IF EXISTS sqlspec_bench_orgs", +) + +POSTGRES_COMPLEX_SCHEMA_DDL = ( + "CREATE TABLE sqlspec_bench_users (id INTEGER, org_id INTEGER, status TEXT, name TEXT)", + "CREATE TABLE sqlspec_bench_orgs (id INTEGER, tier TEXT, region TEXT)", + "CREATE TABLE sqlspec_bench_events (id INTEGER, user_id INTEGER, kind TEXT, ts INTEGER, amount DOUBLE PRECISION)", +) + +POSTGRES_COMPLEX_QUERY_ASYNCPG_SQL = """ +WITH recent_events AS ( + SELECT user_id, kind, COUNT(*) AS cnt, SUM(amount) AS total_amount + FROM sqlspec_bench_events + WHERE ts >= $1::int + GROUP BY user_id, kind +) +SELECT u.id, u.name, o.tier, o.region, r.kind, r.cnt, r.total_amount +FROM sqlspec_bench_users u +JOIN sqlspec_bench_orgs o ON o.id = u.org_id +LEFT JOIN recent_events r ON r.user_id = u.id +WHERE u.status = 'active' AND o.region = 'us' +ORDER BY r.total_amount DESC +LIMIT 25 +""" + +POSTGRES_COMPLEX_QUERY_PG_SQL = """ +WITH recent_events AS ( + SELECT user_id, kind, COUNT(*) AS cnt, SUM(amount) AS total_amount + FROM sqlspec_bench_events + WHERE ts >= %s::int + GROUP BY user_id, kind +) +SELECT u.id, u.name, o.tier, o.region, r.kind, r.cnt, r.total_amount +FROM sqlspec_bench_users u +JOIN sqlspec_bench_orgs o ON o.id = u.org_id +LEFT JOIN recent_events r ON r.user_id = u.id +WHERE u.status = 'active' AND o.region = 'us' +ORDER BY r.total_amount DESC +LIMIT 25 +""" + +ORACLE_COMPLEX_TABLES = ("SQLSPEC_BENCH_EVENTS", "SQLSPEC_BENCH_USERS", "SQLSPEC_BENCH_ORGS") + +ORACLE_COMPLEX_SCHEMA_DDL = ( + "CREATE TABLE SQLSPEC_BENCH_USERS (id NUMBER, org_id NUMBER, status VARCHAR2(32), name VARCHAR2(64))", + "CREATE TABLE SQLSPEC_BENCH_ORGS (id NUMBER, tier VARCHAR2(32), region VARCHAR2(32))", + "CREATE TABLE SQLSPEC_BENCH_EVENTS (id NUMBER, user_id NUMBER, kind VARCHAR2(32), ts NUMBER, amount BINARY_DOUBLE)", +) + +ORACLE_COMPLEX_QUERY_SQL = """ +WITH recent_events AS ( + SELECT user_id, kind, COUNT(*) AS cnt, SUM(amount) AS total_amount + FROM SQLSPEC_BENCH_EVENTS + WHERE ts >= :1 + GROUP BY user_id, kind +) +SELECT u.id, u.name, o.tier, o.region, r.kind, r.cnt, r.total_amount +FROM SQLSPEC_BENCH_USERS u +JOIN SQLSPEC_BENCH_ORGS o ON o.id = u.org_id +LEFT JOIN recent_events r ON r.user_id = u.id +WHERE u.status = 'active' AND o.region = 'us' +ORDER BY r.total_amount DESC +FETCH FIRST 25 ROWS ONLY +""" + +ORACLE_KV_TABLE = "SQLSPEC_BENCH_KV" + +ORACLE_KV_CREATE_SQL = f"CREATE TABLE {ORACLE_KV_TABLE} (value VARCHAR2(64))" + +ORACLE_KV_INSERT_SQL = f"INSERT INTO {ORACLE_KV_TABLE} (value) VALUES (:1)" + +ORACLE_KV_SELECT_BY_VALUE_SQL = f"SELECT value FROM {ORACLE_KV_TABLE} WHERE value = :1" + +SERVICE_ROWS_SELECT_BY_PAYLOAD_PG = "SELECT id, payload FROM sqlspec_bench_rows WHERE payload = %s" + +SERVICE_ROWS_SELECT_BY_PAYLOAD_ASYNCPG = "SELECT id, payload FROM sqlspec_bench_rows WHERE payload = $1" + +HOT_PATH_COMPLEX_ITERATIONS_CAP = 500 + +CREATE_TEST_TABLE = "CREATE TABLE test (value TEXT);" + +DROP_TEST_TABLE = "DROP TABLE IF EXISTS test;" + +INSERT_TEST_VALUE = "INSERT INTO test (value) VALUES (?);" + +INSERT_TEST_VALUE_ASYNCPG = "INSERT INTO test (value) VALUES ($1);" + +SELECT_TEST_VALUES = "SELECT * FROM test;" + +INSERT_TEST_VALUE_SQLA = "INSERT INTO test (value) VALUES (:value);" + +SELECT_BY_VALUE = "SELECT * FROM test WHERE value = ?;" + +SELECT_BY_VALUE_ASYNCPG = "SELECT * FROM test WHERE value = $1;" + +SELECT_BY_VALUE_SQLA = "SELECT * FROM test WHERE value = :value;" + +SERVICE_ROWS_CREATE = "CREATE TABLE IF NOT EXISTS sqlspec_bench_rows (id INTEGER PRIMARY KEY, payload TEXT)" + +SERVICE_ROWS_DROP = "DROP TABLE IF EXISTS sqlspec_bench_rows" + +SERVICE_ROWS_INSERT_PG = "INSERT INTO sqlspec_bench_rows (id, payload) VALUES (%s, %s)" + +SERVICE_ROWS_INSERT_ASYNCPG = "INSERT INTO sqlspec_bench_rows (id, payload) VALUES ($1, $2)" + +SERVICE_ROWS_SELECT = "SELECT id, payload FROM sqlspec_bench_rows ORDER BY id" + +CREATE_WIDE_TABLE = """CREATE TABLE wide_test ( + first_name TEXT, + last_name TEXT, + email_address TEXT, + phone_number TEXT, + street_address TEXT, + postal_code TEXT, + country_name TEXT, + birth_date TEXT, + account_balance TEXT, + is_active TEXT +);""" + +INSERT_WIDE_ROW = ( + "INSERT INTO wide_test " + "(first_name, last_name, email_address, phone_number, street_address, " + "postal_code, country_name, birth_date, account_balance, is_active) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);" +) + +SELECT_WIDE_ALL = "SELECT * FROM wide_test;" + +CREATE_COMPLEX_TABLE = """CREATE TABLE complex_test ( + uuid_val TEXT, + created_at TEXT, + payload TEXT +);""" + +INSERT_COMPLEX_ROW = "INSERT INTO complex_test (uuid_val, created_at, payload) VALUES (?, ?, ?);" + +SELECT_COMPLEX_ALL = "SELECT * FROM complex_test;" + +BENCHMARK_ROWS = 1_000 + +DUCKDB_BULK_TABLE = "sqlspec_bench_bulk" + +DUCKDB_BULK_CREATE = f"CREATE TABLE {DUCKDB_BULK_TABLE} (id INTEGER, payload VARCHAR)" + +DUCKDB_BULK_INSERT = f"INSERT INTO {DUCKDB_BULK_TABLE} (id, payload) VALUES (?, ?)" + +DUCKDB_BULK_COUNT = f"SELECT COUNT(*) FROM {DUCKDB_BULK_TABLE}" + +ADBC_ROWS_TABLE = "sqlspec_bench_adbc_rows" + +ADBC_ROWS_CREATE = f"CREATE TABLE IF NOT EXISTS {ADBC_ROWS_TABLE} (id INTEGER, payload VARCHAR)" + +ADBC_ROWS_TRUNCATE = f"DELETE FROM {ADBC_ROWS_TABLE}" + +ADBC_ROWS_INSERT = f"INSERT INTO {ADBC_ROWS_TABLE} (id, payload) VALUES (?, ?)" + +ADBC_ROWS_INSERT_PG = f"INSERT INTO {ADBC_ROWS_TABLE} (id, payload) VALUES ($1, $2)" + +ADBC_ROWS_SELECT = f"SELECT id, payload FROM {ADBC_ROWS_TABLE} ORDER BY id" + +MYSQL_JSON_TABLE = "sqlspec_bench_json_rows" + +MYSQL_JSON_CREATE = f"CREATE TABLE IF NOT EXISTS {MYSQL_JSON_TABLE} (id INT PRIMARY KEY, payload JSON NOT NULL)" + +MYSQL_JSON_INSERT_RAW = f"INSERT INTO {MYSQL_JSON_TABLE} (id, payload) VALUES (%s, %s)" + +MYSQL_JSON_INSERT = f"INSERT INTO {MYSQL_JSON_TABLE} (id, payload) VALUES (?, ?)" + +MYSQL_JSON_SELECT = f"SELECT id, payload FROM {MYSQL_JSON_TABLE} ORDER BY id" + +SPANNER_STRINGS_TABLE = "sqlspec_bench_strings" + +SPANNER_STRINGS_SELECT = f"SELECT id, payload FROM {SPANNER_STRINGS_TABLE} ORDER BY id" + +SCHEMA_TYPE_NUMPY_ROW_COUNT = 5_000 + +SCHEMA_TYPE_NUMPY_FIRST_FIELD_4 = 4 + +_SCHEMA_TYPE_NUMPY_PAYLOAD: list[dict[str, int]] | None = None + +_SCHEMA_TYPE_NUMPY_ROW_TYPE: type[Any] | None = None + +_SCHEMA_TYPE_NUMPY_VECTOR_TYPE: type[Any] | None = None + +ORACLE_LOB_TABLES = {"1k": "SQLSPEC_LOB_1K", "100k": "SQLSPEC_LOB_100K"} + +ORACLE_JSON_TABLE = "SQLSPEC_JSON_BENCH" + + +class BenchmarkUnavailableError(RuntimeError): + """Signal that an optional benchmark dependency or service is unavailable.""" + @click.command() @click.option( @@ -275,7 +625,6 @@ def main( ROWS_TO_INSERT = rows POOL_SIZE = pool_size - # If --profile-scenario is given, implicitly enable profiling if profile_scenario: profile = True @@ -305,7 +654,7 @@ def main( else: click.echo("No benchmark results to display.") if json_output: - _write_json_results(results, json_output, rows=rows, pool_size=pool_size, iterations=iterations) + _write_json_results(results, json_output, rows=rows, pool_size=pool_size, iterations=iterations, warmup=warmup) click.secho(f"Results written to {json_output}", fg="green") if errors: for err in errors: @@ -320,138 +669,49 @@ def main( click.echo(f"Benchmarks complete for drivers: {', '.join(driver)}") -def _new_benchmark_loop() -> asyncio.AbstractEventLoop: - loop = asyncio.new_event_loop() - asyncio.set_event_loop(loop) - return loop +def run_benchmark( + driver: str, errors: list[str], *, iterations: int = DEFAULT_BENCH_ITERATIONS, warmup: int = DEFAULT_BENCH_WARMUP +) -> list[dict[str, Any]]: + """Run all benchmark scenarios for a driver. + Args: + driver: The database driver name (e.g., "sqlite", "asyncpg") + errors: List to append error messages to + iterations: Number of timed iterations per scenario + warmup: Number of warmup iterations (not timed) -def _close_benchmark_loop(loop: asyncio.AbstractEventLoop) -> None: - try: - loop.run_until_complete(loop.shutdown_asyncgens()) - if hasattr(loop, "shutdown_default_executor"): - loop.run_until_complete(loop.shutdown_default_executor()) - finally: - asyncio.set_event_loop(None) - loop.close() + Returns: + List of benchmark result dictionaries + """ + results: list[dict[str, Any]] = [] + for scenario in CORE_SCENARIOS: + for lib in CORE_LIBRARIES: + func = SCENARIO_REGISTRY.get((lib, driver, scenario)) + if func is None: + errors.append(f"No implementation for library={lib}, driver={driver}, scenario={scenario}") + continue -def _run_scenario_once(func: Any, loop: asyncio.AbstractEventLoop | None) -> None: - if loop is not None: - loop.run_until_complete(func()) - else: - func() + is_async = inspect.iscoroutinefunction(func) + try: + times = _run_benchmark_iterations(func, is_async=is_async, iterations=iterations, warmup=warmup) + stats = _summarize_times(times) + label = _benchmark_library_label(lib) + results.append({ + "driver": driver, + "library": label, + "library_key": lib, + "scenario": scenario, + "times": times, + **stats, + }) + except BenchmarkUnavailableError: + continue + except Exception as exc: + errors.append(f"{lib}/{driver}/{scenario}: {exc}") -def _run_benchmark_iterations( - func: Any, *, is_async: bool, iterations: int, warmup: int, profiler: cProfile.Profile | None = None -) -> list[float]: - loop = _new_benchmark_loop() if is_async else None - try: - for _ in range(warmup): - _run_scenario_once(func, loop) - - times: list[float] = [] - for _ in range(iterations): - gc.collect() - gc_was_enabled = gc.isenabled() - if gc_was_enabled: - gc.disable() - start = time.perf_counter() - try: - if profiler is not None: - profiler.enable() - _run_scenario_once(func, loop) - finally: - if profiler is not None: - profiler.disable() - if gc_was_enabled: - gc.enable() - times.append(time.perf_counter() - start) - return times - finally: - if loop is not None: - _close_benchmark_loop(loop) - - -def _summarize_times(times: list[float]) -> dict[str, float | bool]: - median_time = statistics.median(times) - stddev = statistics.stdev(times) if len(times) > 1 else 0.0 - quartiles = statistics.quantiles(times, n=4, method="inclusive") if len(times) > 1 else (0.0, 0.0, 0.0) - iqr = quartiles[2] - quartiles[0] - noise_ratio = stddev / median_time if median_time > 0 else 0.0 - return { - "time": median_time, - "stddev": stddev, - "iqr": iqr, - "noise_ratio": noise_ratio, - "noisy": noise_ratio > NOISY_STDDEV_RATIO, - } - - -def _benchmark_library_label(library: str) -> str: - """Return a display label for a benchmark library key.""" - if library == "sqlspec": - return SQLSPEC_LABEL - if library == "sqlspec_fetch_lobs_true": - return f"{SQLSPEC_LABEL} fetch_lobs=True" - if library == "sqlspec_async": - return f"{SQLSPEC_LABEL} async" - if library == "sqlspec_async_fetch_lobs_true": - return f"{SQLSPEC_LABEL} async fetch_lobs=True" - return library - - -def _driver_has_core_scenarios(driver: str) -> bool: - """Return True when a driver has any core benchmark scenario registered.""" - return any( - (library, driver, scenario) in SCENARIO_REGISTRY for scenario in CORE_SCENARIOS for library in CORE_LIBRARIES - ) - - -def run_benchmark( - driver: str, errors: list[str], *, iterations: int = DEFAULT_BENCH_ITERATIONS, warmup: int = DEFAULT_BENCH_WARMUP -) -> list[dict[str, Any]]: - """Run all benchmark scenarios for a driver. - - Args: - driver: The database driver name (e.g., "sqlite", "asyncpg") - errors: List to append error messages to - iterations: Number of timed iterations per scenario - warmup: Number of warmup iterations (not timed) - - Returns: - List of benchmark result dictionaries - """ - results: list[dict[str, Any]] = [] - - for scenario in CORE_SCENARIOS: - for lib in CORE_LIBRARIES: - func = SCENARIO_REGISTRY.get((lib, driver, scenario)) - if func is None: - errors.append(f"No implementation for library={lib}, driver={driver}, scenario={scenario}") - continue - - is_async = inspect.iscoroutinefunction(func) - - try: - times = _run_benchmark_iterations(func, is_async=is_async, iterations=iterations, warmup=warmup) - stats = _summarize_times(times) - label = _benchmark_library_label(lib) - results.append({ - "driver": driver, - "library": label, - "library_key": lib, - "scenario": scenario, - "times": times, - **stats, - }) - except BenchmarkUnavailableError: - continue - except Exception as exc: - errors.append(f"{lib}/{driver}/{scenario}: {exc}") - - return results + return results def run_benchmark_profiled( @@ -485,7 +745,6 @@ def run_benchmark_profiled( for scenario in CORE_SCENARIOS: for lib in CORE_LIBRARIES: - # If profiling a specific scenario, skip others if profile_scenario and scenario != profile_scenario: continue @@ -512,12 +771,10 @@ def run_benchmark_profiled( **summary, }) - # Save profile data prof_path = profiles_dir / f"{prof_name}.prof" profiler.dump_stats(str(prof_path)) click.echo(f" Profile saved: {prof_path}") - # Print top 20 summary console.print(f"\n [bold cyan]Profile summary: {prof_name}[/bold cyan]") profile_stats = pstats.Stats(profiler) profile_stats.strip_dirs() @@ -581,66 +838,6 @@ def run_extended_benchmark( return results -def _write_json_results( - results: list[dict[str, Any]], output_path: str, *, rows: int, pool_size: int, iterations: int -) -> None: - """Write benchmark results to a JSON file. - - Args: - results: List of benchmark result dictionaries - output_path: Path to write the JSON file - rows: Number of rows used in the benchmark - pool_size: Pool size used in the benchmark - iterations: Number of timed iterations per scenario - """ - output = { - "metadata": { - "rows": rows, - "pool_size": pool_size, - "iterations": iterations, - "mypyc_compiled": _is_compiled(), - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"), - }, - "results": [ - { - "driver": r["driver"], - "library": r["library"], - "library_key": r.get("library_key", r["library"]), - "scenario": r["scenario"], - "time": r["time"], - "times": r.get("times", [r["time"]]), - "stddev": r.get("stddev", 0.0), - "iqr": r.get("iqr", 0.0), - "noise_ratio": r.get("noise_ratio", 0.0), - "noisy": r.get("noisy", False), - } - for r in results - ], - } - with Path(output_path).open("w") as f: - json.dump(output, f, indent=2) - - -# --- Scenario helpers and registry --- -# SQLite implementations -# ------------------------------ - -CREATE_TEST_TABLE = "CREATE TABLE test (value TEXT);" -DROP_TEST_TABLE = "DROP TABLE IF EXISTS test;" -INSERT_TEST_VALUE = "INSERT INTO test (value) VALUES (?);" -INSERT_TEST_VALUE_ASYNCPG = "INSERT INTO test (value) VALUES ($1);" -SELECT_TEST_VALUES = "SELECT * FROM test;" -INSERT_TEST_VALUE_SQLA = "INSERT INTO test (value) VALUES (:value);" - - -def _optimize_raw_sqlite(conn: sqlite3.Connection) -> None: - """Apply same PRAGMAs that sqlspec applies for fair comparison.""" - conn.execute("PRAGMA journal_mode = WAL") - conn.execute("PRAGMA synchronous = NORMAL") - conn.execute("PRAGMA busy_timeout = 5000") - conn.execute("PRAGMA foreign_keys = ON") - - def raw_sqlite_initialization() -> None: with tempfile.NamedTemporaryFile(suffix=".db") as tmp: conn = sqlite3.connect(tmp.name) @@ -654,7 +851,6 @@ def raw_sqlite_write_heavy() -> None: conn = sqlite3.connect(tmp.name) _optimize_raw_sqlite(conn) conn.execute(CREATE_TEST_TABLE) - # Use executemany for fair comparison data = [(f"value_{i}",) for i in range(ROWS_TO_INSERT)] conn.executemany(INSERT_TEST_VALUE, data) conn.commit() @@ -689,7 +885,6 @@ def sqlspec_sqlite_write_heavy() -> None: config = SqliteConfig(connection_config={"database": tmp.name}) with spec.provide_session(config) as session: session.execute(CREATE_TEST_TABLE) - # Use execute_many for bulk inserts data: Sequence[tuple[str]] = [(f"value_{i}",) for i in range(ROWS_TO_INSERT)] session.execute_many(INSERT_TEST_VALUE, data) @@ -706,16 +901,6 @@ def sqlspec_sqlite_read_heavy() -> None: assert len(rows) == ROWS_TO_INSERT -def _get_sqlalchemy() -> tuple[Any, Any]: - """Import SQLAlchemy lazily.""" - try: - from sqlalchemy import create_engine, text - except ImportError: - return None, None - else: - return create_engine, text - - def sqlalchemy_sqlite_initialization() -> None: create_engine, text = _get_sqlalchemy() if create_engine is None: @@ -735,7 +920,6 @@ def sqlalchemy_sqlite_write_heavy() -> None: engine = create_engine(f"sqlite:///{tmp.name}") with engine.connect() as conn: conn.execute(text(CREATE_TEST_TABLE)) - # Use insert with bindparams for fair bulk comparison data = [{"value": f"value_{i}"} for i in range(ROWS_TO_INSERT)] conn.execute(text(INSERT_TEST_VALUE_SQLA), data) conn.commit() @@ -757,29 +941,13 @@ def sqlalchemy_sqlite_read_heavy() -> None: assert len(rows) == ROWS_TO_INSERT -# DuckDB implementations -# DuckDB is sync like sqlite, but uses its own driver -# ------------------------------ - - -def _get_duckdb() -> Any: - """Import duckdb lazily.""" - try: - import duckdb - except ImportError: - return None - else: - return duckdb - - def raw_duckdb_initialization() -> None: duckdb = _get_duckdb() if duckdb is None: return - # DuckDB needs to create the file itself - use temp name then delete with tempfile.NamedTemporaryFile(suffix=".duckdb", delete=False) as tmp: tmp_path = Path(tmp.name) - tmp_path.unlink() # Delete so DuckDB can create fresh + tmp_path.unlink() try: conn = duckdb.connect(str(tmp_path)) conn.execute(CREATE_TEST_TABLE) @@ -993,72 +1161,17 @@ def sqlspec_duckdb_bulk() -> None: tmp_path.unlink() -def _get_adbc_dbapi() -> Any: - """Import the ADBC DB-API lazily.""" - try: - from adbc_driver_manager import dbapi - except ImportError: - return None - else: - return dbapi - - -def _get_adbc_config() -> Any: - """Import AdbcConfig lazily.""" - try: - from sqlspec.adapters.adbc import AdbcConfig - except ImportError: - return None - else: - return AdbcConfig - - -def _adbc_benchmark_connection_config() -> dict[str, Any] | None: - """Build an ADBC benchmark connection from service-provided environment values.""" - uri = os.environ.get("SQLSPEC_BENCH_ADBC_URI") - driver_name = os.environ.get("SQLSPEC_BENCH_ADBC_DRIVER_NAME") - if not uri or not driver_name: - return None - config: dict[str, Any] = {"uri": uri, "driver_name": driver_name} - for env_name, config_name in ( - ("SQLSPEC_BENCH_ADBC_USERNAME", "username"), - ("SQLSPEC_BENCH_ADBC_PASSWORD", "password"), - ("SQLSPEC_BENCH_ADBC_GIZMOSQL_BACKEND", "gizmosql_backend"), - ): - value = os.environ.get(env_name) - if value: - config[config_name] = value - if os.environ.get("SQLSPEC_BENCH_ADBC_TLS_SKIP_VERIFY") == "1": - config["tls_skip_verify"] = True - return config - - -def _adbc_benchmark_rows() -> list[tuple[int, str]]: - """Return the stable row payload used by the ADBC benchmark.""" - return [(index, f"value_{index}") for index in range(BENCHMARK_ROWS)] - - -def _connect_adbc(dbapi: Any, config: dict[str, Any]) -> Any: - driver_name = config["driver_name"] - if driver_name in {"adbc_driver_sqlite", "adbc_driver_duckdb"}: - module = importlib.import_module(f"{driver_name}.dbapi") - return module.connect(uri=config["uri"], autocommit=True) - db_kwargs = { - key: config[key] for key in ("username", "password", "gizmosql_backend", "tls_skip_verify") if key in config - } - return dbapi.connect(driver=driver_name, uri=config["uri"], db_kwargs=db_kwargs, autocommit=True) - - def raw_adbc_rows() -> None: """Fetch rows through the native ADBC DB-API path.""" dbapi = _get_adbc_dbapi() config = _adbc_benchmark_connection_config() if dbapi is None or config is None: _benchmark_unavailable() + insert_sql = ADBC_ROWS_INSERT_PG if "postgres" in str(config.get("driver_name", "")) else ADBC_ROWS_INSERT with _connect_adbc(dbapi, config) as connection, connection.cursor() as cursor: cursor.execute(ADBC_ROWS_CREATE) cursor.execute(ADBC_ROWS_TRUNCATE) - cursor.executemany(ADBC_ROWS_INSERT, _adbc_benchmark_rows()) + cursor.executemany(insert_sql, _adbc_benchmark_rows()) cursor.execute(ADBC_ROWS_SELECT) rows = cursor.fetchall() assert len(rows) == BENCHMARK_ROWS @@ -1082,60 +1195,13 @@ def sqlspec_adbc_rows() -> None: config.close_pool() -def _get_mysql_connector() -> Any: - """Import mysql-connector lazily.""" - try: - import mysql.connector - except ImportError: - return None - else: - return mysql.connector - - -def _get_mysql_connector_config() -> Any: - """Import MysqlConnectorSyncConfig lazily.""" - try: - from sqlspec.adapters.mysqlconnector import MysqlConnectorSyncConfig - except ImportError: - return None - else: - return MysqlConnectorSyncConfig - - -def _mysql_benchmark_connection_config() -> dict[str, Any] | None: - """Build a MySQL benchmark connection from service-provided environment values.""" - required: dict[str, Any] = { - "host": os.environ.get("SQLSPEC_BENCH_MYSQL_HOST"), - "port": os.environ.get("SQLSPEC_BENCH_MYSQL_PORT"), - "user": os.environ.get("SQLSPEC_BENCH_MYSQL_USER"), - "password": os.environ.get("SQLSPEC_BENCH_MYSQL_PASSWORD"), - "database": os.environ.get("SQLSPEC_BENCH_MYSQL_DATABASE"), - } - if any(value is None for value in required.values()): - return None - required["port"] = int(required["port"]) - required["use_pure"] = True - required["autocommit"] = True - return required - - -def _json_benchmark_rows() -> list[tuple[int, str]]: - """Return the stable JSON payload used by the MySQL benchmark.""" - return [(index, json.dumps({"index": index, "values": [index, index + 1]})) for index in range(BENCHMARK_ROWS)] - - -def _json_row_payload(row: Any) -> Any: - payload = row.get("payload") if isinstance(row, dict) else row[1] - return json.loads(payload) if isinstance(payload, str) else payload - - -def raw_mysqlconnector_json_rows() -> None: - """Fetch JSON rows through the native MySQL connector.""" - connector = _get_mysql_connector() - connection_config = _mysql_benchmark_connection_config() - if connector is None or connection_config is None: - _benchmark_unavailable() - connection = connector.connect(**connection_config) +def raw_mysqlconnector_json_rows() -> None: + """Fetch JSON rows through the native MySQL connector.""" + connector = _get_mysql_connector() + connection_config = _mysql_benchmark_connection_config() + if connector is None or connection_config is None: + _benchmark_unavailable() + connection = connector.connect(**connection_config) try: with connection.cursor() as cursor: cursor.execute(MYSQL_JSON_CREATE) @@ -1169,19 +1235,6 @@ def sqlspec_mysqlconnector_json_rows() -> None: assert _json_row_payload(rows[0])["values"] == [0, 1] -def _spanner_benchmark_config() -> dict[str, Any] | None: - """Build a Spanner benchmark connection from service-provided environment values.""" - required: dict[str, Any] = { - "project": os.environ.get("SQLSPEC_BENCH_SPANNER_PROJECT"), - "instance_id": os.environ.get("SQLSPEC_BENCH_SPANNER_INSTANCE_ID"), - "database_id": os.environ.get("SQLSPEC_BENCH_SPANNER_DATABASE_ID"), - "api_endpoint": os.environ.get("SQLSPEC_BENCH_SPANNER_API_ENDPOINT"), - } - if any(value is None for value in required.values()): - return None - return required - - def raw_spanner_strings() -> None: """Fetch string rows through the native Google Cloud Spanner client.""" try: @@ -1232,17 +1285,6 @@ def sqlspec_spanner_strings() -> None: assert len(rows) == BENCHMARK_ROWS -def _get_duckdb_engine() -> tuple[Any, Any]: - """Import SQLAlchemy with duckdb_engine lazily.""" - try: - importlib.import_module("duckdb_engine") - from sqlalchemy import create_engine, text - except ImportError: - return None, None - else: - return create_engine, text - - def sqlalchemy_duckdb_initialization() -> None: create_engine, text = _get_duckdb_engine() if create_engine is None: @@ -1344,11 +1386,6 @@ def sqlalchemy_duckdb_repeated_queries() -> None: tmp_path.unlink() -# Iterative insert scenarios - tests per-call overhead -# This is what euri10's original benchmark measured for sqlspec -# but not for raw/sqlalchemy (which used executemany) - - def raw_sqlite_iterative_inserts() -> None: """Individual inserts in a loop - shows per-call overhead.""" with tempfile.NamedTemporaryFile(suffix=".db") as tmp: @@ -1386,13 +1423,6 @@ def sqlalchemy_sqlite_iterative_inserts() -> None: conn.commit() -# Query cache scenarios - tests repeated single-row operations -# These stress the query preparation/caching path -SELECT_BY_VALUE = "SELECT * FROM test WHERE value = ?;" -SELECT_BY_VALUE_ASYNCPG = "SELECT * FROM test WHERE value = $1;" -SELECT_BY_VALUE_SQLA = "SELECT * FROM test WHERE value = :value;" - - def raw_sqlite_repeated_queries() -> None: """Repeated single-row queries - tests query preparation overhead.""" with tempfile.NamedTemporaryFile(suffix=".db") as tmp: @@ -1402,7 +1432,6 @@ def raw_sqlite_repeated_queries() -> None: data = [(f"value_{i}",) for i in range(ROWS_TO_INSERT)] conn.executemany(INSERT_TEST_VALUE, data) conn.commit() - # Query same rows repeatedly with different params for i in range(ROWS_TO_INSERT): cursor = conn.execute(SELECT_BY_VALUE, (f"value_{i % 100}",)) cursor.fetchone() @@ -1418,8 +1447,6 @@ def sqlspec_sqlite_repeated_queries() -> None: session.execute(CREATE_TEST_TABLE) data: Sequence[tuple[str]] = [(f"value_{i}",) for i in range(ROWS_TO_INSERT)] session.execute_many(INSERT_TEST_VALUE, data) - # Query same rows repeatedly with different params - # This should hit the query cache after the first few iterations for i in range(ROWS_TO_INSERT): session.fetch_one_or_none(SELECT_BY_VALUE, (f"value_{i % 100}",)) @@ -1436,53 +1463,11 @@ def sqlalchemy_sqlite_repeated_queries() -> None: data = [{"value": f"value_{i}"} for i in range(ROWS_TO_INSERT)] conn.execute(text(INSERT_TEST_VALUE_SQLA), data) conn.commit() - # Query same rows repeatedly with different params for i in range(ROWS_TO_INSERT): result = conn.execute(text(SELECT_BY_VALUE_SQLA), {"value": f"value_{i % 100}"}) result.fetchone() -# Aiosqlite implementations -# These test async sqlite performance - - -def _check_pool_leak(pool: Any, scenario_name: str) -> None: - """Check for connection leaks in a pool. - - Args: - pool: Connection pool with size() and checked_out() methods - scenario_name: Name of the scenario for error reporting - """ - if pool is None: - return - - with suppress(AttributeError, TypeError): - total = pool.size() - checked_out = pool.checked_out() - if checked_out > 0: - _leaked_pools.append(f"{scenario_name}: {checked_out}/{total} connections leaked") - - -def _get_aiosqlite() -> Any: - """Import aiosqlite lazily.""" - try: - import aiosqlite - except ImportError: - return None - else: - return aiosqlite - - -def _get_aiosqlite_config() -> Any: - """Import AiosqliteConfig lazily.""" - try: - from sqlspec.adapters.aiosqlite import AiosqliteConfig - except ImportError: - return None - else: - return AiosqliteConfig - - async def raw_aiosqlite_initialization() -> None: aiosqlite = _get_aiosqlite() if aiosqlite is None: @@ -1576,7 +1561,6 @@ async def sqlspec_aiosqlite_initialization() -> None: aiosqlite_config_type = _get_aiosqlite_config() if aiosqlite_config_type is None: return - # Use delete=False so we control when the file is deleted (after pool close) with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: tmp_path = Path(tmp.name) try: @@ -1585,7 +1569,6 @@ async def sqlspec_aiosqlite_initialization() -> None: async with spec.provide_session(config) as session: await session.execute(DROP_TEST_TABLE) await session.execute(CREATE_TEST_TABLE) - # Properly close the pool to release all connections _check_pool_leak(config.connection_instance, "aiosqlite/initialization") await config.close_pool() finally: @@ -1809,67 +1792,6 @@ async def sqlalchemy_aiosqlite_repeated_queries() -> None: await anyio.Path(tmp_path).unlink() -# Asyncpg implementations -# These require asyncpg and optionally SQLAlchemy[asyncio] to be installed - - -def _get_asyncpg() -> Any: - """Import asyncpg lazily.""" - try: - from asyncpg import connect - except ImportError: - return None - else: - return connect - - -def _get_asyncpg_config() -> Any: - """Import AsyncpgConfig lazily.""" - try: - from sqlspec.adapters.asyncpg import AsyncpgConfig - except ImportError: - return None - else: - return AsyncpgConfig - - -def _get_async_sqlalchemy() -> tuple[Any, Any]: - """Import async SQLAlchemy lazily.""" - try: - from sqlalchemy import text - from sqlalchemy.ext.asyncio import create_async_engine - except ImportError: - return None, None - else: - return create_async_engine, text - - -def _postgres_benchmark_dsn() -> str: - dsn = os.environ.get(POSTGRES_DSN_ENV) - if not dsn: - _benchmark_unavailable() - return dsn - - -def _cockroach_benchmark_dsn() -> str: - dsn = os.environ.get(COCKROACH_DSN_ENV) - if not dsn: - _benchmark_unavailable() - return dsn - - -def _asyncpg_connect_kwargs(dsn: str) -> dict[str, Any]: - """Adapt a service DSN to asyncpg's keyword set.""" - parsed = urlsplit(dsn) - query = dict(parse_qsl(parsed.query, keep_blank_values=True)) - sslmode = query.pop("sslmode", None) - clean_dsn = urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment)) - kwargs: dict[str, Any] = {"dsn": clean_dsn} - if sslmode == "disable": - kwargs["ssl"] = False - return kwargs - - async def raw_asyncpg_initialization() -> None: connect = _get_asyncpg() if connect is None: @@ -1887,7 +1809,6 @@ async def raw_asyncpg_write_heavy() -> None: conn = await connect(**_asyncpg_connect_kwargs(_postgres_benchmark_dsn())) await conn.execute(DROP_TEST_TABLE) await conn.execute(CREATE_TEST_TABLE) - # Use executemany for fair comparison data = [(f"value_{i}",) for i in range(ROWS_TO_INSERT)] await conn.executemany(INSERT_TEST_VALUE_ASYNCPG, data) await conn.close() @@ -2082,180 +2003,36 @@ async def sqlalchemy_asyncpg_repeated_queries() -> None: await engine.dispose() -SERVICE_ROWS_CREATE = "CREATE TABLE IF NOT EXISTS sqlspec_bench_rows (id INTEGER PRIMARY KEY, payload TEXT)" -SERVICE_ROWS_DROP = "DROP TABLE IF EXISTS sqlspec_bench_rows" -SERVICE_ROWS_INSERT_PG = "INSERT INTO sqlspec_bench_rows (id, payload) VALUES (%s, %s)" -SERVICE_ROWS_INSERT_ASYNCPG = "INSERT INTO sqlspec_bench_rows (id, payload) VALUES ($1, $2)" -SERVICE_ROWS_SELECT = "SELECT id, payload FROM sqlspec_bench_rows ORDER BY id" +def raw_psycopg_sync_rows() -> None: + _run_raw_psycopg_sync_rows(_postgres_benchmark_dsn(), cockroach=False) -def _service_benchmark_rows() -> list[tuple[int, str]]: - return [(index, f"value_{index}") for index in range(BENCHMARK_ROWS)] +def sqlspec_psycopg_sync_rows() -> None: + _run_sqlspec_sync_service_rows(_postgres_benchmark_dsn(), driver="psycopg_sync") -def _get_psycopg() -> Any: - try: - import psycopg - except ImportError: - return None - else: - return psycopg +async def raw_psycopg_async_rows() -> None: + await _run_raw_psycopg_async_rows(_postgres_benchmark_dsn()) -def _run_raw_psycopg_sync_rows(dsn: str, *, cockroach: bool) -> None: - psycopg = _get_psycopg() - if psycopg is None: - _benchmark_unavailable() - if cockroach: - from psycopg import crdb +async def sqlspec_psycopg_async_rows() -> None: + await _run_sqlspec_async_service_rows(_postgres_benchmark_dsn(), driver="psycopg_async") - connect = crdb.connect - else: - connect = psycopg.connect - with connect(dsn) as connection: - with connection.cursor() as cursor: - cursor.execute(SERVICE_ROWS_DROP) - cursor.execute(SERVICE_ROWS_CREATE) - cursor.executemany(SERVICE_ROWS_INSERT_PG, _service_benchmark_rows()) - cursor.execute(SERVICE_ROWS_SELECT) - rows = cursor.fetchall() - connection.commit() - assert len(rows) == BENCHMARK_ROWS +async def raw_asyncpg_rows() -> None: + await _run_raw_asyncpg_rows(_postgres_benchmark_dsn()) -def _get_sync_service_config(driver: str) -> Any: - try: - if driver == "psycopg_sync": - from sqlspec.adapters.psycopg import PsycopgSyncConfig - return PsycopgSyncConfig - from sqlspec.adapters.cockroach_psycopg import CockroachPsycopgSyncConfig - except ImportError: - return None - else: - return CockroachPsycopgSyncConfig +async def sqlspec_asyncpg_rows() -> None: + await _run_sqlspec_async_service_rows(_postgres_benchmark_dsn(), driver="asyncpg") -def _run_sqlspec_sync_service_rows(dsn: str, *, driver: str) -> None: - config_class = _get_sync_service_config(driver) - if config_class is None: - _benchmark_unavailable() - config = config_class(connection_config={"conninfo": dsn, "min_size": 1, "max_size": max(1, POOL_SIZE)}) - try: - spec = SQLSpec() - with spec.provide_session(config) as session: - session.execute(SERVICE_ROWS_DROP) - session.execute(SERVICE_ROWS_CREATE) - session.execute_many(SERVICE_ROWS_INSERT_PG, _service_benchmark_rows()) - rows = session.fetch(SERVICE_ROWS_SELECT) - assert len(rows) == BENCHMARK_ROWS - finally: - config.close_pool() +def raw_cockroach_psycopg_sync_rows() -> None: + _run_raw_psycopg_sync_rows(_cockroach_benchmark_dsn(), cockroach=True) -async def _run_raw_psycopg_async_rows(dsn: str) -> None: - psycopg = _get_psycopg() - if psycopg is None: - _benchmark_unavailable() - connection = await psycopg.AsyncConnection.connect(dsn) - try: - async with connection.cursor() as cursor: - await cursor.execute(SERVICE_ROWS_DROP) - await cursor.execute(SERVICE_ROWS_CREATE) - await cursor.executemany(SERVICE_ROWS_INSERT_PG, _service_benchmark_rows()) - await cursor.execute(SERVICE_ROWS_SELECT) - rows = await cursor.fetchall() - await connection.commit() - finally: - await connection.close() - assert len(rows) == BENCHMARK_ROWS - - -async def _run_raw_asyncpg_rows(dsn: str) -> None: - connect = _get_asyncpg() - if connect is None: - _benchmark_unavailable() - connection = await connect(**_asyncpg_connect_kwargs(dsn)) - try: - await connection.execute(SERVICE_ROWS_DROP) - await connection.execute(SERVICE_ROWS_CREATE) - await connection.executemany(SERVICE_ROWS_INSERT_ASYNCPG, _service_benchmark_rows()) - rows = await connection.fetch(SERVICE_ROWS_SELECT) - finally: - await connection.close() - assert len(rows) == BENCHMARK_ROWS - - -def _get_async_service_config(driver: str) -> Any: - try: - if driver in {"psycopg_async", "cockroach_psycopg_async"}: - if driver == "psycopg_async": - from sqlspec.adapters.psycopg import PsycopgAsyncConfig - - return PsycopgAsyncConfig - from sqlspec.adapters.cockroach_psycopg import CockroachPsycopgAsyncConfig - - return CockroachPsycopgAsyncConfig - if driver == "asyncpg": - from sqlspec.adapters.asyncpg import AsyncpgConfig - - return AsyncpgConfig - from sqlspec.adapters.cockroach_asyncpg import CockroachAsyncpgConfig - except ImportError: - return None - else: - return CockroachAsyncpgConfig - - -async def _run_sqlspec_async_service_rows(dsn: str, *, driver: str) -> None: - config_class = _get_async_service_config(driver) - if config_class is None: - _benchmark_unavailable() - connection_key = "dsn" if "asyncpg" in driver else "conninfo" - config = config_class(connection_config={connection_key: dsn, "min_size": 1, "max_size": max(1, POOL_SIZE)}) - try: - spec = SQLSpec() - async with spec.provide_session(config) as session: - await session.execute(SERVICE_ROWS_DROP) - await session.execute(SERVICE_ROWS_CREATE) - insert_sql = SERVICE_ROWS_INSERT_ASYNCPG if "asyncpg" in driver else SERVICE_ROWS_INSERT_PG - await session.execute_many(insert_sql, _service_benchmark_rows()) - rows = await session.fetch(SERVICE_ROWS_SELECT) - assert len(rows) == BENCHMARK_ROWS - finally: - await config.close_pool() - - -def raw_psycopg_sync_rows() -> None: - _run_raw_psycopg_sync_rows(_postgres_benchmark_dsn(), cockroach=False) - - -def sqlspec_psycopg_sync_rows() -> None: - _run_sqlspec_sync_service_rows(_postgres_benchmark_dsn(), driver="psycopg_sync") - - -async def raw_psycopg_async_rows() -> None: - await _run_raw_psycopg_async_rows(_postgres_benchmark_dsn()) - - -async def sqlspec_psycopg_async_rows() -> None: - await _run_sqlspec_async_service_rows(_postgres_benchmark_dsn(), driver="psycopg_async") - - -async def raw_asyncpg_rows() -> None: - await _run_raw_asyncpg_rows(_postgres_benchmark_dsn()) - - -async def sqlspec_asyncpg_rows() -> None: - await _run_sqlspec_async_service_rows(_postgres_benchmark_dsn(), driver="asyncpg") - - -def raw_cockroach_psycopg_sync_rows() -> None: - _run_raw_psycopg_sync_rows(_cockroach_benchmark_dsn(), cockroach=True) - - -def sqlspec_cockroach_psycopg_sync_rows() -> None: - _run_sqlspec_sync_service_rows(_cockroach_benchmark_dsn(), driver="cockroach_psycopg_sync") +def sqlspec_cockroach_psycopg_sync_rows() -> None: + _run_sqlspec_sync_service_rows(_cockroach_benchmark_dsn(), driver="cockroach_psycopg_sync") async def raw_cockroach_psycopg_async_rows() -> None: @@ -2274,65 +2051,6 @@ async def sqlspec_cockroach_asyncpg_rows() -> None: await _run_sqlspec_async_service_rows(_cockroach_benchmark_dsn(), driver="cockroach_asyncpg") -# =========================================================================== -# Extended benchmark scenarios -# These exercise sqlspec-specific features (dict key transforms, schema -# mapping, complex parameter types, thin path stress testing). -# =========================================================================== - -# --- SQL constants for extended scenarios --- - -CREATE_WIDE_TABLE = """CREATE TABLE wide_test ( - first_name TEXT, - last_name TEXT, - email_address TEXT, - phone_number TEXT, - street_address TEXT, - postal_code TEXT, - country_name TEXT, - birth_date TEXT, - account_balance TEXT, - is_active TEXT -);""" - -INSERT_WIDE_ROW = ( - "INSERT INTO wide_test " - "(first_name, last_name, email_address, phone_number, street_address, " - "postal_code, country_name, birth_date, account_balance, is_active) " - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?);" -) - -SELECT_WIDE_ALL = "SELECT * FROM wide_test;" - -CREATE_COMPLEX_TABLE = """CREATE TABLE complex_test ( - uuid_val TEXT, - created_at TEXT, - payload TEXT -);""" - -INSERT_COMPLEX_ROW = "INSERT INTO complex_test (uuid_val, created_at, payload) VALUES (?, ?, ?);" - -SELECT_COMPLEX_ALL = "SELECT * FROM complex_test;" - -BENCHMARK_ROWS = 1_000 -DUCKDB_BULK_TABLE = "sqlspec_bench_bulk" -DUCKDB_BULK_CREATE = f"CREATE TABLE {DUCKDB_BULK_TABLE} (id INTEGER, payload VARCHAR)" -DUCKDB_BULK_INSERT = f"INSERT INTO {DUCKDB_BULK_TABLE} (id, payload) VALUES (?, ?)" -DUCKDB_BULK_COUNT = f"SELECT COUNT(*) FROM {DUCKDB_BULK_TABLE}" -ADBC_ROWS_TABLE = "sqlspec_bench_adbc_rows" -ADBC_ROWS_CREATE = f"CREATE TABLE IF NOT EXISTS {ADBC_ROWS_TABLE} (id INTEGER, payload VARCHAR)" -ADBC_ROWS_TRUNCATE = f"DELETE FROM {ADBC_ROWS_TABLE}" -ADBC_ROWS_INSERT = f"INSERT INTO {ADBC_ROWS_TABLE} (id, payload) VALUES (?, ?)" -ADBC_ROWS_SELECT = f"SELECT id, payload FROM {ADBC_ROWS_TABLE} ORDER BY id" -MYSQL_JSON_TABLE = "sqlspec_bench_json_rows" -MYSQL_JSON_CREATE = f"CREATE TABLE IF NOT EXISTS {MYSQL_JSON_TABLE} (id INT PRIMARY KEY, payload JSON NOT NULL)" -MYSQL_JSON_INSERT_RAW = f"INSERT INTO {MYSQL_JSON_TABLE} (id, payload) VALUES (%s, %s)" -MYSQL_JSON_INSERT = f"INSERT INTO {MYSQL_JSON_TABLE} (id, payload) VALUES (?, ?)" -MYSQL_JSON_SELECT = f"SELECT id, payload FROM {MYSQL_JSON_TABLE} ORDER BY id" -SPANNER_STRINGS_TABLE = "sqlspec_bench_strings" -SPANNER_STRINGS_SELECT = f"SELECT id, payload FROM {SPANNER_STRINGS_TABLE} ORDER BY id" - - class WideRowDict(TypedDict): """TypedDict schema matching the wide_test table for schema_mapping benchmark.""" @@ -2348,54 +2066,6 @@ class WideRowDict(TypedDict): is_active: str -SCHEMA_TYPE_NUMPY_ROW_COUNT = 5_000 -SCHEMA_TYPE_NUMPY_FIRST_FIELD_4 = 4 -_SCHEMA_TYPE_NUMPY_PAYLOAD: list[dict[str, int]] | None = None -_SCHEMA_TYPE_NUMPY_ROW_TYPE: type[Any] | None = None -_SCHEMA_TYPE_NUMPY_VECTOR_TYPE: type[Any] | None = None - - -def _schema_type_numpy_payload() -> list[dict[str, int]]: - """Return the stable 5000x5 payload used by the schema_type numpy benchmark.""" - global _SCHEMA_TYPE_NUMPY_PAYLOAD - if _SCHEMA_TYPE_NUMPY_PAYLOAD is None: - _SCHEMA_TYPE_NUMPY_PAYLOAD = [ - {"field_0": index, "field_1": index + 1, "field_2": index + 2, "field_3": index + 3, "field_4": index + 4} - for index in range(SCHEMA_TYPE_NUMPY_ROW_COUNT) - ] - return _SCHEMA_TYPE_NUMPY_PAYLOAD - - -def _schema_type_numpy_row_type() -> type[Any]: - """Return the cached msgspec row type used by the schema_type numpy benchmark.""" - global _SCHEMA_TYPE_NUMPY_ROW_TYPE - if _SCHEMA_TYPE_NUMPY_ROW_TYPE is None: - import msgspec - - class SchemaTypeNumpyRow(msgspec.Struct): - field_0: int - field_1: int - field_2: int - field_3: int - field_4: int - - _SCHEMA_TYPE_NUMPY_ROW_TYPE = SchemaTypeNumpyRow - return _SCHEMA_TYPE_NUMPY_ROW_TYPE - - -def _schema_type_numpy_vector_type() -> type[Any]: - """Return the cached msgspec vector row type used to assert ndarray fallback behavior.""" - global _SCHEMA_TYPE_NUMPY_VECTOR_TYPE - if _SCHEMA_TYPE_NUMPY_VECTOR_TYPE is None: - import msgspec - - class SchemaTypeNumpyVectorRow(msgspec.Struct): - values: list[float] - - _SCHEMA_TYPE_NUMPY_VECTOR_TYPE = SchemaTypeNumpyVectorRow - return _SCHEMA_TYPE_NUMPY_VECTOR_TYPE - - def assert_schema_type_numpy_vector_fallback() -> None: """Assert ndarray -> list[float] fallback still works for msgspec schema conversion.""" import numpy as np @@ -2406,35 +2076,6 @@ def assert_schema_type_numpy_vector_fallback() -> None: assert converted[0].values == [1.0, 2.0] -def _generate_wide_row(i: int) -> tuple[str, ...]: - """Generate a single row tuple for the wide_test table.""" - return ( - f"First{i}", - f"Last{i}", - f"user{i}@example.com", - f"+1-555-{i:04d}", - f"{i} Main Street", - f"{10000 + i}", - "United States", - f"199{i % 10}-{1 + (i % 12):02d}-{1 + (i % 28):02d}", - f"{100.0 + i * 0.5:.2f}", - "true" if i % 2 == 0 else "false", - ) - - -def _generate_complex_row(i: int) -> tuple[str, str, str]: - """Generate a single row tuple for the complex_test table.""" - import uuid as uuid_mod - - uid = str(uuid_mod.UUID(int=i)) - created_at = f"2024-{1 + (i % 12):02d}-{1 + (i % 28):02d}T{i % 24:02d}:{i % 60:02d}:00" - payload = f'{{"key": "value_{i}", "count": {i}, "tags": ["a", "b"]}}' - return (uid, created_at, payload) - - -# --- dict_key_transform scenarios --- - - def raw_sqlite_dict_key_transform() -> None: """SELECT 10K rows with 10 columns, then transform keys to camelCase (raw).""" with tempfile.NamedTemporaryFile(suffix=".db") as tmp: @@ -2444,12 +2085,10 @@ def raw_sqlite_dict_key_transform() -> None: data = [_generate_wide_row(i) for i in range(ROWS_TO_INSERT)] conn.executemany(INSERT_WIDE_ROW, data) conn.commit() - # Raw driver: fetch as tuples; build dicts manually then transform conn.row_factory = sqlite3.Row cursor = conn.execute(SELECT_WIDE_ALL) rows = cursor.fetchall() assert len(rows) == ROWS_TO_INSERT - # No transform for raw - just fetch (raw baseline) conn.close() @@ -2464,17 +2103,12 @@ def sqlspec_sqlite_dict_key_transform() -> None: session.execute_many(INSERT_WIDE_ROW, data) rows = session.fetch(SELECT_WIDE_ALL) assert len(rows) == ROWS_TO_INSERT - # Apply dict key transformation from snake_case to camelCase transformed = [transform_dict_keys(row, camelize) for row in rows] assert len(transformed) == ROWS_TO_INSERT - # Verify transformation worked on first row first = transformed[0] assert "firstName" in first -# --- schema_mapping scenarios --- - - def raw_sqlite_schema_mapping() -> None: """SELECT 10K rows and return as raw tuples (baseline for schema mapping).""" with tempfile.NamedTemporaryFile(suffix=".db") as tmp: @@ -2501,14 +2135,10 @@ def sqlspec_sqlite_schema_mapping() -> None: session.execute_many(INSERT_WIDE_ROW, data) rows = session.fetch(SELECT_WIDE_ALL, schema_type=WideRowDict) assert len(rows) == ROWS_TO_INSERT - # Verify schema mapping worked first = rows[0] assert "first_name" in first -# --- schema_type_numpy scenarios --- - - def raw_sqlite_schema_type_numpy() -> None: """Legacy baseline: pre-walk a 5000x5 payload before msgspec schema conversion.""" row_type = _schema_type_numpy_row_type() @@ -2525,9 +2155,6 @@ def sqlspec_sqlite_schema_type_numpy() -> None: assert rows[0].field_4 == SCHEMA_TYPE_NUMPY_FIRST_FIELD_4 -# --- complex_parameters scenarios --- - - def raw_sqlite_complex_parameters() -> None: """INSERT rows with complex parameter types: UUID, datetime, JSON (raw).""" with tempfile.NamedTemporaryFile(suffix=".db") as tmp: @@ -2537,7 +2164,6 @@ def raw_sqlite_complex_parameters() -> None: data = [_generate_complex_row(i) for i in range(ROWS_TO_INSERT)] conn.executemany(INSERT_COMPLEX_ROW, data) conn.commit() - # Read back to verify cursor = conn.execute(SELECT_COMPLEX_ALL) rows = cursor.fetchall() assert len(rows) == ROWS_TO_INSERT @@ -2553,43 +2179,1516 @@ def sqlspec_sqlite_complex_parameters() -> None: session.execute(CREATE_COMPLEX_TABLE) data: Sequence[tuple[str, str, str]] = [_generate_complex_row(i) for i in range(ROWS_TO_INSERT)] session.execute_many(INSERT_COMPLEX_ROW, data) - # Read back to verify rows = session.fetch(SELECT_COMPLEX_ALL) assert len(rows) == ROWS_TO_INSERT -# --- thin_path_stress scenarios --- +def raw_sqlite_thin_path_stress() -> None: + """100K individual INSERTs to stress-test per-call execution path (raw).""" + stress_count = ROWS_TO_INSERT * 10 + with tempfile.NamedTemporaryFile(suffix=".db") as tmp: + conn = sqlite3.connect(tmp.name) + _optimize_raw_sqlite(conn) + conn.execute(CREATE_TEST_TABLE) + for i in range(stress_count): + conn.execute(INSERT_TEST_VALUE, (f"value_{i}",)) + conn.commit() + conn.close() + + +def sqlspec_sqlite_thin_path_stress() -> None: + """100K individual INSERTs to stress-test the thin execute path (sqlspec).""" + stress_count = ROWS_TO_INSERT * 10 + with tempfile.NamedTemporaryFile(suffix=".db") as tmp: + spec = SQLSpec() + config = SqliteConfig(connection_config={"database": tmp.name}) + with spec.provide_session(config) as session: + session.execute(CREATE_TEST_TABLE) + for i in range(stress_count): + session.execute(INSERT_TEST_VALUE, (f"value_{i}",)) + + +def raw_oracle_lob_fetch_1k() -> None: + """Fetch 100 1 KiB Oracle CLOB rows through raw python-oracledb.""" + _run_raw_oracle_lob_fetch("1k") + + +def raw_oracle_lob_fetch_100k() -> None: + """Fetch 100 100 KiB Oracle CLOB rows through raw python-oracledb.""" + _run_raw_oracle_lob_fetch("100k") + + +def sqlspec_oracle_lob_fetch_1k() -> None: + """Fetch 100 1 KiB Oracle CLOB rows with sqlspec direct LOB fetching.""" + _run_sqlspec_oracle_lob_fetch("1k", fetch_lobs=False) + + +def sqlspec_oracle_lob_fetch_100k() -> None: + """Fetch 100 100 KiB Oracle CLOB rows with sqlspec direct LOB fetching.""" + _run_sqlspec_oracle_lob_fetch("100k", fetch_lobs=False) + + +def sqlspec_oracle_lob_fetch_fetch_lobs_true_1k() -> None: + """Fetch 100 1 KiB Oracle CLOB rows with sqlspec LOB locators enabled.""" + _run_sqlspec_oracle_lob_fetch("1k", fetch_lobs=True) + + +def sqlspec_oracle_lob_fetch_fetch_lobs_true_100k() -> None: + """Fetch 100 100 KiB Oracle CLOB rows with sqlspec LOB locators enabled.""" + _run_sqlspec_oracle_lob_fetch("100k", fetch_lobs=True) + + +async def sqlspec_oracle_lob_fetch_async_1k() -> None: + """Fetch 100 1 KiB Oracle CLOB rows with async sqlspec direct LOB fetching.""" + await _run_sqlspec_oracle_lob_fetch_async("1k", fetch_lobs=False) + + +async def sqlspec_oracle_lob_fetch_async_100k() -> None: + """Fetch 100 100 KiB Oracle CLOB rows with async sqlspec direct LOB fetching.""" + await _run_sqlspec_oracle_lob_fetch_async("100k", fetch_lobs=False) + + +async def sqlspec_oracle_lob_fetch_async_fetch_lobs_true_1k() -> None: + """Fetch 100 1 KiB Oracle CLOB rows with async sqlspec LOB locators enabled.""" + await _run_sqlspec_oracle_lob_fetch_async("1k", fetch_lobs=True) + + +async def sqlspec_oracle_lob_fetch_async_fetch_lobs_true_100k() -> None: + """Fetch 100 100 KiB Oracle CLOB rows with async sqlspec LOB locators enabled.""" + await _run_sqlspec_oracle_lob_fetch_async("100k", fetch_lobs=True) + + +def sqlspec_oracle_native_json_write() -> None: + """Write native Python mappings to an Oracle native JSON column.""" + _run_sqlspec_oracle_json_write(serialized=False) + + +def sqlspec_oracle_serialized_json_write() -> None: + """Write pre-serialized strings to an Oracle native JSON column.""" + _run_sqlspec_oracle_json_write(serialized=True) + + +def sqlspec_oracle_json_read() -> None: + """Read native JSON values from Oracle through SQLSpec.""" + _run_sqlspec_oracle_json_read() + + +def raw_sqlite_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through raw sqlite3.""" + with sqlite3.connect(":memory:") as conn: + for i in range(ROWS_TO_INSERT): + row = conn.execute(SCALAR_PARAM_SELECT_SQL, (i,)).fetchone() + assert row is not None + assert row[0] == i + + +def sqlspec_sqlite_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through SQLSpec SqliteConfig.""" + spec = SQLSpec() + config = SqliteConfig(connection_config={"database": ":memory:"}) + try: + with spec.provide_session(config) as session: + for i in range(ROWS_TO_INSERT): + val = session.select_value(SCALAR_PARAM_SELECT_SQL, i) + assert val == i + finally: + config.close_pool() + + +def raw_sqlite_no_param_select() -> None: + """Execute parameterless SELECT 1 queries through raw sqlite3.""" + with sqlite3.connect(":memory:") as conn: + for _ in range(ROWS_TO_INSERT): + rows = conn.execute(NO_PARAM_SELECT_SQL).fetchall() + assert len(rows) == 1 + + +def sqlspec_sqlite_no_param_select() -> None: + """Execute parameterless SELECT 1 queries through SQLSpec SqliteConfig.""" + spec = SQLSpec() + config = SqliteConfig(connection_config={"database": ":memory:"}) + try: + with spec.provide_session(config) as session: + for _ in range(ROWS_TO_INSERT): + res = session.execute(NO_PARAM_SELECT_SQL) + assert res is not None + finally: + config.close_pool() + + +def raw_sqlite_complex_query() -> None: + """Execute a CTE + 2-join query through raw sqlite3.""" + iterations = min(ROWS_TO_INSERT, HOT_PATH_COMPLEX_ITERATIONS_CAP) + with sqlite3.connect(":memory:") as conn: + for ddl in COMPLEX_SCHEMA_DDL: + conn.execute(ddl) + for i in range(iterations): + conn.execute(COMPLEX_QUERY_SQL, (i,)).fetchall() + + +def sqlspec_sqlite_complex_query() -> None: + """Execute a CTE + 2-join query with scalar positional param through SQLSpec SqliteConfig.""" + iterations = min(ROWS_TO_INSERT, HOT_PATH_COMPLEX_ITERATIONS_CAP) + spec = SQLSpec() + config = SqliteConfig(connection_config={"database": ":memory:"}) + try: + with spec.provide_session(config) as session: + for ddl in COMPLEX_SCHEMA_DDL: + session.execute(ddl) + for i in range(iterations): + session.execute(COMPLEX_QUERY_SQL, i) + finally: + config.close_pool() + + +def raw_sqlite_session_per_request() -> None: + """Execute one query per request on a reused raw sqlite3 connection.""" + iterations = min(ROWS_TO_INSERT, HOT_PATH_COMPLEX_ITERATIONS_CAP) + with tempfile.NamedTemporaryFile(suffix=".db") as tmp: + conn = sqlite3.connect(tmp.name) + _optimize_raw_sqlite(conn) + conn.execute(CREATE_TEST_TABLE) + conn.executemany(INSERT_TEST_VALUE, [(f"value_{i}",) for i in range(100)]) + conn.commit() + for i in range(iterations): + conn.execute(SELECT_BY_VALUE, (f"value_{i % 100}",)).fetchone() + conn.close() + + +def sqlspec_sqlite_session_per_request() -> None: + """Execute one query per provide_session context through SQLSpec SqliteConfig.""" + iterations = min(ROWS_TO_INSERT, HOT_PATH_COMPLEX_ITERATIONS_CAP) + with tempfile.NamedTemporaryFile(suffix=".db") as tmp: + spec = SQLSpec() + config = SqliteConfig(connection_config={"database": tmp.name}) + try: + with spec.provide_session(config) as session: + session.execute(CREATE_TEST_TABLE) + session.execute_many(INSERT_TEST_VALUE, [(f"value_{i}",) for i in range(100)]) + for i in range(iterations): + with spec.provide_session(config) as session: + session.fetch_one_or_none(SELECT_BY_VALUE, (f"value_{i % 100}",)) + finally: + config.close_pool() + + +def raw_sqlite_observability_select() -> None: + """Baseline raw sqlite3 repeated SELECT for observability comparison.""" + with tempfile.NamedTemporaryFile(suffix=".db") as tmp: + conn = sqlite3.connect(tmp.name) + _optimize_raw_sqlite(conn) + conn.execute(CREATE_TEST_TABLE) + conn.executemany(INSERT_TEST_VALUE, [(f"value_{i}",) for i in range(100)]) + conn.commit() + for i in range(ROWS_TO_INSERT): + conn.execute(SELECT_BY_VALUE, (f"value_{i % 100}",)).fetchone() + conn.close() + + +def sqlspec_sqlite_observability_select() -> None: + """Execute repeated SELECT queries with a statement observer attached on SqliteConfig.""" + with tempfile.NamedTemporaryFile(suffix=".db") as tmp: + spec = SQLSpec() + config = SqliteConfig( + connection_config={"database": tmp.name}, + observability_config=ObservabilityConfig(statement_observers=(_noop_statement_observer,)), + ) + try: + with spec.provide_session(config) as session: + session.execute(CREATE_TEST_TABLE) + session.execute_many(INSERT_TEST_VALUE, [(f"value_{i}",) for i in range(100)]) + for i in range(ROWS_TO_INSERT): + session.fetch_one_or_none(SELECT_BY_VALUE, (f"value_{i % 100}",)) + finally: + config.close_pool() + + +async def raw_aiosqlite_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through raw aiosqlite.""" + aiosqlite = _get_aiosqlite() + if aiosqlite is None: + return + async with aiosqlite.connect(":memory:") as conn: + for i in range(ROWS_TO_INSERT): + cursor = await conn.execute(SCALAR_PARAM_SELECT_SQL, (i,)) + row = await cursor.fetchone() + assert row is not None + assert row[0] == i + + +async def sqlspec_aiosqlite_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through SQLSpec AiosqliteConfig.""" + aiosqlite_config_type = _get_aiosqlite_config() + if aiosqlite_config_type is None: + return + spec = SQLSpec() + config = aiosqlite_config_type(connection_config={"database": ":memory:", "pool_size": POOL_SIZE}) + try: + async with spec.provide_session(config) as session: + for i in range(ROWS_TO_INSERT): + val = await session.select_value(SCALAR_PARAM_SELECT_SQL, i) + assert val == i + _check_pool_leak(config.connection_instance, "aiosqlite/scalar_param_select") + await config.close_pool() + finally: + if config.connection_instance is not None: + await config.close_pool() + + +async def raw_aiosqlite_no_param_select() -> None: + """Execute parameterless SELECT 1 queries through raw aiosqlite.""" + aiosqlite = _get_aiosqlite() + if aiosqlite is None: + return + async with aiosqlite.connect(":memory:") as conn: + for _ in range(ROWS_TO_INSERT): + cursor = await conn.execute(NO_PARAM_SELECT_SQL) + rows = await cursor.fetchall() + assert len(rows) == 1 + + +async def sqlspec_aiosqlite_no_param_select() -> None: + """Execute parameterless SELECT 1 queries through SQLSpec AiosqliteConfig.""" + aiosqlite_config_type = _get_aiosqlite_config() + if aiosqlite_config_type is None: + return + spec = SQLSpec() + config = aiosqlite_config_type(connection_config={"database": ":memory:", "pool_size": POOL_SIZE}) + try: + async with spec.provide_session(config) as session: + for _ in range(ROWS_TO_INSERT): + res = await session.execute(NO_PARAM_SELECT_SQL) + assert res is not None + _check_pool_leak(config.connection_instance, "aiosqlite/no_param_select") + await config.close_pool() + finally: + if config.connection_instance is not None: + await config.close_pool() + + +async def raw_aiosqlite_complex_query() -> None: + """Execute a CTE + 2-join query through raw aiosqlite.""" + aiosqlite = _get_aiosqlite() + if aiosqlite is None: + return + iterations = min(ROWS_TO_INSERT, HOT_PATH_COMPLEX_ITERATIONS_CAP) + async with aiosqlite.connect(":memory:") as conn: + for ddl in COMPLEX_SCHEMA_DDL: + await conn.execute(ddl) + await conn.commit() + for i in range(iterations): + cursor = await conn.execute(COMPLEX_QUERY_SQL, (i,)) + await cursor.fetchall() + + +async def sqlspec_aiosqlite_complex_query() -> None: + """Execute a CTE + 2-join query with scalar positional param through SQLSpec AiosqliteConfig.""" + aiosqlite_config_type = _get_aiosqlite_config() + if aiosqlite_config_type is None: + return + iterations = min(ROWS_TO_INSERT, HOT_PATH_COMPLEX_ITERATIONS_CAP) + spec = SQLSpec() + config = aiosqlite_config_type(connection_config={"database": ":memory:", "pool_size": POOL_SIZE}) + try: + async with spec.provide_session(config) as session: + for ddl in COMPLEX_SCHEMA_DDL: + await session.execute(ddl) + for i in range(iterations): + await session.execute(COMPLEX_QUERY_SQL, i) + _check_pool_leak(config.connection_instance, "aiosqlite/complex_query") + await config.close_pool() + finally: + if config.connection_instance is not None: + await config.close_pool() + + +async def raw_aiosqlite_session_per_request() -> None: + """Execute one query per request on a reused raw aiosqlite connection.""" + aiosqlite = _get_aiosqlite() + if aiosqlite is None: + return + iterations = min(ROWS_TO_INSERT, HOT_PATH_COMPLEX_ITERATIONS_CAP) + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + async with aiosqlite.connect(str(tmp_path)) as conn: + await conn.execute(CREATE_TEST_TABLE) + await conn.executemany(INSERT_TEST_VALUE, [(f"value_{i}",) for i in range(100)]) + await conn.commit() + for i in range(iterations): + cursor = await conn.execute(SELECT_BY_VALUE, (f"value_{i % 100}",)) + await cursor.fetchone() + finally: + with suppress(OSError): + await anyio.Path(tmp_path).unlink() + + +async def sqlspec_aiosqlite_session_per_request() -> None: + """Execute one query per provide_session context through SQLSpec AiosqliteConfig.""" + aiosqlite_config_type = _get_aiosqlite_config() + if aiosqlite_config_type is None: + return + iterations = min(ROWS_TO_INSERT, HOT_PATH_COMPLEX_ITERATIONS_CAP) + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + spec = SQLSpec() + config = aiosqlite_config_type(connection_config={"database": str(tmp_path), "pool_size": POOL_SIZE}) + try: + async with spec.provide_session(config) as session: + await session.execute(DROP_TEST_TABLE) + await session.execute(CREATE_TEST_TABLE) + await session.execute_many(INSERT_TEST_VALUE, [(f"value_{i}",) for i in range(100)]) + for i in range(iterations): + async with spec.provide_session(config) as session: + await session.fetch_one_or_none(SELECT_BY_VALUE, (f"value_{i % 100}",)) + _check_pool_leak(config.connection_instance, "aiosqlite/session_per_request") + await config.close_pool() + finally: + if config.connection_instance is not None: + await config.close_pool() + finally: + with suppress(OSError): + await anyio.Path(tmp_path).unlink() + + +async def raw_aiosqlite_observability_select() -> None: + """Baseline raw aiosqlite repeated SELECT for observability comparison.""" + aiosqlite = _get_aiosqlite() + if aiosqlite is None: + return + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + async with aiosqlite.connect(str(tmp_path)) as conn: + await conn.execute(CREATE_TEST_TABLE) + await conn.executemany(INSERT_TEST_VALUE, [(f"value_{i}",) for i in range(100)]) + await conn.commit() + for i in range(ROWS_TO_INSERT): + cursor = await conn.execute(SELECT_BY_VALUE, (f"value_{i % 100}",)) + await cursor.fetchone() + finally: + with suppress(OSError): + await anyio.Path(tmp_path).unlink() + + +async def sqlspec_aiosqlite_observability_select() -> None: + """Execute repeated SELECT queries with a statement observer attached on AiosqliteConfig.""" + aiosqlite_config_type = _get_aiosqlite_config() + if aiosqlite_config_type is None: + return + with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp: + tmp_path = Path(tmp.name) + try: + spec = SQLSpec() + config = aiosqlite_config_type( + connection_config={"database": str(tmp_path), "pool_size": POOL_SIZE}, + observability_config=ObservabilityConfig(statement_observers=(_noop_statement_observer,)), + ) + try: + async with spec.provide_session(config) as session: + await session.execute(DROP_TEST_TABLE) + await session.execute(CREATE_TEST_TABLE) + await session.execute_many(INSERT_TEST_VALUE, [(f"value_{i}",) for i in range(100)]) + for i in range(ROWS_TO_INSERT): + await session.fetch_one_or_none(SELECT_BY_VALUE, (f"value_{i % 100}",)) + _check_pool_leak(config.connection_instance, "aiosqlite/observability_select") + await config.close_pool() + finally: + if config.connection_instance is not None: + await config.close_pool() + finally: + with suppress(OSError): + await anyio.Path(tmp_path).unlink() + + +def raw_duckdb_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through raw duckdb.""" + duckdb = _get_duckdb() + if duckdb is None: + _benchmark_unavailable() + conn = duckdb.connect(":memory:") + try: + for i in range(ROWS_TO_INSERT): + row = conn.execute(SCALAR_PARAM_SELECT_SQL, (i,)).fetchone() + assert row is not None + assert row[0] == i + finally: + conn.close() + + +def sqlspec_duckdb_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through SQLSpec DuckDBConfig.""" + spec = SQLSpec() + config = DuckDBConfig(connection_config={"database": ":memory:"}) + try: + with spec.provide_session(config) as session: + for i in range(ROWS_TO_INSERT): + val = session.select_value(SCALAR_PARAM_SELECT_SQL, i) + assert val == i + finally: + config.close_pool() + + +def raw_duckdb_no_param_select() -> None: + """Execute parameterless SELECT 1 queries through raw duckdb.""" + duckdb = _get_duckdb() + if duckdb is None: + _benchmark_unavailable() + conn = duckdb.connect(":memory:") + try: + for _ in range(ROWS_TO_INSERT): + rows = conn.execute(NO_PARAM_SELECT_SQL).fetchall() + assert len(rows) == 1 + finally: + conn.close() + + +def sqlspec_duckdb_no_param_select() -> None: + """Execute parameterless SELECT 1 queries through SQLSpec DuckDBConfig.""" + spec = SQLSpec() + config = DuckDBConfig(connection_config={"database": ":memory:"}) + try: + with spec.provide_session(config) as session: + for _ in range(ROWS_TO_INSERT): + res = session.execute(NO_PARAM_SELECT_SQL) + assert res is not None + finally: + config.close_pool() + + +def raw_duckdb_complex_query() -> None: + """Execute a CTE + 2-join query through raw duckdb.""" + duckdb = _get_duckdb() + if duckdb is None: + _benchmark_unavailable() + iterations = min(ROWS_TO_INSERT, HOT_PATH_COMPLEX_ITERATIONS_CAP) + conn = duckdb.connect(":memory:") + try: + for ddl in COMPLEX_SCHEMA_DDL: + conn.execute(ddl) + for i in range(iterations): + conn.execute(COMPLEX_QUERY_SQL, (i,)).fetchall() + finally: + conn.close() + + +def sqlspec_duckdb_complex_query() -> None: + """Execute a CTE + 2-join query with scalar positional param through SQLSpec DuckDBConfig.""" + iterations = min(ROWS_TO_INSERT, HOT_PATH_COMPLEX_ITERATIONS_CAP) + spec = SQLSpec() + config = DuckDBConfig(connection_config={"database": ":memory:"}) + try: + with spec.provide_session(config) as session: + for ddl in COMPLEX_SCHEMA_DDL: + session.execute(ddl) + for i in range(iterations): + session.execute(COMPLEX_QUERY_SQL, i) + finally: + config.close_pool() + + +def raw_duckdb_session_per_request() -> None: + """Execute one query per request on a reused raw duckdb connection.""" + duckdb = _get_duckdb() + if duckdb is None: + _benchmark_unavailable() + iterations = min(ROWS_TO_INSERT, HOT_PATH_COMPLEX_ITERATIONS_CAP) + conn = duckdb.connect(":memory:") + try: + conn.execute(CREATE_TEST_TABLE) + conn.executemany(INSERT_TEST_VALUE, [(f"value_{i}",) for i in range(100)]) + for i in range(iterations): + conn.execute(SELECT_BY_VALUE, (f"value_{i % 100}",)).fetchone() + finally: + conn.close() + + +def sqlspec_duckdb_session_per_request() -> None: + """Execute one query per provide_session context through SQLSpec DuckDBConfig.""" + iterations = min(ROWS_TO_INSERT, HOT_PATH_COMPLEX_ITERATIONS_CAP) + spec = SQLSpec() + config = DuckDBConfig(connection_config={"database": ":memory:"}) + try: + with spec.provide_session(config) as session: + session.execute(CREATE_TEST_TABLE) + session.execute_many(INSERT_TEST_VALUE, [(f"value_{i}",) for i in range(100)]) + for i in range(iterations): + with spec.provide_session(config) as session: + session.fetch_one_or_none(SELECT_BY_VALUE, (f"value_{i % 100}",)) + finally: + config.close_pool() + + +def raw_duckdb_observability_select() -> None: + """Baseline raw duckdb repeated SELECT for observability comparison.""" + duckdb = _get_duckdb() + if duckdb is None: + _benchmark_unavailable() + conn = duckdb.connect(":memory:") + try: + conn.execute(CREATE_TEST_TABLE) + conn.executemany(INSERT_TEST_VALUE, [(f"value_{i}",) for i in range(100)]) + for i in range(ROWS_TO_INSERT): + conn.execute(SELECT_BY_VALUE, (f"value_{i % 100}",)).fetchone() + finally: + conn.close() + + +def sqlspec_duckdb_observability_select() -> None: + """Execute repeated SELECT queries with a statement observer attached on DuckDBConfig.""" + spec = SQLSpec() + config = DuckDBConfig( + connection_config={"database": ":memory:"}, + observability_config=ObservabilityConfig(statement_observers=(_noop_statement_observer,)), + ) + try: + with spec.provide_session(config) as session: + session.execute(CREATE_TEST_TABLE) + session.execute_many(INSERT_TEST_VALUE, [(f"value_{i}",) for i in range(100)]) + for i in range(ROWS_TO_INSERT): + session.fetch_one_or_none(SELECT_BY_VALUE, (f"value_{i % 100}",)) + finally: + config.close_pool() + + +async def raw_asyncpg_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through raw asyncpg.""" + await _run_raw_asyncpg_hot_path("scalar_param_select") + + +async def sqlspec_asyncpg_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through SQLSpec AsyncpgConfig.""" + await _run_sqlspec_asyncpg_hot_path("scalar_param_select") + + +async def raw_asyncpg_no_param_select() -> None: + """Execute parameterless SELECT 1 queries through raw asyncpg.""" + await _run_raw_asyncpg_hot_path("no_param_select") + + +async def sqlspec_asyncpg_no_param_select() -> None: + """Execute parameterless SELECT 1 queries through SQLSpec AsyncpgConfig.""" + await _run_sqlspec_asyncpg_hot_path("no_param_select") + + +async def raw_asyncpg_complex_query() -> None: + """Execute a CTE + 2-join query through raw asyncpg.""" + await _run_raw_asyncpg_hot_path("complex_query") + + +async def sqlspec_asyncpg_complex_query() -> None: + """Execute a CTE + 2-join query through SQLSpec AsyncpgConfig.""" + await _run_sqlspec_asyncpg_hot_path("complex_query") + + +async def raw_asyncpg_session_per_request() -> None: + """Execute one query per request on a reused raw asyncpg connection.""" + await _run_raw_asyncpg_hot_path("session_per_request") + + +async def sqlspec_asyncpg_session_per_request() -> None: + """Execute one query per provide_session context through SQLSpec AsyncpgConfig.""" + await _run_sqlspec_asyncpg_hot_path("session_per_request") + + +async def raw_asyncpg_observability_select() -> None: + """Baseline raw asyncpg repeated SELECT for observability comparison.""" + await _run_raw_asyncpg_hot_path("observability_select") + + +async def sqlspec_asyncpg_observability_select() -> None: + """Execute repeated SELECT queries with a statement observer attached on AsyncpgConfig.""" + await _run_sqlspec_asyncpg_hot_path("observability_select") + + +def raw_psycopg_sync_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through raw psycopg sync.""" + _run_raw_psycopg_sync_hot_path("scalar_param_select") + + +def sqlspec_psycopg_sync_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through SQLSpec PsycopgSyncConfig.""" + _run_sqlspec_psycopg_sync_hot_path("scalar_param_select") + + +def raw_psycopg_sync_no_param_select() -> None: + """Execute parameterless SELECT 1 queries through raw psycopg sync.""" + _run_raw_psycopg_sync_hot_path("no_param_select") + + +def sqlspec_psycopg_sync_no_param_select() -> None: + """Execute parameterless SELECT 1 queries through SQLSpec PsycopgSyncConfig.""" + _run_sqlspec_psycopg_sync_hot_path("no_param_select") + + +def raw_psycopg_sync_complex_query() -> None: + """Execute a CTE + 2-join query through raw psycopg sync.""" + _run_raw_psycopg_sync_hot_path("complex_query") + + +def sqlspec_psycopg_sync_complex_query() -> None: + """Execute a CTE + 2-join query through SQLSpec PsycopgSyncConfig.""" + _run_sqlspec_psycopg_sync_hot_path("complex_query") + + +def raw_psycopg_sync_session_per_request() -> None: + """Execute one query per request on a reused raw psycopg sync connection.""" + _run_raw_psycopg_sync_hot_path("session_per_request") + + +def sqlspec_psycopg_sync_session_per_request() -> None: + """Execute one query per provide_session context through SQLSpec PsycopgSyncConfig.""" + _run_sqlspec_psycopg_sync_hot_path("session_per_request") + + +def raw_psycopg_sync_observability_select() -> None: + """Baseline raw psycopg sync repeated SELECT for observability comparison.""" + _run_raw_psycopg_sync_hot_path("observability_select") + + +def sqlspec_psycopg_sync_observability_select() -> None: + """Execute repeated SELECT queries with a statement observer attached on PsycopgSyncConfig.""" + _run_sqlspec_psycopg_sync_hot_path("observability_select") + + +async def raw_psycopg_async_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through raw psycopg async.""" + await _run_raw_psycopg_async_hot_path("scalar_param_select") + + +async def sqlspec_psycopg_async_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through SQLSpec PsycopgAsyncConfig.""" + await _run_sqlspec_psycopg_async_hot_path("scalar_param_select") + + +async def raw_psycopg_async_no_param_select() -> None: + """Execute parameterless SELECT 1 queries through raw psycopg async.""" + await _run_raw_psycopg_async_hot_path("no_param_select") + + +async def sqlspec_psycopg_async_no_param_select() -> None: + """Execute parameterless SELECT 1 queries through SQLSpec PsycopgAsyncConfig.""" + await _run_sqlspec_psycopg_async_hot_path("no_param_select") + + +async def raw_psycopg_async_complex_query() -> None: + """Execute a CTE + 2-join query through raw psycopg async.""" + await _run_raw_psycopg_async_hot_path("complex_query") + + +async def sqlspec_psycopg_async_complex_query() -> None: + """Execute a CTE + 2-join query through SQLSpec PsycopgAsyncConfig.""" + await _run_sqlspec_psycopg_async_hot_path("complex_query") + + +async def raw_psycopg_async_session_per_request() -> None: + """Execute one query per request on a reused raw psycopg async connection.""" + await _run_raw_psycopg_async_hot_path("session_per_request") + + +async def sqlspec_psycopg_async_session_per_request() -> None: + """Execute one query per provide_session context through SQLSpec PsycopgAsyncConfig.""" + await _run_sqlspec_psycopg_async_hot_path("session_per_request") + + +async def raw_psycopg_async_observability_select() -> None: + """Baseline raw psycopg async repeated SELECT for observability comparison.""" + await _run_raw_psycopg_async_hot_path("observability_select") + + +async def sqlspec_psycopg_async_observability_select() -> None: + """Execute repeated SELECT queries with a statement observer attached on PsycopgAsyncConfig.""" + await _run_sqlspec_psycopg_async_hot_path("observability_select") + + +async def raw_psqlpy_rows() -> None: + """Fetch rows through the native psqlpy connection pool.""" + await _run_raw_psqlpy_rows(_postgres_benchmark_dsn()) + + +async def sqlspec_psqlpy_rows() -> None: + """Fetch rows through SQLSpec PsqlpyConfig.""" + await _run_sqlspec_psqlpy_rows(_postgres_benchmark_dsn()) + + +async def raw_psqlpy_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through raw psqlpy.""" + await _run_raw_psqlpy_hot_path("scalar_param_select") + + +async def sqlspec_psqlpy_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through SQLSpec PsqlpyConfig.""" + await _run_sqlspec_psqlpy_hot_path("scalar_param_select") + + +async def raw_psqlpy_no_param_select() -> None: + """Execute parameterless SELECT 1 queries through raw psqlpy.""" + await _run_raw_psqlpy_hot_path("no_param_select") + + +async def sqlspec_psqlpy_no_param_select() -> None: + """Execute parameterless SELECT 1 queries through SQLSpec PsqlpyConfig.""" + await _run_sqlspec_psqlpy_hot_path("no_param_select") + + +async def raw_psqlpy_complex_query() -> None: + """Execute a CTE + 2-join query through raw psqlpy.""" + await _run_raw_psqlpy_hot_path("complex_query") + + +async def sqlspec_psqlpy_complex_query() -> None: + """Execute a CTE + 2-join query through SQLSpec PsqlpyConfig.""" + await _run_sqlspec_psqlpy_hot_path("complex_query") + + +async def raw_psqlpy_session_per_request() -> None: + """Execute one query per request on a reused raw psqlpy connection.""" + await _run_raw_psqlpy_hot_path("session_per_request") + + +async def sqlspec_psqlpy_session_per_request() -> None: + """Execute one query per provide_session context through SQLSpec PsqlpyConfig.""" + await _run_sqlspec_psqlpy_hot_path("session_per_request") + + +async def raw_psqlpy_observability_select() -> None: + """Baseline raw psqlpy repeated SELECT for observability comparison.""" + await _run_raw_psqlpy_hot_path("observability_select") + + +async def sqlspec_psqlpy_observability_select() -> None: + """Execute repeated SELECT queries with a statement observer attached on PsqlpyConfig.""" + await _run_sqlspec_psqlpy_hot_path("observability_select") + + +def raw_oracle_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through raw python-oracledb.""" + _run_raw_oracle_hot_path("scalar_param_select") + + +def sqlspec_oracle_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through SQLSpec OracleSyncConfig.""" + _run_sqlspec_oracle_hot_path("scalar_param_select") + + +async def sqlspec_oracle_async_scalar_param_select() -> None: + """Execute scalar parameter SELECT queries through SQLSpec OracleAsyncConfig.""" + await _run_sqlspec_oracle_async_hot_path("scalar_param_select") + + +def raw_oracle_no_param_select() -> None: + """Execute parameterless SELECT 1 FROM DUAL queries through raw python-oracledb.""" + _run_raw_oracle_hot_path("no_param_select") + + +def sqlspec_oracle_no_param_select() -> None: + """Execute parameterless SELECT 1 FROM DUAL queries through SQLSpec OracleSyncConfig.""" + _run_sqlspec_oracle_hot_path("no_param_select") + + +async def sqlspec_oracle_async_no_param_select() -> None: + """Execute parameterless SELECT 1 FROM DUAL queries through SQLSpec OracleAsyncConfig.""" + await _run_sqlspec_oracle_async_hot_path("no_param_select") + + +def raw_oracle_complex_query() -> None: + """Execute a CTE + 2-join query through raw python-oracledb.""" + _run_raw_oracle_hot_path("complex_query") + + +def sqlspec_oracle_complex_query() -> None: + """Execute a CTE + 2-join query through SQLSpec OracleSyncConfig.""" + _run_sqlspec_oracle_hot_path("complex_query") + + +async def sqlspec_oracle_async_complex_query() -> None: + """Execute a CTE + 2-join query through SQLSpec OracleAsyncConfig.""" + await _run_sqlspec_oracle_async_hot_path("complex_query") + + +def raw_oracle_session_per_request() -> None: + """Execute one query per request on a reused raw python-oracledb connection.""" + _run_raw_oracle_hot_path("session_per_request") + + +def sqlspec_oracle_session_per_request() -> None: + """Execute one query per provide_session context through SQLSpec OracleSyncConfig.""" + _run_sqlspec_oracle_hot_path("session_per_request") + + +async def sqlspec_oracle_async_session_per_request() -> None: + """Execute one query per provide_session context through SQLSpec OracleAsyncConfig.""" + await _run_sqlspec_oracle_async_hot_path("session_per_request") + + +def raw_oracle_observability_select() -> None: + """Baseline raw python-oracledb repeated SELECT for observability comparison.""" + _run_raw_oracle_hot_path("observability_select") + + +def sqlspec_oracle_observability_select() -> None: + """Execute repeated SELECT queries with a statement observer attached on OracleSyncConfig.""" + _run_sqlspec_oracle_hot_path("observability_select") + + +async def sqlspec_oracle_async_observability_select() -> None: + """Execute repeated SELECT queries with a statement observer attached on OracleAsyncConfig.""" + await _run_sqlspec_oracle_async_hot_path("observability_select") + + +def print_benchmark_table(results: list[dict[str, Any]]) -> None: + console = Console() + table = Table(title="Benchmark Results") + table.add_column("Driver", style="cyan", no_wrap=True) + table.add_column("Library", style="magenta") + table.add_column("Scenario", style="green") + table.add_column("Time (s)", justify="right", style="yellow") + table.add_column("Stddev", justify="right") + table.add_column("IQR", justify="right") + table.add_column("Noise", justify="center") + table.add_column("% Slower vs Raw", justify="right", style="red") + + multi_iter = any(len(row.get("times", [])) > 1 for row in results) + + raw_times: dict[tuple[str, str], float] = {} + for row in results: + if row["library"] == "raw": + raw_times[row["driver"], row["scenario"]] = row["time"] + + for row in results: + driver = row["driver"] + scenario = row["scenario"] + lib = row["library"] + t = row["time"] + times = row.get("times", [t]) + stddev = float(row.get("stddev", 0.0)) + iqr = float(row.get("iqr", 0.0)) + noise = "yes" if row.get("noisy", False) else "" + if lib == "raw": + percent_slower = "---" + else: + raw_time = raw_times.get((driver, scenario)) + percent_slower = f"{100 * (t - raw_time) / raw_time:.1f}%" if raw_time and raw_time > 0 else "n/a" + time_str = f"{t:.4f} ({min(times):.4f}-{max(times):.4f})" if multi_iter and len(times) > 1 else f"{t:.4f}" + table.add_row(driver, lib, scenario, time_str, f"{stddev:.4f}", f"{iqr:.4f}", noise, percent_slower) + console.print(table) + + +def _benchmark_unavailable() -> NoReturn: + raise BenchmarkUnavailableError + + +def _is_compiled() -> bool: + """Detect if sqlspec driver modules are mypyc-compiled.""" + try: + from sqlspec.driver import _sync + + return hasattr(_sync, "__file__") and (_sync.__file__ or "").endswith(".so") + except ImportError: + return False + + +def _new_benchmark_loop() -> asyncio.AbstractEventLoop: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + return loop + + +def _close_benchmark_loop(loop: asyncio.AbstractEventLoop) -> None: + try: + loop.run_until_complete(loop.shutdown_asyncgens()) + if hasattr(loop, "shutdown_default_executor"): + loop.run_until_complete(loop.shutdown_default_executor()) + finally: + asyncio.set_event_loop(None) + loop.close() + + +def _run_scenario_once(func: Any, loop: asyncio.AbstractEventLoop | None) -> None: + if loop is not None: + loop.run_until_complete(func()) + else: + func() + + +def _run_benchmark_iterations( + func: Any, *, is_async: bool, iterations: int, warmup: int, profiler: cProfile.Profile | None = None +) -> list[float]: + loop = _new_benchmark_loop() if is_async else None + try: + for _ in range(warmup): + _run_scenario_once(func, loop) + + times: list[float] = [] + for _ in range(iterations): + gc.collect() + gc_was_enabled = gc.isenabled() + if gc_was_enabled: + gc.disable() + start = time.perf_counter() + try: + if profiler is not None: + profiler.enable() + _run_scenario_once(func, loop) + finally: + if profiler is not None: + profiler.disable() + if gc_was_enabled: + gc.enable() + times.append(time.perf_counter() - start) + return times + finally: + if loop is not None: + _close_benchmark_loop(loop) + + +def _summarize_times(times: list[float]) -> dict[str, float | bool]: + median_time = statistics.median(times) + stddev = statistics.stdev(times) if len(times) > 1 else 0.0 + quartiles = statistics.quantiles(times, n=4, method="inclusive") if len(times) > 1 else (0.0, 0.0, 0.0) + iqr = quartiles[2] - quartiles[0] + noise_ratio = stddev / median_time if median_time > 0 else 0.0 + return { + "time": median_time, + "stddev": stddev, + "iqr": iqr, + "noise_ratio": noise_ratio, + "noisy": noise_ratio > NOISY_STDDEV_RATIO, + } + + +def _benchmark_library_label(library: str) -> str: + """Return a display label for a benchmark library key.""" + if library == "sqlspec": + return SQLSPEC_LABEL + if library == "sqlspec_fetch_lobs_true": + return f"{SQLSPEC_LABEL} fetch_lobs=True" + if library == "sqlspec_async": + return f"{SQLSPEC_LABEL} async" + if library == "sqlspec_async_fetch_lobs_true": + return f"{SQLSPEC_LABEL} async fetch_lobs=True" + return library + + +def _driver_has_core_scenarios(driver: str) -> bool: + """Return True when a driver has any core benchmark scenario registered.""" + return any( + (library, driver, scenario) in SCENARIO_REGISTRY for scenario in CORE_SCENARIOS for library in CORE_LIBRARIES + ) + + +def _collect_environment_metadata() -> dict[str, Any]: + """Collect Python, platform, and key package versions for benchmark reproducibility.""" + packages: dict[str, str | None] = {} + for pkg_name in ("duckdb", "sqlglot", "aiosqlite", "pyarrow", "sqlspec"): + try: + packages[pkg_name] = importlib.metadata.version(pkg_name) + except importlib.metadata.PackageNotFoundError: + packages[pkg_name] = None + return {"python_version": platform.python_version(), "platform": platform.platform(), "packages": packages} + + +def _write_json_results( + results: list[dict[str, Any]], + output_path: str, + *, + rows: int, + pool_size: int, + iterations: int, + warmup: int = DEFAULT_BENCH_WARMUP, +) -> None: + """Write benchmark results to a JSON file. + + Args: + results: List of benchmark result dictionaries + output_path: Path to write the JSON file + rows: Number of rows used in the benchmark + pool_size: Pool size used in the benchmark + iterations: Number of timed iterations per scenario + warmup: Number of warmup iterations per scenario + """ + output = { + "metadata": { + "rows": rows, + "pool_size": pool_size, + "iterations": iterations, + "warmup": warmup, + "mypyc_compiled": _is_compiled(), + "environment": _collect_environment_metadata(), + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + }, + "results": [ + { + "driver": r["driver"], + "library": r["library"], + "library_key": r.get("library_key", r["library"]), + "scenario": r["scenario"], + "time": r["time"], + "times": r.get("times", [r["time"]]), + "stddev": r.get("stddev", 0.0), + "iqr": r.get("iqr", 0.0), + "noise_ratio": r.get("noise_ratio", 0.0), + "noisy": r.get("noisy", False), + } + for r in results + ], + } + with Path(output_path).open("w") as f: + json.dump(output, f, indent=2) + + +def _optimize_raw_sqlite(conn: sqlite3.Connection) -> None: + """Apply same PRAGMAs that sqlspec applies for fair comparison.""" + conn.execute("PRAGMA journal_mode = WAL") + conn.execute("PRAGMA synchronous = NORMAL") + conn.execute("PRAGMA busy_timeout = 5000") + conn.execute("PRAGMA foreign_keys = ON") + + +def _get_sqlalchemy() -> tuple[Any, Any]: + """Import SQLAlchemy lazily.""" + try: + from sqlalchemy import create_engine, text + except ImportError: + return None, None + else: + return create_engine, text + + +def _get_duckdb() -> Any: + """Import duckdb lazily.""" + try: + import duckdb + except ImportError: + return None + else: + return duckdb + + +def _get_adbc_dbapi() -> Any: + """Import the ADBC DB-API lazily.""" + try: + from adbc_driver_manager import dbapi + except ImportError: + return None + else: + return dbapi + + +def _get_adbc_config() -> Any: + """Import AdbcConfig lazily.""" + try: + from sqlspec.adapters.adbc import AdbcConfig + except ImportError: + return None + else: + return AdbcConfig + + +def _adbc_benchmark_connection_config() -> dict[str, Any] | None: + """Build an ADBC benchmark connection from service-provided environment values.""" + uri = os.environ.get("SQLSPEC_BENCH_ADBC_URI") + driver_name = os.environ.get("SQLSPEC_BENCH_ADBC_DRIVER_NAME") + if not uri or not driver_name: + return None + config: dict[str, Any] = {"uri": uri, "driver_name": driver_name} + for env_name, config_name in ( + ("SQLSPEC_BENCH_ADBC_USERNAME", "username"), + ("SQLSPEC_BENCH_ADBC_PASSWORD", "password"), + ("SQLSPEC_BENCH_ADBC_GIZMOSQL_BACKEND", "gizmosql_backend"), + ): + value = os.environ.get(env_name) + if value: + config[config_name] = value + if os.environ.get("SQLSPEC_BENCH_ADBC_TLS_SKIP_VERIFY") == "1": + config["tls_skip_verify"] = True + return config + + +def _adbc_benchmark_rows() -> list[tuple[int, str]]: + """Return the stable row payload used by the ADBC benchmark.""" + return [(index, f"value_{index}") for index in range(BENCHMARK_ROWS)] + + +def _connect_adbc(dbapi: Any, config: dict[str, Any]) -> Any: + driver_name = config["driver_name"] + if driver_name in {"adbc_driver_sqlite", "adbc_driver_duckdb", "adbc_driver_postgresql"}: + module = importlib.import_module(f"{driver_name}.dbapi") + return module.connect(uri=config["uri"], autocommit=True) + db_kwargs = { + key: config[key] for key in ("username", "password", "gizmosql_backend", "tls_skip_verify") if key in config + } + return dbapi.connect(driver=driver_name, uri=config["uri"], db_kwargs=db_kwargs, autocommit=True) + + +def _get_mysql_connector() -> Any: + """Import mysql-connector lazily.""" + try: + import mysql.connector + except ImportError: + return None + else: + return mysql.connector + + +def _get_mysql_connector_config() -> Any: + """Import MysqlConnectorSyncConfig lazily.""" + try: + from sqlspec.adapters.mysqlconnector import MysqlConnectorSyncConfig + except ImportError: + return None + else: + return MysqlConnectorSyncConfig + + +def _mysql_benchmark_connection_config() -> dict[str, Any] | None: + """Build a MySQL benchmark connection from service-provided environment values.""" + required: dict[str, Any] = { + "host": os.environ.get("SQLSPEC_BENCH_MYSQL_HOST"), + "port": os.environ.get("SQLSPEC_BENCH_MYSQL_PORT"), + "user": os.environ.get("SQLSPEC_BENCH_MYSQL_USER"), + "password": os.environ.get("SQLSPEC_BENCH_MYSQL_PASSWORD"), + "database": os.environ.get("SQLSPEC_BENCH_MYSQL_DATABASE"), + } + if any(value is None for value in required.values()): + return None + required["port"] = int(required["port"]) + required["use_pure"] = True + required["autocommit"] = True + return required + + +def _json_benchmark_rows() -> list[tuple[int, str]]: + """Return the stable JSON payload used by the MySQL benchmark.""" + return [(index, json.dumps({"index": index, "values": [index, index + 1]})) for index in range(BENCHMARK_ROWS)] + + +def _json_row_payload(row: Any) -> Any: + payload = row.get("payload") if isinstance(row, dict) else row[1] + return json.loads(payload) if isinstance(payload, str) else payload + + +def _spanner_benchmark_config() -> dict[str, Any] | None: + """Build a Spanner benchmark connection from service-provided environment values.""" + required: dict[str, Any] = { + "project": os.environ.get("SQLSPEC_BENCH_SPANNER_PROJECT"), + "instance_id": os.environ.get("SQLSPEC_BENCH_SPANNER_INSTANCE_ID"), + "database_id": os.environ.get("SQLSPEC_BENCH_SPANNER_DATABASE_ID"), + "api_endpoint": os.environ.get("SQLSPEC_BENCH_SPANNER_API_ENDPOINT"), + } + if any(value is None for value in required.values()): + return None + return required + + +def _get_duckdb_engine() -> tuple[Any, Any]: + """Import SQLAlchemy with duckdb_engine lazily.""" + try: + importlib.import_module("duckdb_engine") + from sqlalchemy import create_engine, text + except ImportError: + return None, None + else: + return create_engine, text + + +def _check_pool_leak(pool: Any, scenario_name: str) -> None: + """Check for connection leaks in a pool. + + Args: + pool: Connection pool with size() and checked_out() methods + scenario_name: Name of the scenario for error reporting + """ + if pool is None: + return + + with suppress(AttributeError, TypeError): + total = pool.size() + checked_out = pool.checked_out() + if checked_out > 0: + _leaked_pools.append(f"{scenario_name}: {checked_out}/{total} connections leaked") + + +def _get_aiosqlite() -> Any: + """Import aiosqlite lazily.""" + try: + import aiosqlite + except ImportError: + return None + else: + return aiosqlite + + +def _get_aiosqlite_config() -> Any: + """Import AiosqliteConfig lazily.""" + try: + from sqlspec.adapters.aiosqlite import AiosqliteConfig + except ImportError: + return None + else: + return AiosqliteConfig + + +def _get_asyncpg() -> Any: + """Import asyncpg lazily.""" + try: + from asyncpg import connect + except ImportError: + return None + else: + return connect + + +def _get_asyncpg_config() -> Any: + """Import AsyncpgConfig lazily.""" + try: + from sqlspec.adapters.asyncpg import AsyncpgConfig + except ImportError: + return None + else: + return AsyncpgConfig + + +def _get_async_sqlalchemy() -> tuple[Any, Any]: + """Import async SQLAlchemy lazily.""" + try: + from sqlalchemy import text + from sqlalchemy.ext.asyncio import create_async_engine + except ImportError: + return None, None + else: + return create_async_engine, text + + +def _postgres_benchmark_dsn() -> str: + dsn = os.environ.get(POSTGRES_DSN_ENV) + if not dsn: + _benchmark_unavailable() + return dsn + + +def _cockroach_benchmark_dsn() -> str: + dsn = os.environ.get(COCKROACH_DSN_ENV) + if not dsn: + _benchmark_unavailable() + return dsn + + +def _asyncpg_connect_kwargs(dsn: str) -> dict[str, Any]: + """Adapt a service DSN to asyncpg's keyword set.""" + parsed = urlsplit(dsn) + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + sslmode = query.pop("sslmode", None) + clean_dsn = urlunsplit((parsed.scheme, parsed.netloc, parsed.path, urlencode(query), parsed.fragment)) + kwargs: dict[str, Any] = {"dsn": clean_dsn} + if sslmode == "disable": + kwargs["ssl"] = False + return kwargs + + +def _service_benchmark_rows() -> list[tuple[int, str]]: + return [(index, f"value_{index}") for index in range(BENCHMARK_ROWS)] + + +def _get_psycopg() -> Any: + try: + import psycopg + except ImportError: + return None + else: + return psycopg + + +def _run_raw_psycopg_sync_rows(dsn: str, *, cockroach: bool) -> None: + psycopg = _get_psycopg() + if psycopg is None: + _benchmark_unavailable() + if cockroach: + from psycopg import crdb + + connect = crdb.connect + else: + connect = psycopg.connect + with connect(dsn) as connection: + with connection.cursor() as cursor: + cursor.execute(SERVICE_ROWS_DROP) + cursor.execute(SERVICE_ROWS_CREATE) + cursor.executemany(SERVICE_ROWS_INSERT_PG, _service_benchmark_rows()) + cursor.execute(SERVICE_ROWS_SELECT) + rows = cursor.fetchall() + connection.commit() + assert len(rows) == BENCHMARK_ROWS + + +def _get_sync_service_config(driver: str) -> Any: + try: + if driver == "psycopg_sync": + from sqlspec.adapters.psycopg import PsycopgSyncConfig + + return PsycopgSyncConfig + from sqlspec.adapters.cockroach_psycopg import CockroachPsycopgSyncConfig + except ImportError: + return None + else: + return CockroachPsycopgSyncConfig + + +def _run_sqlspec_sync_service_rows(dsn: str, *, driver: str) -> None: + config_class = _get_sync_service_config(driver) + if config_class is None: + _benchmark_unavailable() + config = config_class(connection_config={"conninfo": dsn, "min_size": 1, "max_size": max(1, POOL_SIZE)}) + try: + spec = SQLSpec() + with spec.provide_session(config) as session: + session.execute(SERVICE_ROWS_DROP) + session.execute(SERVICE_ROWS_CREATE) + session.execute_many(SERVICE_ROWS_INSERT_PG, _service_benchmark_rows()) + rows = session.fetch(SERVICE_ROWS_SELECT) + assert len(rows) == BENCHMARK_ROWS + finally: + config.close_pool() + + +async def _run_raw_psycopg_async_rows(dsn: str) -> None: + psycopg = _get_psycopg() + if psycopg is None: + _benchmark_unavailable() + connection = await psycopg.AsyncConnection.connect(dsn) + try: + async with connection.cursor() as cursor: + await cursor.execute(SERVICE_ROWS_DROP) + await cursor.execute(SERVICE_ROWS_CREATE) + await cursor.executemany(SERVICE_ROWS_INSERT_PG, _service_benchmark_rows()) + await cursor.execute(SERVICE_ROWS_SELECT) + rows = await cursor.fetchall() + await connection.commit() + finally: + await connection.close() + assert len(rows) == BENCHMARK_ROWS + + +async def _run_raw_asyncpg_rows(dsn: str) -> None: + connect = _get_asyncpg() + if connect is None: + _benchmark_unavailable() + connection = await connect(**_asyncpg_connect_kwargs(dsn)) + try: + await connection.execute(SERVICE_ROWS_DROP) + await connection.execute(SERVICE_ROWS_CREATE) + await connection.executemany(SERVICE_ROWS_INSERT_ASYNCPG, _service_benchmark_rows()) + rows = await connection.fetch(SERVICE_ROWS_SELECT) + finally: + await connection.close() + assert len(rows) == BENCHMARK_ROWS + + +def _get_async_service_config(driver: str) -> Any: + try: + if driver in {"psycopg_async", "cockroach_psycopg_async"}: + if driver == "psycopg_async": + from sqlspec.adapters.psycopg import PsycopgAsyncConfig + + return PsycopgAsyncConfig + from sqlspec.adapters.cockroach_psycopg import CockroachPsycopgAsyncConfig + + return CockroachPsycopgAsyncConfig + if driver == "asyncpg": + from sqlspec.adapters.asyncpg import AsyncpgConfig + + return AsyncpgConfig + from sqlspec.adapters.cockroach_asyncpg import CockroachAsyncpgConfig + except ImportError: + return None + else: + return CockroachAsyncpgConfig + + +async def _run_sqlspec_async_service_rows(dsn: str, *, driver: str) -> None: + config_class = _get_async_service_config(driver) + if config_class is None: + _benchmark_unavailable() + connection_key = "dsn" if "asyncpg" in driver else "conninfo" + config = config_class(connection_config={connection_key: dsn, "min_size": 1, "max_size": max(1, POOL_SIZE)}) + try: + spec = SQLSpec() + async with spec.provide_session(config) as session: + await session.execute(SERVICE_ROWS_DROP) + await session.execute(SERVICE_ROWS_CREATE) + insert_sql = SERVICE_ROWS_INSERT_ASYNCPG if "asyncpg" in driver else SERVICE_ROWS_INSERT_PG + await session.execute_many(insert_sql, _service_benchmark_rows()) + rows = await session.fetch(SERVICE_ROWS_SELECT) + assert len(rows) == BENCHMARK_ROWS + finally: + await config.close_pool() + + +def _schema_type_numpy_payload() -> list[dict[str, int]]: + """Return the stable 5000x5 payload used by the schema_type numpy benchmark.""" + global _SCHEMA_TYPE_NUMPY_PAYLOAD + if _SCHEMA_TYPE_NUMPY_PAYLOAD is None: + _SCHEMA_TYPE_NUMPY_PAYLOAD = [ + {"field_0": index, "field_1": index + 1, "field_2": index + 2, "field_3": index + 3, "field_4": index + 4} + for index in range(SCHEMA_TYPE_NUMPY_ROW_COUNT) + ] + return _SCHEMA_TYPE_NUMPY_PAYLOAD + + +def _schema_type_numpy_row_type() -> type[Any]: + """Return the cached msgspec row type used by the schema_type numpy benchmark.""" + global _SCHEMA_TYPE_NUMPY_ROW_TYPE + if _SCHEMA_TYPE_NUMPY_ROW_TYPE is None: + import msgspec + + class SchemaTypeNumpyRow(msgspec.Struct): + field_0: int + field_1: int + field_2: int + field_3: int + field_4: int + + _SCHEMA_TYPE_NUMPY_ROW_TYPE = SchemaTypeNumpyRow + return _SCHEMA_TYPE_NUMPY_ROW_TYPE + +def _schema_type_numpy_vector_type() -> type[Any]: + """Return the cached msgspec vector row type used to assert ndarray fallback behavior.""" + global _SCHEMA_TYPE_NUMPY_VECTOR_TYPE + if _SCHEMA_TYPE_NUMPY_VECTOR_TYPE is None: + import msgspec -def raw_sqlite_thin_path_stress() -> None: - """100K individual INSERTs to stress-test per-call execution path (raw).""" - stress_count = ROWS_TO_INSERT * 10 - with tempfile.NamedTemporaryFile(suffix=".db") as tmp: - conn = sqlite3.connect(tmp.name) - _optimize_raw_sqlite(conn) - conn.execute(CREATE_TEST_TABLE) - for i in range(stress_count): - conn.execute(INSERT_TEST_VALUE, (f"value_{i}",)) - conn.commit() - conn.close() + class SchemaTypeNumpyVectorRow(msgspec.Struct): + values: list[float] + _SCHEMA_TYPE_NUMPY_VECTOR_TYPE = SchemaTypeNumpyVectorRow + return _SCHEMA_TYPE_NUMPY_VECTOR_TYPE -def sqlspec_sqlite_thin_path_stress() -> None: - """100K individual INSERTs to stress-test the thin execute path (sqlspec).""" - stress_count = ROWS_TO_INSERT * 10 - with tempfile.NamedTemporaryFile(suffix=".db") as tmp: - spec = SQLSpec() - config = SqliteConfig(connection_config={"database": tmp.name}) - with spec.provide_session(config) as session: - session.execute(CREATE_TEST_TABLE) - for i in range(stress_count): - session.execute(INSERT_TEST_VALUE, (f"value_{i}",)) +def _generate_wide_row(i: int) -> tuple[str, ...]: + """Generate a single row tuple for the wide_test table.""" + return ( + f"First{i}", + f"Last{i}", + f"user{i}@example.com", + f"+1-555-{i:04d}", + f"{i} Main Street", + f"{10000 + i}", + "United States", + f"199{i % 10}-{1 + (i % 12):02d}-{1 + (i % 28):02d}", + f"{100.0 + i * 0.5:.2f}", + "true" if i % 2 == 0 else "false", + ) -# --- Oracle LOB fetch scenarios --- -ORACLE_LOB_TABLES = {"1k": "SQLSPEC_LOB_1K", "100k": "SQLSPEC_LOB_100K"} -ORACLE_JSON_TABLE = "SQLSPEC_JSON_BENCH" +def _generate_complex_row(i: int) -> tuple[str, str, str]: + """Generate a single row tuple for the complex_test table.""" + import uuid as uuid_mod + + uid = str(uuid_mod.UUID(int=i)) + created_at = f"2024-{1 + (i % 12):02d}-{1 + (i % 28):02d}T{i % 24:02d}:{i % 60:02d}:00" + payload = f'{{"key": "value_{i}", "count": {i}, "tags": ["a", "b"]}}' + return (uid, created_at, payload) def _get_oracledb() -> Any: @@ -2816,73 +3915,578 @@ def _run_sqlspec_oracle_json_read() -> None: config.close_pool() -def raw_oracle_lob_fetch_1k() -> None: - """Fetch 100 1 KiB Oracle CLOB rows through raw python-oracledb.""" - _run_raw_oracle_lob_fetch("1k") +def _noop_statement_observer(_event: Any) -> None: + """No-op statement observer used for observability benchmark scenarios.""" + return -def raw_oracle_lob_fetch_100k() -> None: - """Fetch 100 100 KiB Oracle CLOB rows through raw python-oracledb.""" - _run_raw_oracle_lob_fetch("100k") +def _service_loop_iterations() -> int: + """Return bounded iteration count for network-backed hot-path scenarios.""" + return min(ROWS_TO_INSERT, HOT_PATH_COMPLEX_ITERATIONS_CAP) -def sqlspec_oracle_lob_fetch_1k() -> None: - """Fetch 100 1 KiB Oracle CLOB rows with sqlspec direct LOB fetching.""" - _run_sqlspec_oracle_lob_fetch("1k", fetch_lobs=False) +async def _run_raw_asyncpg_hot_path(scenario: str) -> None: + connect = _get_asyncpg() + if connect is None: + _benchmark_unavailable() + dsn = _postgres_benchmark_dsn() + iterations = _service_loop_iterations() + conn = await connect(**_asyncpg_connect_kwargs(dsn)) + try: + if scenario == "scalar_param_select": + for i in range(iterations): + val = await conn.fetchval(SCALAR_PARAM_SELECT_ASYNCPG_SQL, i) + assert val == i + elif scenario == "no_param_select": + for _ in range(iterations): + rows = await conn.fetch(NO_PARAM_SELECT_SQL) + assert len(rows) == 1 + elif scenario == "complex_query": + for ddl in POSTGRES_COMPLEX_DROP_DDL: + await conn.execute(ddl) + for ddl in POSTGRES_COMPLEX_SCHEMA_DDL: + await conn.execute(ddl) + for i in range(iterations): + await conn.fetch(POSTGRES_COMPLEX_QUERY_ASYNCPG_SQL, i) + elif scenario in {"session_per_request", "observability_select"}: + await conn.execute(SERVICE_ROWS_DROP) + await conn.execute(SERVICE_ROWS_CREATE) + await conn.executemany(SERVICE_ROWS_INSERT_ASYNCPG, [(i, f"value_{i}") for i in range(100)]) + for i in range(iterations): + await conn.fetchrow(SERVICE_ROWS_SELECT_BY_PAYLOAD_ASYNCPG, f"value_{i % 100}") + finally: + await conn.close() -def sqlspec_oracle_lob_fetch_100k() -> None: - """Fetch 100 100 KiB Oracle CLOB rows with sqlspec direct LOB fetching.""" - _run_sqlspec_oracle_lob_fetch("100k", fetch_lobs=False) +async def _run_sqlspec_asyncpg_hot_path(scenario: str) -> None: + asyncpg_config_type = _get_asyncpg_config() + if asyncpg_config_type is None: + _benchmark_unavailable() + dsn = _postgres_benchmark_dsn() + iterations = _service_loop_iterations() + spec = SQLSpec() + obs_cfg = ( + ObservabilityConfig(statement_observers=(_noop_statement_observer,)) + if scenario == "observability_select" + else None + ) + config = asyncpg_config_type( + connection_config={"dsn": dsn, "min_size": 1, "max_size": max(1, POOL_SIZE)}, observability_config=obs_cfg + ) + try: + if scenario == "scalar_param_select": + async with spec.provide_session(config) as session: + for i in range(iterations): + val = await session.select_value(SCALAR_PARAM_SELECT_ASYNCPG_SQL, i) + assert val == i + elif scenario == "no_param_select": + async with spec.provide_session(config) as session: + for _ in range(iterations): + res = await session.execute(NO_PARAM_SELECT_SQL) + assert res is not None + elif scenario == "complex_query": + async with spec.provide_session(config) as session: + for ddl in POSTGRES_COMPLEX_DROP_DDL: + await session.execute(ddl) + for ddl in POSTGRES_COMPLEX_SCHEMA_DDL: + await session.execute(ddl) + for i in range(iterations): + await session.execute(POSTGRES_COMPLEX_QUERY_ASYNCPG_SQL, i) + elif scenario == "session_per_request": + async with spec.provide_session(config) as session: + await session.execute(SERVICE_ROWS_DROP) + await session.execute(SERVICE_ROWS_CREATE) + await session.execute_many(SERVICE_ROWS_INSERT_ASYNCPG, [(i, f"value_{i}") for i in range(100)]) + for i in range(iterations): + async with spec.provide_session(config) as session: + await session.fetch_one_or_none(SERVICE_ROWS_SELECT_BY_PAYLOAD_ASYNCPG, (f"value_{i % 100}",)) + elif scenario == "observability_select": + async with spec.provide_session(config) as session: + await session.execute(SERVICE_ROWS_DROP) + await session.execute(SERVICE_ROWS_CREATE) + await session.execute_many(SERVICE_ROWS_INSERT_ASYNCPG, [(i, f"value_{i}") for i in range(100)]) + for i in range(iterations): + await session.fetch_one_or_none(SERVICE_ROWS_SELECT_BY_PAYLOAD_ASYNCPG, (f"value_{i % 100}",)) + finally: + await config.close_pool() -def sqlspec_oracle_lob_fetch_fetch_lobs_true_1k() -> None: - """Fetch 100 1 KiB Oracle CLOB rows with sqlspec LOB locators enabled.""" - _run_sqlspec_oracle_lob_fetch("1k", fetch_lobs=True) +def _run_raw_psycopg_sync_hot_path(scenario: str) -> None: + psycopg = _get_psycopg() + if psycopg is None: + _benchmark_unavailable() + dsn = _postgres_benchmark_dsn() + iterations = _service_loop_iterations() + with psycopg.connect(dsn) as conn, conn.cursor() as cursor: + if scenario == "scalar_param_select": + for i in range(iterations): + cursor.execute(SCALAR_PARAM_SELECT_PG_SQL, (i,)) + row = cursor.fetchone() + assert row is not None + assert row[0] == i + elif scenario == "no_param_select": + for _ in range(iterations): + cursor.execute(NO_PARAM_SELECT_SQL) + rows = cursor.fetchall() + assert len(rows) == 1 + elif scenario == "complex_query": + for ddl in POSTGRES_COMPLEX_DROP_DDL: + cursor.execute(ddl) + for ddl in POSTGRES_COMPLEX_SCHEMA_DDL: + cursor.execute(ddl) + conn.commit() + for i in range(iterations): + cursor.execute(POSTGRES_COMPLEX_QUERY_PG_SQL, (i,)) + cursor.fetchall() + elif scenario in {"session_per_request", "observability_select"}: + cursor.execute(SERVICE_ROWS_DROP) + cursor.execute(SERVICE_ROWS_CREATE) + cursor.executemany(SERVICE_ROWS_INSERT_PG, [(i, f"value_{i}") for i in range(100)]) + conn.commit() + for i in range(iterations): + cursor.execute(SERVICE_ROWS_SELECT_BY_PAYLOAD_PG, (f"value_{i % 100}",)) + cursor.fetchone() -def sqlspec_oracle_lob_fetch_fetch_lobs_true_100k() -> None: - """Fetch 100 100 KiB Oracle CLOB rows with sqlspec LOB locators enabled.""" - _run_sqlspec_oracle_lob_fetch("100k", fetch_lobs=True) +def _run_sqlspec_psycopg_sync_hot_path(scenario: str) -> None: + config_class = _get_sync_service_config("psycopg_sync") + if config_class is None: + _benchmark_unavailable() + dsn = _postgres_benchmark_dsn() + iterations = _service_loop_iterations() + spec = SQLSpec() + obs_cfg = ( + ObservabilityConfig(statement_observers=(_noop_statement_observer,)) + if scenario == "observability_select" + else None + ) + config = config_class( + connection_config={"conninfo": dsn, "min_size": 1, "max_size": max(1, POOL_SIZE)}, observability_config=obs_cfg + ) + try: + if scenario == "scalar_param_select": + with spec.provide_session(config) as session: + for i in range(iterations): + val = session.select_value(SCALAR_PARAM_SELECT_PG_SQL, i) + assert val == i + elif scenario == "no_param_select": + with spec.provide_session(config) as session: + for _ in range(iterations): + res = session.execute(NO_PARAM_SELECT_SQL) + assert res is not None + elif scenario == "complex_query": + with spec.provide_session(config) as session: + for ddl in POSTGRES_COMPLEX_DROP_DDL: + session.execute(ddl) + for ddl in POSTGRES_COMPLEX_SCHEMA_DDL: + session.execute(ddl) + for i in range(iterations): + session.execute(POSTGRES_COMPLEX_QUERY_PG_SQL, i) + elif scenario == "session_per_request": + with spec.provide_session(config) as session: + session.execute(SERVICE_ROWS_DROP) + session.execute(SERVICE_ROWS_CREATE) + session.execute_many(SERVICE_ROWS_INSERT_PG, [(i, f"value_{i}") for i in range(100)]) + for i in range(iterations): + with spec.provide_session(config) as session: + session.fetch_one_or_none(SERVICE_ROWS_SELECT_BY_PAYLOAD_PG, (f"value_{i % 100}",)) + elif scenario == "observability_select": + with spec.provide_session(config) as session: + session.execute(SERVICE_ROWS_DROP) + session.execute(SERVICE_ROWS_CREATE) + session.execute_many(SERVICE_ROWS_INSERT_PG, [(i, f"value_{i}") for i in range(100)]) + for i in range(iterations): + session.fetch_one_or_none(SERVICE_ROWS_SELECT_BY_PAYLOAD_PG, (f"value_{i % 100}",)) + finally: + config.close_pool() -async def sqlspec_oracle_lob_fetch_async_1k() -> None: - """Fetch 100 1 KiB Oracle CLOB rows with async sqlspec direct LOB fetching.""" - await _run_sqlspec_oracle_lob_fetch_async("1k", fetch_lobs=False) +async def _run_raw_psycopg_async_hot_path(scenario: str) -> None: + psycopg = _get_psycopg() + if psycopg is None: + _benchmark_unavailable() + dsn = _postgres_benchmark_dsn() + iterations = _service_loop_iterations() + conn = await psycopg.AsyncConnection.connect(dsn) + try: + async with conn.cursor() as cursor: + if scenario == "scalar_param_select": + for i in range(iterations): + await cursor.execute(SCALAR_PARAM_SELECT_PG_SQL, (i,)) + row = await cursor.fetchone() + assert row is not None + assert row[0] == i + elif scenario == "no_param_select": + for _ in range(iterations): + await cursor.execute(NO_PARAM_SELECT_SQL) + rows = await cursor.fetchall() + assert len(rows) == 1 + elif scenario == "complex_query": + for ddl in POSTGRES_COMPLEX_DROP_DDL: + await cursor.execute(ddl) + for ddl in POSTGRES_COMPLEX_SCHEMA_DDL: + await cursor.execute(ddl) + await conn.commit() + for i in range(iterations): + await cursor.execute(POSTGRES_COMPLEX_QUERY_PG_SQL, (i,)) + await cursor.fetchall() + elif scenario in {"session_per_request", "observability_select"}: + await cursor.execute(SERVICE_ROWS_DROP) + await cursor.execute(SERVICE_ROWS_CREATE) + await cursor.executemany(SERVICE_ROWS_INSERT_PG, [(i, f"value_{i}") for i in range(100)]) + await conn.commit() + for i in range(iterations): + await cursor.execute(SERVICE_ROWS_SELECT_BY_PAYLOAD_PG, (f"value_{i % 100}",)) + await cursor.fetchone() + finally: + await conn.close() -async def sqlspec_oracle_lob_fetch_async_100k() -> None: - """Fetch 100 100 KiB Oracle CLOB rows with async sqlspec direct LOB fetching.""" - await _run_sqlspec_oracle_lob_fetch_async("100k", fetch_lobs=False) +async def _run_sqlspec_psycopg_async_hot_path(scenario: str) -> None: + config_class = _get_async_service_config("psycopg_async") + if config_class is None: + _benchmark_unavailable() + dsn = _postgres_benchmark_dsn() + iterations = _service_loop_iterations() + spec = SQLSpec() + obs_cfg = ( + ObservabilityConfig(statement_observers=(_noop_statement_observer,)) + if scenario == "observability_select" + else None + ) + config = config_class( + connection_config={"conninfo": dsn, "min_size": 1, "max_size": max(1, POOL_SIZE)}, observability_config=obs_cfg + ) + try: + if scenario == "scalar_param_select": + async with spec.provide_session(config) as session: + for i in range(iterations): + val = await session.select_value(SCALAR_PARAM_SELECT_PG_SQL, i) + assert val == i + elif scenario == "no_param_select": + async with spec.provide_session(config) as session: + for _ in range(iterations): + res = await session.execute(NO_PARAM_SELECT_SQL) + assert res is not None + elif scenario == "complex_query": + async with spec.provide_session(config) as session: + for ddl in POSTGRES_COMPLEX_DROP_DDL: + await session.execute(ddl) + for ddl in POSTGRES_COMPLEX_SCHEMA_DDL: + await session.execute(ddl) + for i in range(iterations): + await session.execute(POSTGRES_COMPLEX_QUERY_PG_SQL, i) + elif scenario == "session_per_request": + async with spec.provide_session(config) as session: + await session.execute(SERVICE_ROWS_DROP) + await session.execute(SERVICE_ROWS_CREATE) + await session.execute_many(SERVICE_ROWS_INSERT_PG, [(i, f"value_{i}") for i in range(100)]) + for i in range(iterations): + async with spec.provide_session(config) as session: + await session.fetch_one_or_none(SERVICE_ROWS_SELECT_BY_PAYLOAD_PG, (f"value_{i % 100}",)) + elif scenario == "observability_select": + async with spec.provide_session(config) as session: + await session.execute(SERVICE_ROWS_DROP) + await session.execute(SERVICE_ROWS_CREATE) + await session.execute_many(SERVICE_ROWS_INSERT_PG, [(i, f"value_{i}") for i in range(100)]) + for i in range(iterations): + await session.fetch_one_or_none(SERVICE_ROWS_SELECT_BY_PAYLOAD_PG, (f"value_{i % 100}",)) + finally: + await config.close_pool() -async def sqlspec_oracle_lob_fetch_async_fetch_lobs_true_1k() -> None: - """Fetch 100 1 KiB Oracle CLOB rows with async sqlspec LOB locators enabled.""" - await _run_sqlspec_oracle_lob_fetch_async("1k", fetch_lobs=True) +def _get_psqlpy_pool_class() -> Any: + """Import psqlpy ConnectionPool lazily.""" + try: + from psqlpy import ConnectionPool + except ImportError: + return None + else: + return ConnectionPool -async def sqlspec_oracle_lob_fetch_async_fetch_lobs_true_100k() -> None: - """Fetch 100 100 KiB Oracle CLOB rows with async sqlspec LOB locators enabled.""" - await _run_sqlspec_oracle_lob_fetch_async("100k", fetch_lobs=True) +def _get_psqlpy_config() -> Any: + """Import PsqlpyConfig lazily.""" + try: + from sqlspec.adapters.psqlpy import PsqlpyConfig + except ImportError: + return None + else: + return PsqlpyConfig -def sqlspec_oracle_native_json_write() -> None: - """Write native Python mappings to an Oracle native JSON column.""" - _run_sqlspec_oracle_json_write(serialized=False) +async def _run_raw_psqlpy_rows(dsn: str) -> None: + pool_class = _get_psqlpy_pool_class() + if pool_class is None: + _benchmark_unavailable() + pool = pool_class(dsn=dsn, max_db_pool_size=max(2, POOL_SIZE)) + try: + conn = await pool.connection() + await conn.execute(SERVICE_ROWS_DROP) + await conn.execute(SERVICE_ROWS_CREATE) + await conn.execute_many(SERVICE_ROWS_INSERT_ASYNCPG, [[idx, val] for idx, val in _service_benchmark_rows()]) + res = await conn.fetch(SERVICE_ROWS_SELECT) + rows = res.result() + assert len(rows) == BENCHMARK_ROWS + finally: + pool.close() -def sqlspec_oracle_serialized_json_write() -> None: - """Write pre-serialized strings to an Oracle native JSON column.""" - _run_sqlspec_oracle_json_write(serialized=True) +async def _run_sqlspec_psqlpy_rows(dsn: str) -> None: + config_class = _get_psqlpy_config() + if config_class is None: + _benchmark_unavailable() + config = config_class(connection_config={"dsn": dsn, "max_db_pool_size": max(2, POOL_SIZE)}) + try: + spec = SQLSpec() + async with spec.provide_session(config) as session: + await session.execute(SERVICE_ROWS_DROP) + await session.execute(SERVICE_ROWS_CREATE) + await session.execute_many(SERVICE_ROWS_INSERT_ASYNCPG, _service_benchmark_rows()) + rows = await session.fetch(SERVICE_ROWS_SELECT) + assert len(rows) == BENCHMARK_ROWS + finally: + await config.close_pool() -def sqlspec_oracle_json_read() -> None: - """Read native JSON values from Oracle through SQLSpec.""" - _run_sqlspec_oracle_json_read() +async def _run_raw_psqlpy_hot_path(scenario: str) -> None: + pool_class = _get_psqlpy_pool_class() + if pool_class is None: + _benchmark_unavailable() + dsn = _postgres_benchmark_dsn() + iterations = _service_loop_iterations() + pool = pool_class(dsn=dsn, max_db_pool_size=max(2, POOL_SIZE)) + try: + conn = await pool.connection() + if scenario == "scalar_param_select": + for i in range(iterations): + res = await conn.fetch(SCALAR_PARAM_SELECT_ASYNCPG_SQL, [i]) + rows = res.result() + assert len(rows) == 1 + elif scenario == "no_param_select": + for _ in range(iterations): + res = await conn.fetch(NO_PARAM_SELECT_SQL) + rows = res.result() + assert len(rows) == 1 + elif scenario == "complex_query": + for ddl in POSTGRES_COMPLEX_DROP_DDL: + await conn.execute(ddl) + for ddl in POSTGRES_COMPLEX_SCHEMA_DDL: + await conn.execute(ddl) + for i in range(iterations): + res = await conn.fetch(POSTGRES_COMPLEX_QUERY_ASYNCPG_SQL, [i]) + res.result() + elif scenario in {"session_per_request", "observability_select"}: + await conn.execute(SERVICE_ROWS_DROP) + await conn.execute(SERVICE_ROWS_CREATE) + await conn.execute_many(SERVICE_ROWS_INSERT_ASYNCPG, [[i, f"value_{i}"] for i in range(100)]) + for i in range(iterations): + res = await conn.fetch(SERVICE_ROWS_SELECT_BY_PAYLOAD_ASYNCPG, [f"value_{i % 100}"]) + res.result() + finally: + pool.close() + + +async def _run_sqlspec_psqlpy_hot_path(scenario: str) -> None: + config_class = _get_psqlpy_config() + if config_class is None: + _benchmark_unavailable() + dsn = _postgres_benchmark_dsn() + iterations = _service_loop_iterations() + spec = SQLSpec() + obs_cfg = ( + ObservabilityConfig(statement_observers=(_noop_statement_observer,)) + if scenario == "observability_select" + else None + ) + config = config_class( + connection_config={"dsn": dsn, "max_db_pool_size": max(2, POOL_SIZE)}, observability_config=obs_cfg + ) + try: + if scenario == "scalar_param_select": + async with spec.provide_session(config) as session: + for i in range(iterations): + val = await session.select_value(SCALAR_PARAM_SELECT_ASYNCPG_SQL, i) + assert val == i + elif scenario == "no_param_select": + async with spec.provide_session(config) as session: + for _ in range(iterations): + res = await session.execute(NO_PARAM_SELECT_SQL) + assert res is not None + elif scenario == "complex_query": + async with spec.provide_session(config) as session: + for ddl in POSTGRES_COMPLEX_DROP_DDL: + await session.execute(ddl) + for ddl in POSTGRES_COMPLEX_SCHEMA_DDL: + await session.execute(ddl) + for i in range(iterations): + await session.execute(POSTGRES_COMPLEX_QUERY_ASYNCPG_SQL, i) + elif scenario == "session_per_request": + async with spec.provide_session(config) as session: + await session.execute(SERVICE_ROWS_DROP) + await session.execute(SERVICE_ROWS_CREATE) + await session.execute_many(SERVICE_ROWS_INSERT_ASYNCPG, [(i, f"value_{i}") for i in range(100)]) + for i in range(iterations): + async with spec.provide_session(config) as session: + await session.fetch_one_or_none(SERVICE_ROWS_SELECT_BY_PAYLOAD_ASYNCPG, (f"value_{i % 100}",)) + elif scenario == "observability_select": + async with spec.provide_session(config) as session: + await session.execute(SERVICE_ROWS_DROP) + await session.execute(SERVICE_ROWS_CREATE) + await session.execute_many(SERVICE_ROWS_INSERT_ASYNCPG, [(i, f"value_{i}") for i in range(100)]) + for i in range(iterations): + await session.fetch_one_or_none(SERVICE_ROWS_SELECT_BY_PAYLOAD_ASYNCPG, (f"value_{i % 100}",)) + finally: + await config.close_pool() + + +def _run_raw_oracle_hot_path(scenario: str) -> None: + oracledb = _get_oracledb() + if oracledb is None: + _benchmark_unavailable() + iterations = _service_loop_iterations() + conn = oracledb.connect(**_oracle_connect_config_from_env()) + try: + with conn.cursor() as cursor: + if scenario == "scalar_param_select": + for i in range(iterations): + cursor.execute(SCALAR_PARAM_SELECT_ORACLE_SQL, (i,)) + row = cursor.fetchone() + assert row is not None + assert row[0] == i + elif scenario == "no_param_select": + for _ in range(iterations): + cursor.execute(NO_PARAM_SELECT_ORACLE_SQL) + rows = cursor.fetchall() + assert len(rows) == 1 + elif scenario == "complex_query": + for tbl in ORACLE_COMPLEX_TABLES: + cursor.execute(_oracle_drop_table_sql(tbl)) + for ddl in ORACLE_COMPLEX_SCHEMA_DDL: + cursor.execute(ddl) + conn.commit() + for i in range(iterations): + cursor.execute(ORACLE_COMPLEX_QUERY_SQL, (i,)) + cursor.fetchall() + elif scenario in {"session_per_request", "observability_select"}: + cursor.execute(_oracle_drop_table_sql(ORACLE_KV_TABLE)) + cursor.execute(ORACLE_KV_CREATE_SQL) + cursor.executemany(ORACLE_KV_INSERT_SQL, [(f"value_{i}",) for i in range(100)]) + conn.commit() + for i in range(iterations): + cursor.execute(ORACLE_KV_SELECT_BY_VALUE_SQL, (f"value_{i % 100}",)) + cursor.fetchone() + finally: + conn.close() + + +def _run_sqlspec_oracle_hot_path(scenario: str) -> None: + from sqlspec.adapters.oracledb import OracleSyncConfig + + iterations = _service_loop_iterations() + spec = SQLSpec() + obs_cfg = ( + ObservabilityConfig(statement_observers=(_noop_statement_observer,)) + if scenario == "observability_select" + else None + ) + config = OracleSyncConfig(connection_config=_oracle_connection_config_from_env(), observability_config=obs_cfg) + try: + if scenario == "scalar_param_select": + with spec.provide_session(config) as session: + for i in range(iterations): + val = session.select_value(SCALAR_PARAM_SELECT_ORACLE_SQL, i) + assert val == i + elif scenario == "no_param_select": + with spec.provide_session(config) as session: + for _ in range(iterations): + res = session.execute(NO_PARAM_SELECT_ORACLE_SQL) + assert res is not None + elif scenario == "complex_query": + with spec.provide_session(config) as session: + for tbl in ORACLE_COMPLEX_TABLES: + session.execute_script(_oracle_drop_table_sql(tbl)) + for ddl in ORACLE_COMPLEX_SCHEMA_DDL: + session.execute(ddl) + for i in range(iterations): + session.execute(ORACLE_COMPLEX_QUERY_SQL, i) + elif scenario == "session_per_request": + with spec.provide_session(config) as session: + session.execute_script(_oracle_drop_table_sql(ORACLE_KV_TABLE)) + session.execute(ORACLE_KV_CREATE_SQL) + session.execute_many(ORACLE_KV_INSERT_SQL, [(f"value_{i}",) for i in range(100)]) + for i in range(iterations): + with spec.provide_session(config) as session: + session.fetch_one_or_none(ORACLE_KV_SELECT_BY_VALUE_SQL, (f"value_{i % 100}",)) + elif scenario == "observability_select": + with spec.provide_session(config) as session: + session.execute_script(_oracle_drop_table_sql(ORACLE_KV_TABLE)) + session.execute(ORACLE_KV_CREATE_SQL) + session.execute_many(ORACLE_KV_INSERT_SQL, [(f"value_{i}",) for i in range(100)]) + for i in range(iterations): + session.fetch_one_or_none(ORACLE_KV_SELECT_BY_VALUE_SQL, (f"value_{i % 100}",)) + _check_pool_leak(config.connection_instance, f"oracle/{scenario}") + config.close_pool() + finally: + if config.connection_instance is not None: + config.close_pool() + + +async def _run_sqlspec_oracle_async_hot_path(scenario: str) -> None: + from sqlspec.adapters.oracledb import OracleAsyncConfig + + iterations = _service_loop_iterations() + spec = SQLSpec() + obs_cfg = ( + ObservabilityConfig(statement_observers=(_noop_statement_observer,)) + if scenario == "observability_select" + else None + ) + config = OracleAsyncConfig(connection_config=_oracle_connection_config_from_env(), observability_config=obs_cfg) + try: + if scenario == "scalar_param_select": + async with spec.provide_session(config) as session: + for i in range(iterations): + val = await session.select_value(SCALAR_PARAM_SELECT_ORACLE_SQL, i) + assert val == i + elif scenario == "no_param_select": + async with spec.provide_session(config) as session: + for _ in range(iterations): + res = await session.execute(NO_PARAM_SELECT_ORACLE_SQL) + assert res is not None + elif scenario == "complex_query": + async with spec.provide_session(config) as session: + for tbl in ORACLE_COMPLEX_TABLES: + await session.execute_script(_oracle_drop_table_sql(tbl)) + for ddl in ORACLE_COMPLEX_SCHEMA_DDL: + await session.execute(ddl) + for i in range(iterations): + await session.execute(ORACLE_COMPLEX_QUERY_SQL, i) + elif scenario == "session_per_request": + async with spec.provide_session(config) as session: + await session.execute_script(_oracle_drop_table_sql(ORACLE_KV_TABLE)) + await session.execute(ORACLE_KV_CREATE_SQL) + await session.execute_many(ORACLE_KV_INSERT_SQL, [(f"value_{i}",) for i in range(100)]) + for i in range(iterations): + async with spec.provide_session(config) as session: + await session.fetch_one_or_none(ORACLE_KV_SELECT_BY_VALUE_SQL, (f"value_{i % 100}",)) + elif scenario == "observability_select": + async with spec.provide_session(config) as session: + await session.execute_script(_oracle_drop_table_sql(ORACLE_KV_TABLE)) + await session.execute(ORACLE_KV_CREATE_SQL) + await session.execute_many(ORACLE_KV_INSERT_SQL, [(f"value_{i}",) for i in range(100)]) + for i in range(iterations): + await session.fetch_one_or_none(ORACLE_KV_SELECT_BY_VALUE_SQL, (f"value_{i % 100}",)) + _check_pool_leak(config.connection_instance, f"oracle_async/{scenario}") + await config.close_pool() + finally: + if config.connection_instance is not None: + await config.close_pool() + +SQLSPEC_LABEL = "sqlspec (mypyc)" if _is_compiled() else "sqlspec" SCENARIO_REGISTRY: dict[tuple[str, str, str], Any] = { - # SQLite scenarios ("raw", "sqlite", "initialization"): raw_sqlite_initialization, ("raw", "sqlite", "write_heavy"): raw_sqlite_write_heavy, ("raw", "sqlite", "read_heavy"): raw_sqlite_read_heavy, @@ -2898,7 +4502,6 @@ def sqlspec_oracle_json_read() -> None: ("sqlalchemy", "sqlite", "read_heavy"): sqlalchemy_sqlite_read_heavy, ("sqlalchemy", "sqlite", "iterative_inserts"): sqlalchemy_sqlite_iterative_inserts, ("sqlalchemy", "sqlite", "repeated_queries"): sqlalchemy_sqlite_repeated_queries, - # DuckDB scenarios ("raw", "duckdb", "initialization"): raw_duckdb_initialization, ("raw", "duckdb", "write_heavy"): raw_duckdb_write_heavy, ("raw", "duckdb", "read_heavy"): raw_duckdb_read_heavy, @@ -2914,7 +4517,6 @@ def sqlspec_oracle_json_read() -> None: ("sqlalchemy", "duckdb", "read_heavy"): sqlalchemy_duckdb_read_heavy, ("sqlalchemy", "duckdb", "iterative_inserts"): sqlalchemy_duckdb_iterative_inserts, ("sqlalchemy", "duckdb", "repeated_queries"): sqlalchemy_duckdb_repeated_queries, - # Aiosqlite scenarios ("raw", "aiosqlite", "initialization"): raw_aiosqlite_initialization, ("raw", "aiosqlite", "write_heavy"): raw_aiosqlite_write_heavy, ("raw", "aiosqlite", "read_heavy"): raw_aiosqlite_read_heavy, @@ -2940,20 +4542,20 @@ def sqlspec_oracle_json_read() -> None: ("sqlspec", "adbc", "rows"): sqlspec_adbc_rows, ("raw", "duckdb", "bulk"): raw_duckdb_bulk, ("sqlspec", "duckdb", "bulk"): sqlspec_duckdb_bulk, - # PostgreSQL and CockroachDB service row scenarios ("raw", "psycopg_sync", "rows"): raw_psycopg_sync_rows, ("sqlspec", "psycopg_sync", "rows"): sqlspec_psycopg_sync_rows, ("raw", "psycopg_async", "rows"): raw_psycopg_async_rows, ("sqlspec", "psycopg_async", "rows"): sqlspec_psycopg_async_rows, ("raw", "asyncpg", "rows"): raw_asyncpg_rows, ("sqlspec", "asyncpg", "rows"): sqlspec_asyncpg_rows, + ("raw", "psqlpy", "rows"): raw_psqlpy_rows, + ("sqlspec", "psqlpy", "rows"): sqlspec_psqlpy_rows, ("raw", "cockroach_psycopg_sync", "rows"): raw_cockroach_psycopg_sync_rows, ("sqlspec", "cockroach_psycopg_sync", "rows"): sqlspec_cockroach_psycopg_sync_rows, ("raw", "cockroach_psycopg_async", "rows"): raw_cockroach_psycopg_async_rows, ("sqlspec", "cockroach_psycopg_async", "rows"): sqlspec_cockroach_psycopg_async_rows, ("raw", "cockroach_asyncpg", "rows"): raw_cockroach_asyncpg_rows, ("sqlspec", "cockroach_asyncpg", "rows"): sqlspec_cockroach_asyncpg_rows, - # Asyncpg scenarios ("raw", "asyncpg", "initialization"): raw_asyncpg_initialization, ("raw", "asyncpg", "write_heavy"): raw_asyncpg_write_heavy, ("raw", "asyncpg", "read_heavy"): raw_asyncpg_read_heavy, @@ -2969,7 +4571,6 @@ def sqlspec_oracle_json_read() -> None: ("sqlalchemy", "asyncpg", "read_heavy"): sqlalchemy_asyncpg_read_heavy, ("sqlalchemy", "asyncpg", "iterative_inserts"): sqlalchemy_asyncpg_iterative_inserts, ("sqlalchemy", "asyncpg", "repeated_queries"): sqlalchemy_asyncpg_repeated_queries, - # Extended SQLite scenarios (raw vs sqlspec only) ("raw", "sqlite", "dict_key_transform"): raw_sqlite_dict_key_transform, ("sqlspec", "sqlite", "dict_key_transform"): sqlspec_sqlite_dict_key_transform, ("raw", "sqlite", "schema_mapping"): raw_sqlite_schema_mapping, @@ -2980,7 +4581,76 @@ def sqlspec_oracle_json_read() -> None: ("sqlspec", "sqlite", "complex_parameters"): sqlspec_sqlite_complex_parameters, ("raw", "sqlite", "thin_path_stress"): raw_sqlite_thin_path_stress, ("sqlspec", "sqlite", "thin_path_stress"): sqlspec_sqlite_thin_path_stress, - # Extended Oracle LOB fetch scenarios + ("raw", "sqlite", "scalar_param_select"): raw_sqlite_scalar_param_select, + ("sqlspec", "sqlite", "scalar_param_select"): sqlspec_sqlite_scalar_param_select, + ("raw", "sqlite", "no_param_select"): raw_sqlite_no_param_select, + ("sqlspec", "sqlite", "no_param_select"): sqlspec_sqlite_no_param_select, + ("raw", "sqlite", "complex_query"): raw_sqlite_complex_query, + ("sqlspec", "sqlite", "complex_query"): sqlspec_sqlite_complex_query, + ("raw", "sqlite", "session_per_request"): raw_sqlite_session_per_request, + ("sqlspec", "sqlite", "session_per_request"): sqlspec_sqlite_session_per_request, + ("raw", "sqlite", "observability_select"): raw_sqlite_observability_select, + ("sqlspec", "sqlite", "observability_select"): sqlspec_sqlite_observability_select, + ("raw", "aiosqlite", "scalar_param_select"): raw_aiosqlite_scalar_param_select, + ("sqlspec", "aiosqlite", "scalar_param_select"): sqlspec_aiosqlite_scalar_param_select, + ("raw", "aiosqlite", "no_param_select"): raw_aiosqlite_no_param_select, + ("sqlspec", "aiosqlite", "no_param_select"): sqlspec_aiosqlite_no_param_select, + ("raw", "aiosqlite", "complex_query"): raw_aiosqlite_complex_query, + ("sqlspec", "aiosqlite", "complex_query"): sqlspec_aiosqlite_complex_query, + ("raw", "aiosqlite", "session_per_request"): raw_aiosqlite_session_per_request, + ("sqlspec", "aiosqlite", "session_per_request"): sqlspec_aiosqlite_session_per_request, + ("raw", "aiosqlite", "observability_select"): raw_aiosqlite_observability_select, + ("sqlspec", "aiosqlite", "observability_select"): sqlspec_aiosqlite_observability_select, + ("raw", "duckdb", "scalar_param_select"): raw_duckdb_scalar_param_select, + ("sqlspec", "duckdb", "scalar_param_select"): sqlspec_duckdb_scalar_param_select, + ("raw", "duckdb", "no_param_select"): raw_duckdb_no_param_select, + ("sqlspec", "duckdb", "no_param_select"): sqlspec_duckdb_no_param_select, + ("raw", "duckdb", "complex_query"): raw_duckdb_complex_query, + ("sqlspec", "duckdb", "complex_query"): sqlspec_duckdb_complex_query, + ("raw", "duckdb", "session_per_request"): raw_duckdb_session_per_request, + ("sqlspec", "duckdb", "session_per_request"): sqlspec_duckdb_session_per_request, + ("raw", "duckdb", "observability_select"): raw_duckdb_observability_select, + ("sqlspec", "duckdb", "observability_select"): sqlspec_duckdb_observability_select, + ("raw", "asyncpg", "scalar_param_select"): raw_asyncpg_scalar_param_select, + ("sqlspec", "asyncpg", "scalar_param_select"): sqlspec_asyncpg_scalar_param_select, + ("raw", "asyncpg", "no_param_select"): raw_asyncpg_no_param_select, + ("sqlspec", "asyncpg", "no_param_select"): sqlspec_asyncpg_no_param_select, + ("raw", "asyncpg", "complex_query"): raw_asyncpg_complex_query, + ("sqlspec", "asyncpg", "complex_query"): sqlspec_asyncpg_complex_query, + ("raw", "asyncpg", "session_per_request"): raw_asyncpg_session_per_request, + ("sqlspec", "asyncpg", "session_per_request"): sqlspec_asyncpg_session_per_request, + ("raw", "asyncpg", "observability_select"): raw_asyncpg_observability_select, + ("sqlspec", "asyncpg", "observability_select"): sqlspec_asyncpg_observability_select, + ("raw", "psycopg_sync", "scalar_param_select"): raw_psycopg_sync_scalar_param_select, + ("sqlspec", "psycopg_sync", "scalar_param_select"): sqlspec_psycopg_sync_scalar_param_select, + ("raw", "psycopg_sync", "no_param_select"): raw_psycopg_sync_no_param_select, + ("sqlspec", "psycopg_sync", "no_param_select"): sqlspec_psycopg_sync_no_param_select, + ("raw", "psycopg_sync", "complex_query"): raw_psycopg_sync_complex_query, + ("sqlspec", "psycopg_sync", "complex_query"): sqlspec_psycopg_sync_complex_query, + ("raw", "psycopg_sync", "session_per_request"): raw_psycopg_sync_session_per_request, + ("sqlspec", "psycopg_sync", "session_per_request"): sqlspec_psycopg_sync_session_per_request, + ("raw", "psycopg_sync", "observability_select"): raw_psycopg_sync_observability_select, + ("sqlspec", "psycopg_sync", "observability_select"): sqlspec_psycopg_sync_observability_select, + ("raw", "psycopg_async", "scalar_param_select"): raw_psycopg_async_scalar_param_select, + ("sqlspec", "psycopg_async", "scalar_param_select"): sqlspec_psycopg_async_scalar_param_select, + ("raw", "psycopg_async", "no_param_select"): raw_psycopg_async_no_param_select, + ("sqlspec", "psycopg_async", "no_param_select"): sqlspec_psycopg_async_no_param_select, + ("raw", "psycopg_async", "complex_query"): raw_psycopg_async_complex_query, + ("sqlspec", "psycopg_async", "complex_query"): sqlspec_psycopg_async_complex_query, + ("raw", "psycopg_async", "session_per_request"): raw_psycopg_async_session_per_request, + ("sqlspec", "psycopg_async", "session_per_request"): sqlspec_psycopg_async_session_per_request, + ("raw", "psycopg_async", "observability_select"): raw_psycopg_async_observability_select, + ("sqlspec", "psycopg_async", "observability_select"): sqlspec_psycopg_async_observability_select, + ("raw", "psqlpy", "scalar_param_select"): raw_psqlpy_scalar_param_select, + ("sqlspec", "psqlpy", "scalar_param_select"): sqlspec_psqlpy_scalar_param_select, + ("raw", "psqlpy", "no_param_select"): raw_psqlpy_no_param_select, + ("sqlspec", "psqlpy", "no_param_select"): sqlspec_psqlpy_no_param_select, + ("raw", "psqlpy", "complex_query"): raw_psqlpy_complex_query, + ("sqlspec", "psqlpy", "complex_query"): sqlspec_psqlpy_complex_query, + ("raw", "psqlpy", "session_per_request"): raw_psqlpy_session_per_request, + ("sqlspec", "psqlpy", "session_per_request"): sqlspec_psqlpy_session_per_request, + ("raw", "psqlpy", "observability_select"): raw_psqlpy_observability_select, + ("sqlspec", "psqlpy", "observability_select"): sqlspec_psqlpy_observability_select, ("raw", "oracle", "lob_fetch_1k"): raw_oracle_lob_fetch_1k, ("raw", "oracle", "lob_fetch_100k"): raw_oracle_lob_fetch_100k, ("sqlspec", "oracle", "lob_fetch_1k"): sqlspec_oracle_lob_fetch_1k, @@ -2994,48 +4664,22 @@ def sqlspec_oracle_json_read() -> None: ("sqlspec_native_json", "oracle", "json_write"): sqlspec_oracle_native_json_write, ("sqlspec_serialized_json", "oracle", "json_write"): sqlspec_oracle_serialized_json_write, ("sqlspec", "oracle", "json_read"): sqlspec_oracle_json_read, + ("raw", "oracle", "scalar_param_select"): raw_oracle_scalar_param_select, + ("sqlspec", "oracle", "scalar_param_select"): sqlspec_oracle_scalar_param_select, + ("sqlspec_async", "oracle", "scalar_param_select"): sqlspec_oracle_async_scalar_param_select, + ("raw", "oracle", "no_param_select"): raw_oracle_no_param_select, + ("sqlspec", "oracle", "no_param_select"): sqlspec_oracle_no_param_select, + ("sqlspec_async", "oracle", "no_param_select"): sqlspec_oracle_async_no_param_select, + ("raw", "oracle", "complex_query"): raw_oracle_complex_query, + ("sqlspec", "oracle", "complex_query"): sqlspec_oracle_complex_query, + ("sqlspec_async", "oracle", "complex_query"): sqlspec_oracle_async_complex_query, + ("raw", "oracle", "session_per_request"): raw_oracle_session_per_request, + ("sqlspec", "oracle", "session_per_request"): sqlspec_oracle_session_per_request, + ("sqlspec_async", "oracle", "session_per_request"): sqlspec_oracle_async_session_per_request, + ("raw", "oracle", "observability_select"): raw_oracle_observability_select, + ("sqlspec", "oracle", "observability_select"): sqlspec_oracle_observability_select, + ("sqlspec_async", "oracle", "observability_select"): sqlspec_oracle_async_observability_select, } - -def print_benchmark_table(results: list[dict[str, Any]]) -> None: - console = Console() - table = Table(title="Benchmark Results") - table.add_column("Driver", style="cyan", no_wrap=True) - table.add_column("Library", style="magenta") - table.add_column("Scenario", style="green") - table.add_column("Time (s)", justify="right", style="yellow") - table.add_column("Stddev", justify="right") - table.add_column("IQR", justify="right") - table.add_column("Noise", justify="center") - table.add_column("% Slower vs Raw", justify="right", style="red") - - # Check if any result has multiple iterations - multi_iter = any(len(row.get("times", [])) > 1 for row in results) - - # Build a lookup for raw times: {(driver, scenario): time} - raw_times: dict[tuple[str, str], float] = {} - for row in results: - if row["library"] == "raw": - raw_times[row["driver"], row["scenario"]] = row["time"] - - for row in results: - driver = row["driver"] - scenario = row["scenario"] - lib = row["library"] - t = row["time"] - times = row.get("times", [t]) - stddev = float(row.get("stddev", 0.0)) - iqr = float(row.get("iqr", 0.0)) - noise = "yes" if row.get("noisy", False) else "" - if lib == "raw": - percent_slower = "---" - else: - raw_time = raw_times.get((driver, scenario)) - percent_slower = f"{100 * (t - raw_time) / raw_time:.1f}%" if raw_time and raw_time > 0 else "n/a" - time_str = f"{t:.4f} ({min(times):.4f}-{max(times):.4f})" if multi_iter and len(times) > 1 else f"{t:.4f}" - table.add_row(driver, lib, scenario, time_str, f"{stddev:.4f}", f"{iqr:.4f}", noise, percent_slower) - console.print(table) - - if __name__ == "__main__": main() diff --git a/tools/scripts/bench_compare.py b/tools/scripts/bench_compare.py index ef0d5a83b..50edaad36 100644 --- a/tools/scripts/bench_compare.py +++ b/tools/scripts/bench_compare.py @@ -2,7 +2,7 @@ Accepts two JSON result files (baseline and current) produced by ``bench.py --json-output`` and produces a rich table showing per-scenario -comparison with absolute and percentage changes. +comparison with absolute or relative ratio changes and metadata parity checks. Run with:: @@ -20,102 +20,202 @@ BenchmarkKey = tuple[str, str, str] -__all__ = ("main", "print_comparison_table") +HARD_METADATA_KEYS: tuple[str, ...] = ("rows", "pool_size", "mypyc_compiled") +SOFT_METADATA_KEYS: tuple[str, ...] = ("iterations", "warmup", "environment") + +__all__ = ( + "HARD_METADATA_KEYS", + "SOFT_METADATA_KEYS", + "compute_comparison_rows", + "format_run_args", + "main", + "print_comparison_table", + "validate_metadata_parity", +) -def _load_results(path: str) -> dict[str, Any]: - """Load and validate a benchmark JSON file. +def validate_metadata_parity(baseline: dict[str, Any], current: dict[str, Any]) -> tuple[list[str], list[str]]: + """Compare run metadata between baseline and current benchmark payloads. Args: - path: Path to the JSON file. + baseline: Parsed baseline benchmark JSON payload. + current: Parsed current benchmark JSON payload. Returns: - Parsed JSON data. - - Raises: - click.ClickException: If the file cannot be loaded or is invalid. + Tuple of (hard_mismatches, soft_warnings). """ - try: - with Path(path).open() as f: - data = json.load(f) - except (OSError, json.JSONDecodeError) as exc: - msg = f"Failed to load {path}: {exc}" - raise click.ClickException(msg) from exc - - if "results" not in data: - msg = f"Invalid benchmark file {path}: missing 'results' key" - raise click.ClickException(msg) - - return cast("dict[str, Any]", data) - - -def _build_lookup(data: dict[str, Any]) -> dict[BenchmarkKey, dict[str, Any]]: - """Build a lookup table from benchmark results. + base_meta = baseline.get("metadata", {}) + curr_meta = current.get("metadata", {}) + hard_mismatches = [ + f"{key}: baseline={base_meta[key]!r} != current={curr_meta[key]!r}" + for key in HARD_METADATA_KEYS + if key in base_meta and key in curr_meta and base_meta[key] != curr_meta[key] + ] + soft_warnings = [ + f"{key}: baseline={base_meta[key]!r} != current={curr_meta[key]!r}" + for key in SOFT_METADATA_KEYS + if key in base_meta and key in curr_meta and base_meta[key] != curr_meta[key] + ] + return hard_mismatches, soft_warnings + + +def format_run_args(baseline: dict[str, Any]) -> str: + """Build bench.py CLI flags matching a baseline JSON file's metadata. Args: - data: Parsed benchmark JSON data. + baseline: Parsed baseline benchmark JSON payload. Returns: - Dictionary mapping (driver, library, scenario) to result payload. + Space-joined CLI argument string for ``bench.py``. """ - lookup: dict[BenchmarkKey, dict[str, Any]] = {} - for result in data["results"]: - key = (result["driver"], result.get("library_key", result["library"]), result["scenario"]) - lookup[key] = result - return lookup - - -def _missing_required_scenarios(data: dict[str, Any], required: set[BenchmarkKey]) -> set[BenchmarkKey]: - """Return required benchmark identities absent from a result payload.""" - return required - set(_build_lookup(data)) - - -def _format_time(result: dict[str, Any]) -> str: - time_value = float(result["time"]) - stddev = result.get("stddev") - if isinstance(stddev, int | float): - return f"{time_value:.4f} +/- {float(stddev):.4f}" - return f"{time_value:.4f}" - - -def _status_text(change_pct: float, threshold: float = 5.0) -> tuple[str, str]: - """Determine status text and style based on percentage change. + meta = baseline.get("metadata", {}) + args: list[str] = [] + if "rows" in meta: + args.extend(("--rows", str(meta["rows"]))) + if "iterations" in meta: + args.extend(("--iterations", str(meta["iterations"]))) + if "pool_size" in meta: + args.extend(("--pool-size", str(meta["pool_size"]))) + if "warmup" in meta: + args.extend(("--warmup", str(meta["warmup"]))) + return " ".join(args) + + +def compute_comparison_rows( + baseline: dict[str, Any], current: dict[str, Any], *, mode: str = "absolute", threshold: float = 5.0 +) -> list[dict[str, Any]]: + """Compute per-scenario comparison rows in absolute or ratio mode. Args: - change_pct: Percentage change (positive = slower, negative = faster). - threshold: Percentage threshold for considering a change significant. + baseline: Parsed baseline benchmark JSON data. + current: Parsed current benchmark JSON data. + mode: Comparison mode (``"absolute"`` or ``"ratio"``). + threshold: Percentage threshold for flagging changes as significant. Returns: - Tuple of (status_text, rich_style). + List of comparison row dictionaries ordered by (driver, library_key, scenario). """ - if change_pct < -threshold: - return "IMPROVED", "bold green" - if change_pct > threshold: - return "REGRESSED", "bold red" - return "unchanged", "dim" + baseline_lookup = _build_lookup(baseline) + current_lookup = _build_lookup(current) + all_keys = sorted(set(baseline_lookup.keys()) | set(current_lookup.keys())) + + rows: list[dict[str, Any]] = [] + for key in all_keys: + driver, library_key, scenario = key + baseline_result = baseline_lookup.get(key) + current_result = current_lookup.get(key) + library_label = (current_result or baseline_result or {}).get("library", library_key) + if baseline_result is None: + assert current_result is not None + rows.append({ + "driver": driver, + "library_key": library_key, + "library_label": library_label, + "scenario": scenario, + "baseline_result": None, + "current_result": current_result, + "metric_kind": "new", + "baseline_metric": None, + "current_metric": float(current_result["time"]), + "abs_change": None, + "pct_change": None, + "status_text": "NEW", + "status_style": "dim", + }) + continue + + if current_result is None: + rows.append({ + "driver": driver, + "library_key": library_key, + "library_label": library_label, + "scenario": scenario, + "baseline_result": baseline_result, + "current_result": None, + "metric_kind": "removed", + "baseline_metric": float(baseline_result["time"]), + "current_metric": None, + "abs_change": None, + "pct_change": None, + "status_text": "REMOVED", + "status_style": "dim", + }) + continue -def print_comparison_table(baseline: dict[str, Any], current: dict[str, Any], *, threshold: float = 5.0) -> None: + baseline_time = float(baseline_result["time"]) + current_time = float(current_result["time"]) + abs_change = current_time - baseline_time + + metric_kind = "absolute" + baseline_metric = baseline_time + current_metric = current_time + + if mode == "ratio": + raw_key = (driver, "raw", scenario) + base_raw = baseline_lookup.get(raw_key) + curr_raw = current_lookup.get(raw_key) + if library_key == "raw": + metric_kind = "raw-ref" + elif ( + base_raw is not None + and curr_raw is not None + and float(base_raw["time"]) > 0 + and float(curr_raw["time"]) > 0 + ): + metric_kind = "ratio" + baseline_metric = baseline_time / float(base_raw["time"]) + current_metric = current_time / float(curr_raw["time"]) + else: + metric_kind = "abs-fallback" + + if metric_kind == "raw-ref": + pct_change = ((current_time - baseline_time) / baseline_time * 100.0) if baseline_time > 0 else 0.0 + status_text, status_style = "unchanged", "dim" + else: + delta_metric = current_metric - baseline_metric + pct_change = (delta_metric / baseline_metric * 100.0) if baseline_metric > 0 else 0.0 + status_text, status_style = _status_text(pct_change, threshold) + + rows.append({ + "driver": driver, + "library_key": library_key, + "library_label": library_label, + "scenario": scenario, + "baseline_result": baseline_result, + "current_result": current_result, + "metric_kind": metric_kind, + "baseline_metric": baseline_metric, + "current_metric": current_metric, + "abs_change": abs_change, + "pct_change": pct_change, + "status_text": status_text, + "status_style": status_style, + }) + + return rows + + +def print_comparison_table( + baseline: dict[str, Any], current: dict[str, Any], *, threshold: float = 5.0, mode: str = "absolute" +) -> None: """Print a rich comparison table between two benchmark runs. Args: baseline: Parsed baseline benchmark JSON data. current: Parsed current benchmark JSON data. threshold: Percentage threshold for flagging changes as significant. + mode: Comparison mode (``"absolute"`` or ``"ratio"``). """ console = Console() + rows = compute_comparison_rows(baseline, current, mode=mode, threshold=threshold) - baseline_lookup = _build_lookup(baseline) - current_lookup = _build_lookup(current) - - # Collect all keys from both runs - all_keys = sorted(set(baseline_lookup.keys()) | set(current_lookup.keys())) - - if not all_keys: + if not rows: console.print("[yellow]No benchmark results to compare.[/yellow]") return - table = Table(title="Benchmark Comparison: Baseline vs Current") + title_suffix = " (Ratio Mode)" if mode == "ratio" else "" + table = Table(title=f"Benchmark Comparison: Baseline vs Current{title_suffix}") table.add_column("Driver", style="cyan", no_wrap=True) table.add_column("Library", style="magenta") table.add_column("Scenario", style="green") @@ -129,20 +229,22 @@ def print_comparison_table(baseline: dict[str, Any], current: dict[str, Any], *, regressed_count = 0 unchanged_count = 0 - for key in all_keys: - driver, library, scenario = key - baseline_result = baseline_lookup.get(key) - current_result = current_lookup.get(key) - library_label = (current_result or baseline_result or {}).get("library", library) + for row in rows: + driver = row["driver"] + library_label = row["library_label"] + scenario = row["scenario"] + baseline_result = row["baseline_result"] + current_result = row["current_result"] + status_text = row["status_text"] + status_style = row["status_style"] - if baseline_result is None: - assert current_result is not None + if baseline_result is None and current_result is not None: table.add_row( driver, library_label, scenario, "n/a", _format_time(current_result), "n/a", "n/a", "[dim]NEW[/dim]" ) continue - if current_result is None: + if current_result is None and baseline_result is not None: table.add_row( driver, library_label, @@ -155,12 +257,8 @@ def print_comparison_table(baseline: dict[str, Any], current: dict[str, Any], *, ) continue - baseline_time = float(baseline_result["time"]) - current_time = float(current_result["time"]) - abs_change = current_time - baseline_time - pct_change = abs_change / baseline_time * 100.0 if baseline_time > 0 else 0.0 - - status_text, status_style = _status_text(pct_change, threshold) + abs_change = float(row["abs_change"]) + pct_change = float(row["pct_change"]) if status_text == "IMPROVED": improved_count += 1 @@ -169,8 +267,7 @@ def print_comparison_table(baseline: dict[str, Any], current: dict[str, Any], *, else: unchanged_count += 1 - # Color the change values - change_style = "green" if abs_change < 0 else ("red" if abs_change > 0 else "dim") + change_style = "green" if pct_change < 0 else ("red" if pct_change > 0 else "dim") table.add_row( driver, @@ -185,7 +282,6 @@ def print_comparison_table(baseline: dict[str, Any], current: dict[str, Any], *, console.print(table) - # Print summary console.print() total = improved_count + regressed_count + unchanged_count console.print(f" Total scenarios compared: {total}") @@ -193,7 +289,6 @@ def print_comparison_table(baseline: dict[str, Any], current: dict[str, Any], *, console.print(f" [red]Regressed: {regressed_count}[/red]") console.print(f" [dim]Unchanged: {unchanged_count}[/dim]") - # Print metadata comparison baseline_meta = baseline.get("metadata", {}) current_meta = current.get("metadata", {}) if baseline_meta or current_meta: @@ -215,12 +310,30 @@ def print_comparison_table(baseline: dict[str, Any], current: dict[str, Any], *, @click.command() @click.argument("baseline", type=click.Path(exists=True)) -@click.argument("current", type=click.Path(exists=True)) +@click.argument("current", required=False, type=click.Path(exists=True)) @click.option( "--threshold", default=5.0, show_default=True, help="Percentage threshold for flagging changes as significant" ) -def main(baseline: str, current: str, threshold: float) -> None: - """Compare two benchmark result JSON files. +@click.option( + "--mode", + type=click.Choice(["absolute", "ratio"]), + default="absolute", + show_default=True, + help="Comparison mode: absolute seconds or relative sqlspec/raw ratio", +) +@click.option( + "--allow-metadata-mismatch", + is_flag=True, + default=False, + help="Proceed even when hard metadata keys (rows, pool_size, mypyc_compiled) differ", +) +@click.option( + "--print-run-args", is_flag=True, default=False, help="Print bench.py CLI flags matching BASELINE metadata and exit" +) +def main( + baseline: str, current: str | None, threshold: float, mode: str, allow_metadata_mismatch: bool, print_run_args: bool +) -> None: + """Compare two benchmark result JSON files or print baseline run args. BASELINE is the path to the baseline benchmark JSON file. CURRENT is the path to the current benchmark JSON file. @@ -228,30 +341,114 @@ def main(baseline: str, current: str, threshold: float) -> None: Both files should be produced by ``bench.py --json-output ``. """ baseline_data = _load_results(baseline) + if print_run_args: + click.echo(format_run_args(baseline_data)) + return + + if current is None: + raise click.ClickException("Missing argument 'CURRENT'.") + current_data = _load_results(current) + hard_mismatches, soft_warnings = validate_metadata_parity(baseline_data, current_data) + + for warning in soft_warnings: + click.secho(f"Metadata warning: {warning}", fg="yellow") - print_comparison_table(baseline_data, current_data, threshold=threshold) + if hard_mismatches: + for mismatch in hard_mismatches: + click.secho(f"Metadata mismatch: {mismatch}", fg="red") + if not allow_metadata_mismatch: + sys.exit(2) + + print_comparison_table(baseline_data, current_data, threshold=threshold, mode=mode) - # Exit with non-zero if any regressions detected - current_lookup = _build_lookup(current_data) baseline_lookup = _build_lookup(baseline_data) missing = _missing_required_scenarios(current_data, set(baseline_lookup)) if missing: missing_text = ", ".join("/".join(key) for key in sorted(missing)) click.secho(f"\nMissing required benchmark scenario(s): {missing_text}", fg="red") sys.exit(1) - regressions = 0 - for key in set(baseline_lookup.keys()) & set(current_lookup.keys()): - bt = float(baseline_lookup[key]["time"]) - ct = float(current_lookup[key]["time"]) - if bt > 0 and ((ct - bt) / bt) * 100.0 > threshold: - regressions += 1 + + rows = compute_comparison_rows(baseline_data, current_data, mode=mode, threshold=threshold) + regressions = sum(1 for row in rows if row["status_text"] == "REGRESSED") if regressions > 0: click.secho(f"\n{regressions} regression(s) detected (>{threshold}% slower).", fg="red") sys.exit(1) - else: - click.secho("\nNo regressions detected.", fg="green") + click.secho("\nNo regressions detected.", fg="green") + + +def _load_results(path: str) -> dict[str, Any]: + """Load and validate a benchmark JSON file. + + Args: + path: Path to the JSON file. + + Returns: + Parsed JSON data. + + Raises: + click.ClickException: If the file cannot be loaded or is invalid. + """ + try: + with Path(path).open() as f: + data = json.load(f) + except (OSError, json.JSONDecodeError) as exc: + msg = f"Failed to load {path}: {exc}" + raise click.ClickException(msg) from exc + + if "results" not in data: + msg = f"Invalid benchmark file {path}: missing 'results' key" + raise click.ClickException(msg) + + return cast("dict[str, Any]", data) + + +def _build_lookup(data: dict[str, Any]) -> dict[BenchmarkKey, dict[str, Any]]: + """Build a lookup table from benchmark results. + + Args: + data: Parsed benchmark JSON data. + + Returns: + Dictionary mapping (driver, library, scenario) to result payload. + """ + lookup: dict[BenchmarkKey, dict[str, Any]] = {} + for result in data["results"]: + key = (result["driver"], result.get("library_key", result["library"]), result["scenario"]) + lookup[key] = result + return lookup + + +def _missing_required_scenarios(data: dict[str, Any], required: set[BenchmarkKey]) -> set[BenchmarkKey]: + """Return required benchmark identities absent from a result payload.""" + return required - set(_build_lookup(data)) + + +def _format_time(result: dict[str, Any]) -> str: + """Format a benchmark result's time and optional stddev.""" + time_value = float(result["time"]) + stddev = result.get("stddev") + if isinstance(stddev, int | float): + return f"{time_value:.4f} +/- {float(stddev):.4f}" + return f"{time_value:.4f}" + + +def _status_text(change_pct: float, threshold: float = 5.0) -> tuple[str, str]: + """Determine status text and style based on percentage change. + + Args: + change_pct: Percentage change (positive = slower, negative = faster). + threshold: Percentage threshold for considering a change significant. + + Returns: + Tuple of (status_text, rich_style). + """ + if change_pct < -threshold: + return "IMPROVED", "bold green" + if change_pct > threshold: + return "REGRESSED", "bold red" + return "unchanged", "dim" if __name__ == "__main__": diff --git a/tools/scripts/bench_gate.py b/tools/scripts/bench_gate.py index bcc4de21b..051d590e5 100644 --- a/tools/scripts/bench_gate.py +++ b/tools/scripts/bench_gate.py @@ -14,13 +14,14 @@ uv run python tools/scripts/bench_gate.py uv run python tools/scripts/bench_gate.py --rows 5000 --iterations 5 - uv run python tools/scripts/bench_gate.py --threshold-iterative 50 # stricter + uv run python tools/scripts/bench_gate.py --threshold-iterative 50 """ import importlib.util import json import sys import time +from contextlib import suppress from pathlib import Path from typing import Any, cast @@ -39,8 +40,6 @@ "run_gate", ) - -# Import bench.py from the same directory, regardless of working directory _bench_path = Path(__file__).parent / "bench.py" _spec = importlib.util.spec_from_file_location("bench", _bench_path) assert _spec is not None @@ -48,10 +47,6 @@ bench_mod = importlib.util.module_from_spec(_spec) _spec.loader.exec_module(bench_mod) -# --------------------------------------------------------------------------- -# Default overhead thresholds (percentage of raw time) -# --------------------------------------------------------------------------- - DEFAULT_THRESHOLDS: dict[str, float] = { "iterative_inserts": 100.0, "repeated_queries": 5.0, @@ -59,11 +54,8 @@ "read_heavy": 12.0, } -# Core scenarios to gate on GATE_SCENARIOS = ["iterative_inserts", "repeated_queries", "write_heavy", "read_heavy"] -# PRD benchmark matrix for mypyc expansion work. This keeps the benchmark -# expectations next to the scripts that actually exercise them. BENCHMARK_SCENARIO_MATRIX: dict[str, dict[str, str | tuple[str, ...]]] = { "parameter_pipeline": { "tracked_by": "tools/scripts/bench_subsystems.py + tools/scripts/bench.py", @@ -138,20 +130,16 @@ def run_gate( Returns: Tuple of (results list, all_passed boolean). """ - # Set bench.py global rows cast("Any", bench_mod).ROWS_TO_INSERT = rows - # Run benchmarks using the same infrastructure as bench.py errors: list[str] = [] bench_results = bench_mod.run_benchmark("sqlite", errors, iterations=iterations, warmup=warmup) - # Build lookup: (library, scenario) -> median time time_lookup: dict[tuple[str, str], float] = {} for result in bench_results: key = (result["library"], result["scenario"]) time_lookup[key] = result["time"] - # Determine sqlspec label (may include "(mypyc)") sqlspec_label = bench_mod.SQLSPEC_LABEL gate_results: list[dict[str, Any]] = [] @@ -240,32 +228,6 @@ def print_gate_table(results: list[dict[str, Any]]) -> None: console.print(table) -def _write_json_results( - results: list[dict[str, Any]], - output_path: str | Path, - *, - rows: int, - iterations: int, - warmup: int, - thresholds: dict[str, float], - all_passed: bool, -) -> None: - """Write a performance gate report to JSON.""" - output = { - "metadata": { - "rows": rows, - "iterations": iterations, - "warmup": warmup, - "mypyc_compiled": bench_mod._is_compiled(), - "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"), - }, - "thresholds": thresholds, - "all_passed": all_passed, - "results": results, - } - Path(output_path).write_text(json.dumps(output, indent=2, sort_keys=True)) - - @click.command() @click.option("--rows", default=10_000, show_default=True, help="Number of rows per scenario") @click.option( @@ -281,6 +243,12 @@ def _write_json_results( help="Number of warmup iterations (not timed)", ) @click.option("--json-output", default=None, type=click.Path(), help="Write gate results to a JSON file") +@click.option( + "--include-subsystems/--no-include-subsystems", + default=False, + show_default=True, + help="Also run and display subsystem and stub-driver micro-benchmarks (report-only)", +) @click.option( "--threshold-iterative", "threshold_iterative", @@ -313,6 +281,7 @@ def main( rows: int, iterations: int, warmup: int, + include_subsystems: bool, threshold_iterative: float, threshold_repeated: float, threshold_write: float, @@ -341,6 +310,9 @@ def main( results, all_passed = run_gate(rows=rows, iterations=iterations, warmup=warmup, thresholds=thresholds) print_gate_table(results) + if include_subsystems: + _run_report_only_subsystems(iterations=max(100, rows // 10), warmup=max(5, warmup)) + if json_output is not None: _write_json_results( results, @@ -362,5 +334,46 @@ def main( sys.exit(1) +def _run_report_only_subsystems(*, iterations: int, warmup: int) -> None: + """Run subsystem micro-benchmarks in report-only mode.""" + from tools.scripts.bench_subsystems import _make_temp_db, _setup_test_table, print_results_table, run_benchmarks + + db_path = _make_temp_db() + try: + _setup_test_table(db_path) + click.echo("\nRunning report-only subsystem micro-benchmarks...") + sub_results = run_benchmarks(db_path, iterations=iterations, warmup=warmup, profile=False) + print_results_table(sub_results) + finally: + with suppress(OSError): + db_path.unlink() + + +def _write_json_results( + results: list[dict[str, Any]], + output_path: str | Path, + *, + rows: int, + iterations: int, + warmup: int, + thresholds: dict[str, float], + all_passed: bool, +) -> None: + """Write a performance gate report to JSON.""" + output = { + "metadata": { + "rows": rows, + "iterations": iterations, + "warmup": warmup, + "mypyc_compiled": bench_mod._is_compiled(), + "timestamp": time.strftime("%Y-%m-%dT%H:%M:%S%z"), + }, + "thresholds": thresholds, + "all_passed": all_passed, + "results": results, + } + Path(output_path).write_text(json.dumps(output, indent=2, sort_keys=True)) + + if __name__ == "__main__": main() diff --git a/tools/scripts/bench_subsystems.py b/tools/scripts/bench_subsystems.py index 0909d0258..45721f7fe 100644 --- a/tools/scripts/bench_subsystems.py +++ b/tools/scripts/bench_subsystems.py @@ -8,9 +8,11 @@ uv run python tools/scripts/bench_subsystems.py """ +import gc import io import json import sqlite3 +import statistics import tempfile import time import timeit @@ -26,10 +28,436 @@ __all__ = ("SubsystemBenchmark", "main", "print_results_table", "run_benchmarks") +STUB_COMPLEX_QUERY_SQL = """ +WITH recent_events AS ( + SELECT user_id, kind, COUNT(*) AS cnt, SUM(amount) AS total_amount + FROM events + WHERE ts >= ? + GROUP BY user_id, kind +) +SELECT u.id, u.name, o.tier, o.region, r.kind, r.cnt, r.total_amount +FROM users u +JOIN orgs o ON o.id = u.org_id +LEFT JOIN recent_events r ON r.user_id = u.id +WHERE u.status = 'active' AND o.region = 'us' +ORDER BY r.total_amount DESC +LIMIT 25 +""" + + +class SubsystemBenchmark: + """Container for a single subsystem benchmark.""" + + __slots__ = ("bench_fn", "description", "iterations", "name", "setup_fn") + + def __init__( + self, name: str, bench_fn: Any, iterations: int = 10_000, setup_fn: Any = None, description: str = "" + ) -> None: + self.name = name + self.bench_fn = bench_fn + self.iterations = iterations + self.setup_fn = setup_fn + self.description = description + + +def run_benchmarks(db_path: Path, iterations: int, warmup: int, profile: bool = False) -> list[dict[str, Any]]: + """Run all subsystem benchmarks using interleaved round-robin rounds. + + Args: + db_path: Path to the temporary SQLite database. + iterations: Number of iterations per benchmark. + warmup: Number of warmup iterations. + profile: Whether to profile the benchmarks using HotPathProfiler. + + Returns: + List of result dictionaries with timing data. + """ + benchmarks = _build_benchmarks(db_path, iterations) + active_benchmarks: list[SubsystemBenchmark] = [] + cleanup_benchmarks: list[SubsystemBenchmark] = [] + for bench in benchmarks: + if bench.name == "_cleanup_": + cleanup_benchmarks.append(bench) + else: + active_benchmarks.append(bench) + + try: + for bench in active_benchmarks: + for _ in range(warmup): + bench.bench_fn() + + results: list[dict[str, Any]] = [] + if profile: + for bench in active_benchmarks: + click.echo(f" Benchmarking: {bench.name}...") + with HotPathProfiler() as prof: + elapsed = timeit.timeit(bench.bench_fn, number=bench.iterations) + click.echo(f" Profile results for {bench.name}:") + prof.print_report(limit=10) + click.echo() + time_per_op_us = (elapsed / bench.iterations) * 1_000_000 + ops_per_sec = bench.iterations / elapsed if elapsed > 0 else float("inf") + results.append({ + "name": bench.name, + "time_per_op_us": time_per_op_us, + "ops_per_sec": ops_per_sec, + "total_time": elapsed, + "iterations": bench.iterations, + "description": bench.description, + }) + return results + + rounds = min(5, max(1, iterations)) + per_round_us: dict[str, list[float]] = {bench.name: [] for bench in active_benchmarks} + total_elapsed: dict[str, float] = dict.fromkeys((bench.name for bench in active_benchmarks), 0.0) + total_iters: dict[str, int] = dict.fromkeys((bench.name for bench in active_benchmarks), 0) + + for _ in range(rounds): + for bench in active_benchmarks: + batch_size = max(1, bench.iterations // rounds) + gc.collect() + gc_was_enabled = gc.isenabled() + if gc_was_enabled: + gc.disable() + try: + elapsed = timeit.timeit(bench.bench_fn, number=batch_size) + finally: + if gc_was_enabled: + gc.enable() + per_round_us[bench.name].append((elapsed / batch_size) * 1_000_000) + total_elapsed[bench.name] += elapsed + total_iters[bench.name] += batch_size + + for bench in active_benchmarks: + click.echo(f" Benchmarking: {bench.name}...") + samples = per_round_us[bench.name] + median_us = statistics.median(samples) if samples else 0.0 + ops_per_sec = 1_000_000.0 / median_us if median_us > 0 else float("inf") + results.append({ + "name": bench.name, + "time_per_op_us": median_us, + "ops_per_sec": ops_per_sec, + "total_time": total_elapsed[bench.name], + "iterations": total_iters[bench.name], + "description": bench.description, + }) + + return results + finally: + for bench in cleanup_benchmarks: + if bench.setup_fn: + bench.setup_fn() + -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- +def print_results_table(results: list[dict[str, Any]]) -> None: + """Print benchmark results as a rich table. + + Args: + results: List of result dictionaries from run_benchmarks. + """ + console = Console() + + subsystem_times = [ + r["time_per_op_us"] + for r in results + if not r["name"].startswith("raw ") and r["name"] != "session.execute() - full path" + ] + total_subsystem_us = sum(subsystem_times) if subsystem_times else 1.0 + + table = Table(title="Subsystem Micro-Benchmarks (sqlite)") + table.add_column("Subsystem", style="cyan", no_wrap=True, max_width=52) + table.add_column("Time/Op", justify="right", style="yellow") + table.add_column("Ops/sec", justify="right", style="green") + table.add_column("Relative Cost", justify="right", style="magenta") + + for r in results: + name = r["name"] + us = r["time_per_op_us"] + ops = r["ops_per_sec"] + pct = (us / total_subsystem_us) * 100 if total_subsystem_us > 0 else 0 + + _us_per_ms = 1000 + if us >= _us_per_ms: + time_str = f"{us / _us_per_ms:.2f} ms" + elif us >= 1: + time_str = f"{us:.2f} us" + else: + time_str = f"{us * 1000:.1f} ns" + + _million = 1_000_000 + _thousand = 1_000 + if ops >= _million: + ops_str = f"{ops / _million:.2f}M" + elif ops >= _thousand: + ops_str = f"{ops / _thousand:.1f}K" + else: + ops_str = f"{ops:.0f}" + + if name.startswith("raw "): + pct_str = "(baseline)" + elif name == "session.execute() - full path": + pct_str = "(end-to-end)" + else: + pct_str = f"{pct:.1f}%" + + table.add_row(name, time_str, ops_str, pct_str) + + console.print(table) + + console.print() + full_execute = next((r for r in results if r["name"] == "session.execute() - full path"), None) + raw_execute = next((r for r in results if r["name"] == "raw sqlite3 conn.execute()"), None) + + if full_execute and raw_execute: + overhead_us = full_execute["time_per_op_us"] - raw_execute["time_per_op_us"] + overhead_pct = (overhead_us / raw_execute["time_per_op_us"]) * 100 if raw_execute["time_per_op_us"] > 0 else 0 + console.print(f" [bold]Per-call overhead:[/bold] {overhead_us:.2f} us ({overhead_pct:.1f}% vs raw)") + console.print(f" [bold]Raw execute:[/bold] {raw_execute['time_per_op_us']:.2f} us/op") + console.print(f" [bold]sqlspec execute:[/bold] {full_execute['time_per_op_us']:.2f} us/op") + + +@click.command() +@click.option("--iterations", default=10_000, show_default=True, help="Number of iterations per subsystem benchmark") +@click.option("--warmup", default=100, show_default=True, help="Number of warmup iterations (not timed)") +@click.option("--profile", is_flag=True, default=False, help="Profile the benchmarks using HotPathProfiler") +@click.option("--json-output", default=None, type=click.Path(), help="Write subsystem results to a JSON file") +def main(iterations: int, warmup: int, profile: bool, json_output: str | None) -> None: + """Run subsystem micro-benchmarks for sqlspec hot path profiling. + + Isolates each execution subsystem and measures it independently to + identify exactly where time is spent during query execution. + """ + console = Console() + console.print("[bold]sqlspec Subsystem Micro-Benchmarks[/bold]") + console.print(f" iterations={iterations}, warmup={warmup}, profile={profile}") + console.print() + + db_path = _make_temp_db() + try: + _setup_test_table(db_path) + click.echo("Running benchmarks...") + results = run_benchmarks(db_path, iterations, warmup, profile=profile) + click.echo() + print_results_table(results) + if json_output is not None: + _write_json_results(results, json_output, iterations=iterations, warmup=warmup, profile=profile) + click.secho(f"Subsystem report written to {json_output}", fg="green") + finally: + with suppress(OSError): + db_path.unlink() + + click.echo() + click.secho("Subsystem benchmarks complete.", fg="green") + + +class _StubArrowTable: + """Minimal no-op Arrow table stub for pre-Phase-5 DuckDB dispatch_execute.""" + + __slots__ = ("column_names", "num_rows") + + def __init__(self) -> None: + self.column_names = ["id", "value"] + self.num_rows = 1 + + def to_pylist(self) -> list[dict[str, Any]]: + return [{"id": 1, "value": "value_1"}] + + +class _StubDuckDBCursor: + """Minimal no-op DB-API cursor isolating pure-Python SQLSpec driver overhead.""" + + __slots__ = ("_arrow_table", "description", "rowcount") + + def __init__(self) -> None: + self.description = [ + ("id", "NUMBER", None, None, None, None, None), + ("value", "STRING", None, None, None, None, None), + ] + self.rowcount = 1 + self._arrow_table = _StubArrowTable() + + def execute(self, sql: str, parameters: Any = None) -> "_StubDuckDBCursor": + return self + + def executemany(self, sql: str, parameters: Any = None) -> "_StubDuckDBCursor": + return self + + def fetchall(self) -> list[tuple[int, str]]: + return [(1, "value_1")] + + def to_arrow_table(self) -> _StubArrowTable: + return self._arrow_table + + def close(self) -> None: + return None + + +class _StubDuckDBConnection: + """Minimal no-op DB-API connection returning a reusable stub cursor.""" + + __slots__ = ("_cursor", "description", "rowcount") + + def __init__(self) -> None: + self._cursor = _StubDuckDBCursor() + self.description = self._cursor.description + self.rowcount = 1 + + def cursor(self) -> _StubDuckDBCursor: + return self._cursor + + def execute(self, sql: str, parameters: Any = None) -> _StubDuckDBCursor: + return self._cursor + + def executemany(self, sql: str, parameters: Any = None) -> _StubDuckDBCursor: + return self._cursor + + def fetchall(self) -> list[tuple[int, str]]: + return self._cursor.fetchall() + + def to_arrow_table(self) -> _StubArrowTable: + return self._cursor.to_arrow_table() + + def register(self, name: str, table: Any) -> None: + return None + + def unregister(self, name: str) -> None: + return None + + def commit(self) -> None: + return None + + def rollback(self) -> None: + return None + + +def _build_stub_driver_benchmarks(iterations: int) -> list[SubsystemBenchmark]: + """Build pure-Python stub-connection driver micro-benchmarks.""" + from sqlspec.adapters.duckdb import DuckDBConfig + from sqlspec.adapters.duckdb.driver import DuckDBDriver + + stub_conn = _StubDuckDBConnection() + shared_config = DuckDBConfig( + connection_config={"database": ":memory:"}, driver_features={"enable_arrow_ingest": False} + ) + shared_stmt_cfg = shared_config.statement_config + shared_features = dict(shared_config.driver_features) + stub_driver = DuckDBDriver( + connection=stub_conn, # type: ignore[arg-type] + statement_config=shared_stmt_cfg, + driver_features=shared_features, + ) + + select_sql = "SELECT id, value FROM test WHERE value = ?" + insert_sql = "INSERT INTO test (value) VALUES (?)" + no_param_sql = "SELECT 1" + scalar_sql = "SELECT id, value FROM test WHERE id = ?" + many_sql = "INSERT INTO test (id, value) VALUES (?, ?)" + batch_100: list[tuple[int, str]] = [(idx, f"value_{idx}") for idx in range(100)] + + stub_driver.execute(select_sql, ("value_1",)) + stub_driver.execute(select_sql, ("value_1",)) + stub_driver.execute(insert_sql, ("value_1",)) + stub_driver.execute(insert_sql, ("value_1",)) + stub_driver.fetch_one_or_none(select_sql, ("value_1",)) + stub_driver.execute(no_param_sql) + stub_driver.execute(scalar_sql, 1) + stub_driver.execute(STUB_COMPLEX_QUERY_SQL, 1) + stub_driver.execute_many(many_sql, batch_100) + + def bench_stub_fast_select() -> None: + stub_driver.execute(select_sql, ("value_1",)) + + def bench_stub_fast_insert() -> None: + stub_driver.execute(insert_sql, ("value_1",)) + + def bench_stub_fast_fetch_one_or_none() -> None: + stub_driver.fetch_one_or_none(select_sql, ("value_1",)) + + def bench_stub_no_param_select() -> None: + stub_driver.execute(no_param_sql) + + def bench_stub_scalar_select() -> None: + stub_driver.execute(scalar_sql, 1) + + def bench_stub_complex_cte() -> None: + stub_driver.execute(STUB_COMPLEX_QUERY_SQL, 1) + + def bench_stub_session_per_request_simple() -> None: + drv = DuckDBDriver( + connection=stub_conn, # type: ignore[arg-type] + statement_config=shared_stmt_cfg, + driver_features=shared_features, + ) + drv.execute(select_sql, ("value_1",)) + + def bench_stub_session_per_request_complex() -> None: + drv = DuckDBDriver( + connection=stub_conn, # type: ignore[arg-type] + statement_config=shared_stmt_cfg, + driver_features=shared_features, + ) + drv.execute(STUB_COMPLEX_QUERY_SQL, 1) + + def bench_stub_execute_many_100() -> None: + stub_driver.execute_many(many_sql, batch_100) + + bounded_iters = max(1, min(iterations, 2_000)) + return [ + SubsystemBenchmark( + name="stub driver: fast-path execute(SELECT, tuple)", + bench_fn=bench_stub_fast_select, + iterations=iterations, + description="Pure-Python driver overhead on warm fast-path SELECT with 1-tuple param", + ), + SubsystemBenchmark( + name="stub driver: fast-path execute(INSERT, tuple)", + bench_fn=bench_stub_fast_insert, + iterations=iterations, + description="Pure-Python driver overhead on warm fast-path INSERT with 1-tuple param", + ), + SubsystemBenchmark( + name="stub driver: fast-path fetch_one_or_none(SELECT, tuple)", + bench_fn=bench_stub_fast_fetch_one_or_none, + iterations=iterations, + description="Pure-Python driver overhead on warm fetch_one_or_none with 1-tuple param", + ), + SubsystemBenchmark( + name="stub driver: parameterless execute(SELECT)", + bench_fn=bench_stub_no_param_select, + iterations=iterations, + description="Pure-Python driver overhead on parameterless SELECT 1", + ), + SubsystemBenchmark( + name="stub driver: scalar-param execute(SELECT, int)", + bench_fn=bench_stub_scalar_select, + iterations=iterations, + description="Pure-Python driver overhead on SELECT with scalar positional param", + ), + SubsystemBenchmark( + name="stub driver: complex CTE+2-join execute(SELECT, int)", + bench_fn=bench_stub_complex_cte, + iterations=bounded_iters, + description="Pure-Python driver overhead on CTE + 2-join query with scalar param", + ), + SubsystemBenchmark( + name="stub driver: session-per-request simple SELECT", + bench_fn=bench_stub_session_per_request_simple, + iterations=bounded_iters, + description="New driver instance per request executing simple SELECT", + ), + SubsystemBenchmark( + name="stub driver: session-per-request complex CTE SELECT", + bench_fn=bench_stub_session_per_request_complex, + iterations=bounded_iters, + description="New driver instance per request executing CTE + 2-join SELECT", + ), + SubsystemBenchmark( + name="stub driver: execute_many 100 rows", + bench_fn=bench_stub_execute_many_100, + iterations=bounded_iters, + description="Pure-Python driver overhead on execute_many with 100 parameter rows", + ), + ] def _make_temp_db() -> Path: @@ -61,26 +489,6 @@ def _librt_string_writer_type() -> "type[Any] | None": return StringWriter -# --------------------------------------------------------------------------- -# Benchmark definitions -# --------------------------------------------------------------------------- - - -class SubsystemBenchmark: - """Container for a single subsystem benchmark.""" - - __slots__ = ("bench_fn", "description", "iterations", "name", "setup_fn") - - def __init__( - self, name: str, bench_fn: Any, iterations: int = 10_000, setup_fn: Any = None, description: str = "" - ) -> None: - self.name = name - self.bench_fn = bench_fn - self.iterations = iterations - self.setup_fn = setup_fn - self.description = description - - def _build_librt_candidate_benchmarks(iterations: int) -> list[SubsystemBenchmark]: """Build optional benchmarks for candidate librt.strings replacements.""" string_writer_type = _librt_string_writer_type() @@ -161,7 +569,7 @@ def bench_parameter_converter_string_writer() -> None: def bench_splitter_join_baseline() -> None: statement_chars: list[str] = [] for token_value in token_values: - statement_chars.append(token_value) # noqa: PERF402 - Keep the splitter's token append shape. + statement_chars.append(token_value) # noqa: PERF402 "".join(statement_chars).strip() benchmarks.append( @@ -248,10 +656,14 @@ def _build_benchmarks(db_path: Path, iterations: int) -> list[SubsystemBenchmark """ benchmarks: list[SubsystemBenchmark] = [] - # --- 1. SQL object construction --- - - from sqlspec import sql + from sqlspec import SQLSpec, sql + from sqlspec.adapters.sqlite import SqliteConfig + from sqlspec.adapters.sqlite.driver import SqliteCursor + from sqlspec.core.result._base import SQLResult from sqlspec.core.statement import SQL + from sqlspec.storage.pipeline import SyncStoragePipeline, _decode_arrow_payload, _encode_row_payload + from sqlspec.storage.registry import StorageRegistry + from sqlspec.typing import Empty def bench_sql_construction_no_params() -> None: SQL("INSERT INTO test (value) VALUES (?)") @@ -305,15 +717,9 @@ def bench_builder_same_shape_varying_values() -> None: ) ) - # --- 2. SQL.compile() --- - insert_stmt = SQL("INSERT INTO test (value) VALUES (?)", ("hello",)) def bench_compile_insert() -> None: - insert_stmt._processed_state = type(insert_stmt)._processed_state - # Reset to force recompilation - from sqlspec.typing import Empty - insert_stmt._processed_state = Empty insert_stmt.compile() @@ -329,8 +735,6 @@ def bench_compile_insert() -> None: select_stmt = SQL("SELECT * FROM test WHERE value = ?", ("value_1",)) def bench_compile_select() -> None: - from sqlspec.typing import Empty - select_stmt._processed_state = Empty select_stmt.compile() @@ -352,8 +756,6 @@ def bench_compile_select() -> None: complex_stmt = SQL(complex_sql, ("active", "2024-01-01", 100, 50)) def bench_compile_complex() -> None: - from sqlspec.typing import Empty - complex_stmt._processed_state = Empty complex_stmt.compile() @@ -366,30 +768,19 @@ def bench_compile_complex() -> None: ) ) - # --- 3. Query cache cycle --- - # We need a real SqliteDriver to test the QC path. - - from sqlspec import SQLSpec - from sqlspec.adapters.sqlite import SqliteConfig - spec = SQLSpec() config = SqliteConfig(connection_config={"database": str(db_path), "isolation_level": None}) - # We need to get a driver instance to test QC methods. - # Use the session context to get the driver, but keep it alive. _session_ctx = spec.provide_session(config) session = _session_ctx.__enter__() - # Create the test table and seed data through the sqlspec session session.execute("CREATE TABLE IF NOT EXISTS test (value TEXT)") data = [(f"value_{i}",) for i in range(100)] session.execute_many("INSERT INTO test (value) VALUES (?)", data) - # Prime the query cache by executing a query twice (first stores, second hits cache) session.execute("INSERT INTO test (value) VALUES (?)", ("cache_prime",)) session.execute("INSERT INTO test (value) VALUES (?)", ("cache_prime2",)) - # Now benchmark the cache-miss lookup path def bench_cache_execution_miss() -> None: session._cached_execution("INSERT INTO unique_table (col) VALUES (?)", ("val",)) @@ -402,7 +793,6 @@ def bench_cache_execution_miss() -> None: ) ) - # Full statement cache lookup cycle def bench_cached_execution() -> None: session._cached_execution("INSERT INTO test (value) VALUES (?)", ("bench_val",)) @@ -415,8 +805,6 @@ def bench_cached_execution() -> None: ) ) - # --- 4. Parameter processing --- - def bench_prepare_driver_params_tuple() -> None: session.prepare_driver_parameters(("value_1",), session.statement_config, is_many=False) @@ -453,10 +841,6 @@ def bench_driver_parameters() -> None: ) ) - # --- 5. Result construction --- - - from sqlspec.core.result._base import SQLResult - result_stmt = SQL("INSERT INTO test (value) VALUES (?)", ("hello",)) result_stmt.compile() @@ -494,11 +878,6 @@ def bench_result_init_select() -> None: ) ) - # --- 6. Storage runtime + Arrow boundary helpers --- - - from sqlspec.storage.pipeline import SyncStoragePipeline, _decode_arrow_payload, _encode_row_payload - from sqlspec.storage.registry import StorageRegistry - storage_root = db_path.parent / "bench-storage-runtime" storage_root.mkdir(exist_ok=True) storage_path = storage_root / "bench-storage.jsonl" @@ -548,13 +927,9 @@ def bench_storage_decode_jsonl_arrow() -> None: ) ) - # --- 7. Cursor context manager overhead --- - raw_conn = sqlite3.connect(str(db_path), isolation_level=None) raw_conn.execute("PRAGMA journal_mode = WAL") - from sqlspec.adapters.sqlite.driver import SqliteCursor - def bench_cursor_context_manager() -> None: with SqliteCursor(raw_conn) as _cursor: pass @@ -568,7 +943,6 @@ def bench_cursor_context_manager() -> None: ) ) - # Raw cursor for comparison def bench_raw_cursor() -> None: cursor = raw_conn.cursor() cursor.close() @@ -582,8 +956,6 @@ def bench_raw_cursor() -> None: ) ) - # --- 8. Full execute() overhead (single statement, end-to-end) --- - def bench_full_execute() -> None: session.execute("INSERT INTO test (value) VALUES (?)", ("bench_e2e",)) @@ -596,7 +968,6 @@ def bench_full_execute() -> None: ) ) - # Raw sqlite for comparison def bench_raw_execute() -> None: raw_conn.execute("INSERT INTO test (value) VALUES (?)", ("bench_raw",)) @@ -609,6 +980,7 @@ def bench_raw_execute() -> None: ) ) + benchmarks.extend(_build_stub_driver_benchmarks(iterations)) benchmarks.extend(_build_librt_candidate_benchmarks(iterations)) def cleanup_benchmarks() -> None: @@ -618,7 +990,6 @@ def cleanup_benchmarks() -> None: with suppress(OSError): storage_root.rmdir() - # Store session context for cleanup benchmarks.append( SubsystemBenchmark(name="_cleanup_", bench_fn=lambda: None, iterations=0, setup_fn=cleanup_benchmarks) ) @@ -626,134 +997,6 @@ def cleanup_benchmarks() -> None: return benchmarks -# --------------------------------------------------------------------------- -# Runner -# --------------------------------------------------------------------------- - - -def run_benchmarks(db_path: Path, iterations: int, warmup: int, profile: bool = False) -> list[dict[str, Any]]: - """Run all subsystem benchmarks and return results. - - Args: - db_path: Path to the temporary SQLite database. - iterations: Number of iterations per benchmark. - warmup: Number of warmup iterations. - profile: Whether to profile the benchmarks using HotPathProfiler. - - Returns: - List of result dictionaries with timing data. - """ - benchmarks = _build_benchmarks(db_path, iterations) - results: list[dict[str, Any]] = [] - - for bench in benchmarks: - if bench.name == "_cleanup_": - if bench.setup_fn: - bench.setup_fn() - continue - - click.echo(f" Benchmarking: {bench.name}...") - - # Warmup - for _ in range(warmup): - bench.bench_fn() - - # Timed run using timeit - if profile: - with HotPathProfiler() as prof: - elapsed = timeit.timeit(bench.bench_fn, number=bench.iterations) - click.echo(f" Profile results for {bench.name}:") - prof.print_report(limit=10) - click.echo() - else: - elapsed = timeit.timeit(bench.bench_fn, number=bench.iterations) - time_per_op_us = (elapsed / bench.iterations) * 1_000_000 # microseconds - ops_per_sec = bench.iterations / elapsed if elapsed > 0 else float("inf") - - results.append({ - "name": bench.name, - "time_per_op_us": time_per_op_us, - "ops_per_sec": ops_per_sec, - "total_time": elapsed, - "iterations": bench.iterations, - "description": bench.description, - }) - - return results - - -def print_results_table(results: list[dict[str, Any]]) -> None: - """Print benchmark results as a rich table. - - Args: - results: List of result dictionaries from run_benchmarks. - """ - console = Console() - - # Calculate total overhead as sum of key subsystem times (excluding raw baselines) - subsystem_times = [ - r["time_per_op_us"] - for r in results - if not r["name"].startswith("raw ") and r["name"] != "session.execute() - full path" - ] - total_subsystem_us = sum(subsystem_times) if subsystem_times else 1.0 - - table = Table(title="Subsystem Micro-Benchmarks (sqlite)") - table.add_column("Subsystem", style="cyan", no_wrap=True, max_width=42) - table.add_column("Time/Op", justify="right", style="yellow") - table.add_column("Ops/sec", justify="right", style="green") - table.add_column("Relative Cost", justify="right", style="magenta") - - for r in results: - name = r["name"] - us = r["time_per_op_us"] - ops = r["ops_per_sec"] - pct = (us / total_subsystem_us) * 100 if total_subsystem_us > 0 else 0 - - # Format time with appropriate unit - _us_per_ms = 1000 - if us >= _us_per_ms: - time_str = f"{us / _us_per_ms:.2f} ms" - elif us >= 1: - time_str = f"{us:.2f} us" - else: - time_str = f"{us * 1000:.1f} ns" - - # Format ops/sec - _million = 1_000_000 - _thousand = 1_000 - if ops >= _million: - ops_str = f"{ops / _million:.2f}M" - elif ops >= _thousand: - ops_str = f"{ops / _thousand:.1f}K" - else: - ops_str = f"{ops:.0f}" - - # Style for baselines - if name.startswith("raw "): - pct_str = "(baseline)" - elif name == "session.execute() - full path": - pct_str = "(end-to-end)" - else: - pct_str = f"{pct:.1f}%" - - table.add_row(name, time_str, ops_str, pct_str) - - console.print(table) - - # Print summary - console.print() - full_execute = next((r for r in results if r["name"] == "session.execute() - full path"), None) - raw_execute = next((r for r in results if r["name"] == "raw sqlite3 conn.execute()"), None) - - if full_execute and raw_execute: - overhead_us = full_execute["time_per_op_us"] - raw_execute["time_per_op_us"] - overhead_pct = (overhead_us / raw_execute["time_per_op_us"]) * 100 if raw_execute["time_per_op_us"] > 0 else 0 - console.print(f" [bold]Per-call overhead:[/bold] {overhead_us:.2f} us ({overhead_pct:.1f}% vs raw)") - console.print(f" [bold]Raw execute:[/bold] {raw_execute['time_per_op_us']:.2f} us/op") - console.print(f" [bold]sqlspec execute:[/bold] {full_execute['time_per_op_us']:.2f} us/op") - - def _write_json_results( results: list[dict[str, Any]], output_path: str | Path, *, iterations: int, warmup: int, profile: bool ) -> None: @@ -771,44 +1014,5 @@ def _write_json_results( Path(output_path).write_text(json.dumps(output, indent=2, sort_keys=True)) -# --------------------------------------------------------------------------- -# CLI -# --------------------------------------------------------------------------- - - -@click.command() -@click.option("--iterations", default=10_000, show_default=True, help="Number of iterations per subsystem benchmark") -@click.option("--warmup", default=100, show_default=True, help="Number of warmup iterations (not timed)") -@click.option("--profile", is_flag=True, default=False, help="Profile the benchmarks using HotPathProfiler") -@click.option("--json-output", default=None, type=click.Path(), help="Write subsystem results to a JSON file") -def main(iterations: int, warmup: int, profile: bool, json_output: str | None) -> None: - """Run subsystem micro-benchmarks for sqlspec hot path profiling. - - Isolates each execution subsystem and measures it independently to - identify exactly where time is spent during query execution. - """ - console = Console() - console.print("[bold]sqlspec Subsystem Micro-Benchmarks[/bold]") - console.print(f" iterations={iterations}, warmup={warmup}, profile={profile}") - console.print() - - db_path = _make_temp_db() - try: - _setup_test_table(db_path) - click.echo("Running benchmarks...") - results = run_benchmarks(db_path, iterations, warmup, profile=profile) - click.echo() - print_results_table(results) - if json_output is not None: - _write_json_results(results, json_output, iterations=iterations, warmup=warmup, profile=profile) - click.secho(f"Subsystem report written to {json_output}", fg="green") - finally: - with suppress(OSError): - db_path.unlink() - - click.echo() - click.secho("Subsystem benchmarks complete.", fg="green") - - if __name__ == "__main__": main() diff --git a/uv.lock b/uv.lock index f3e53a276..b0aa33f16 100644 --- a/uv.lock +++ b/uv.lock @@ -425,66 +425,66 @@ wheels = [ [[package]] name = "ast-serialize" -version = "0.12.1" +version = "0.13.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c2/1c/7257e6ec9382843915ce475558ce4492ccb5ed39122c256bb369c27e2ebf/ast_serialize-0.12.1.tar.gz", hash = "sha256:5285a390caf1c44368ae270f037f797b91427d138b7d43cad0f1fda4c83518d9", size = 954408, upload-time = "2026-10-03T12:25:00.221Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4a/f7/e976169da322c009bb083a52d21e88fbfe5f071e1806e8c8361ab4ac477a/ast_serialize-0.12.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:e73255c9227fd74eac8a9b55c4049e8ad7b66d1f690bf827c98a86b2e594def7", size = 897232, upload-time = "2026-10-03T12:23:21.945Z" }, - { url = "https://files.pythonhosted.org/packages/e1/89/5545f6f4d38dd41b4e2a20050967ccd722508bc90fab0dfba463d8c8b994/ast_serialize-0.12.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:4655ef993e69e01bb47d2d99647de9bbb74af03938438656832cd010d95de348", size = 1235397, upload-time = "2026-10-03T12:23:23.835Z" }, - { url = "https://files.pythonhosted.org/packages/22/19/e9b839ef9b57626e15e20dd7cf764a9a6b50f9750f86d0a49bc3a971fb72/ast_serialize-0.12.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bac5a99a2c91dd823be9b8c44645694fccbb0750773cc5b27889a9f6f22fce89", size = 1216331, upload-time = "2026-10-03T12:23:25.557Z" }, - { url = "https://files.pythonhosted.org/packages/26/2a/d054d4ff8ba42472a22e3da6eb6dee0e69a32c477b5077eefdbada99f554/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6485e681625ed7a094221f16a7ff2ef154946112266a05cf83bde50c959ef345", size = 1281194, upload-time = "2026-10-03T12:23:27.349Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0c/c73eddfa180a7a4c1613c0f3d3ef020b05dca9b922ac08212463c33ad11f/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a513bc6f60980d01767f7cbe39b17ce0373e722824a74ce28d6cea49ee3c8460", size = 1287170, upload-time = "2026-10-03T12:23:29.333Z" }, - { url = "https://files.pythonhosted.org/packages/94/77/39dc75d8b718844859b64a9067c9df0cfce218ca45ea215fb24a1fda3cf7/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:82866f3523d53ffca8d2a69a750bec52908b69f728012e40959bebce2620453c", size = 1531238, upload-time = "2026-10-03T12:23:30.954Z" }, - { url = "https://files.pythonhosted.org/packages/b9/c0/6a6a6f94f45a288c4bac2eb8379a3d9654574a0f9249380ce3b07f6d64bb/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:243054a05a5190f5d087b5c8b16423e7f1cefa4991bac26e9c8ace18074b75b6", size = 1307154, upload-time = "2026-10-03T12:23:32.645Z" }, - { url = "https://files.pythonhosted.org/packages/f9/3d/80f843892bd0f7c0d95ec5422ba3dc315c1ce011e6f08b06d5f71bd82c25/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d9fbe5a3e8acddfc2fddff3dbbc7ea0e9798b3df3428f851b8abc52a3806f31", size = 1301506, upload-time = "2026-10-03T12:23:34.63Z" }, - { url = "https://files.pythonhosted.org/packages/df/cc/49a5fe852706f545e3e005584c5be89456bc637a8c9179aeaa8b9f26e8e4/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:d3d516da3463071d27e64caf54d88cba25cf4ad4afcc807e0bcf67743719f03e", size = 1297616, upload-time = "2026-10-03T12:23:36.377Z" }, - { url = "https://files.pythonhosted.org/packages/49/5c/1208c91d6e00cc43cc276bd6233c40c9b4ec3ef8537c83281dd5372cbdb8/ast_serialize-0.12.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8d6711adf11136c77e3a35517de9488a5081d1012874fae99c2876b64f4daace", size = 1354884, upload-time = "2026-10-03T12:23:38.035Z" }, - { url = "https://files.pythonhosted.org/packages/a9/80/2b5fc912ff0be64d8d61ff5dc7dc405c6311297a0e2039b848b7d14333f2/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:cbe239bee4bd609186daf60b95b7b0f47146c7f7f55f6da83807d747d6fe753f", size = 1457911, upload-time = "2026-10-03T12:23:39.679Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f8/d720429bf8933efbd0cc2038c0a50b6267a585d503500845c44bc6c8ff66/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:5bbf582286c9dc6b4c544ef645dc99e4b3aa09db28892bc60344141f6926641f", size = 1563023, upload-time = "2026-10-03T12:23:41.585Z" }, - { url = "https://files.pythonhosted.org/packages/7e/0a/99e6cc92bdbae5db60f84a14a0fb1ae77b6087e451d77808d87558162c9a/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:99e33c93efb5254a70c525b46038212371dfe5693d48eb2d0d5f17d936a263d7", size = 1556601, upload-time = "2026-10-03T12:23:43.361Z" }, - { url = "https://files.pythonhosted.org/packages/7a/05/59de9e16a2e333da534f30776d0f5e426034b64c67c17843425e3cc827d1/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:aa6c17a2b7f07e81fa8cfcc4aa7c832b3e57733853aebea113ab502f9b0963db", size = 1665039, upload-time = "2026-10-03T12:23:45.257Z" }, - { url = "https://files.pythonhosted.org/packages/33/83/35ed67a127167b484b42a071df440f84b14c0d20ea8f69dbed5cc96bfd98/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f896fa38e0af38821e1ab1425c5dee89e359623e165765bdeae7d0eb6909e76d", size = 1472263, upload-time = "2026-10-03T12:23:46.811Z" }, - { url = "https://files.pythonhosted.org/packages/c4/b0/3ab8613bbb690297f1bb687d780a248c486df0f4131b6a82044fcb49e438/ast_serialize-0.12.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:af699e81fd7ce80b8b03945826d8ea23dd36d072f10d4613402da597ba4ee9c6", size = 1499242, upload-time = "2026-10-03T12:23:48.531Z" }, - { url = "https://files.pythonhosted.org/packages/9d/a0/a28894d3b06f8775cea8989f371bd9f72ce562c32bc807770e7fb920ce17/ast_serialize-0.12.1-cp314-cp314t-win32.whl", hash = "sha256:10b59afc108eb285146acb23d1b5ec0fc58bb3c09cb2ab8876402df06c373c3b", size = 1119488, upload-time = "2026-10-03T12:23:50.419Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b2/0c44952f4ba4e14bb7f60a5858e2960dfefb9884e6ff007aaf64337dba5c/ast_serialize-0.12.1-cp314-cp314t-win_amd64.whl", hash = "sha256:72e871f6995a066c1b19104f8a6b5832b1163adb9a8267c2aa4711fbb0f4d1f3", size = 1157434, upload-time = "2026-10-03T12:23:52.383Z" }, - { url = "https://files.pythonhosted.org/packages/1f/1e/cb594c63f46a01d53629af1c4f9e42cd02afcea1c2fe483e12f22743ebac/ast_serialize-0.12.1-cp314-cp314t-win_arm64.whl", hash = "sha256:3398e458047d21c9bc1b323fe5aab77c608dc9ddb65b2d44deebaff503a1f1eb", size = 1129094, upload-time = "2026-10-03T12:23:54.133Z" }, - { url = "https://files.pythonhosted.org/packages/16/05/ca16884f9498386f3646bb18be59f0e31d44e992d252d7d6f5e4f8ae1ee2/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:410233de149ab8414cb27c6fc73e9d2baa35d6f971672d540d752060d980ffb4", size = 1235686, upload-time = "2026-10-03T12:23:55.863Z" }, - { url = "https://files.pythonhosted.org/packages/29/f2/34e87ed30e292cf365523712c4bcfef1967d9c3c2749de21b1f93b1fe0f3/ast_serialize-0.12.1-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:b9a2310845302f1a6bd45ae8a67d5760211103a8d66410b854bfa440d107e093", size = 1215598, upload-time = "2026-10-03T12:23:57.48Z" }, - { url = "https://files.pythonhosted.org/packages/f8/dc/c498f41c957b6ff31b97ed8ceccf3a84f85af7debca1125183cab95bb58b/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ff65f40f49d5e1a1a043ba366081d59a4e26a9f5c1b07eb1170e172115da7ca", size = 1281911, upload-time = "2026-10-03T12:23:58.954Z" }, - { url = "https://files.pythonhosted.org/packages/dc/60/70ccefae9d88058c4c234bf0aed93f54aca36eb74087736e76e9515aee96/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:536d783c4d91331f094e0a892221e619be5ffbe6fb6640885f14d7f226ec90ca", size = 1287296, upload-time = "2026-10-03T12:24:00.429Z" }, - { url = "https://files.pythonhosted.org/packages/08/e9/4fc697879c7128e29f9dab2ed19a9b586a56b621e5ea4aee2ae28c18e116/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c42d2d65f388d1960c5796231eb9bf5a988c46228633eb489605c4549ad16c52", size = 1532339, upload-time = "2026-10-03T12:24:02.053Z" }, - { url = "https://files.pythonhosted.org/packages/8a/9e/9e2bd489731602a94dbd0c576ebe1cc487a2d0f6127be743f44711166f0d/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a5628a12acc875fe7a167910f18d101dd101c2a7b1e6c2b6f7289ffaff25805c", size = 1308988, upload-time = "2026-10-03T12:24:03.61Z" }, - { url = "https://files.pythonhosted.org/packages/3f/69/e9cae837bd766a66db6953ffb5fc7f04b1945e02b0a9e4c6a0b6acb08f17/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9e855adfa5bb982b2e6fe09056b2d584f6dd4fce085d91a07d1155683751b6b5", size = 1303412, upload-time = "2026-10-03T12:24:05.62Z" }, - { url = "https://files.pythonhosted.org/packages/b2/1e/5ef8c62d5031d93187ed0d8dade5d942de9920c3fbd678c7652362b9a7a2/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:fafe1471e8aca6c87b4913b7b54ff97197adf702fbe28692284b929dfa62ff96", size = 1298466, upload-time = "2026-10-03T12:24:07.242Z" }, - { url = "https://files.pythonhosted.org/packages/c2/f3/25ded60844a1a437edc840e597b6f81daf91dc4a26035416e14298d3a091/ast_serialize-0.12.1-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a5cac246474d2a703147d1605a6ac5ba0fa9e0a443cf1cf42513adf4df02686f", size = 1355617, upload-time = "2026-10-03T12:24:09.125Z" }, - { url = "https://files.pythonhosted.org/packages/05/68/a0d3cc8d8042208a2cbef7b26483f4941b44dd5dd717bb19f20e4a4c0d66/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:6e25cd319fb0d7b39fcac666784ec86708ccbc78d07a698b1400cf5ed40c045b", size = 1458949, upload-time = "2026-10-03T12:24:10.772Z" }, - { url = "https://files.pythonhosted.org/packages/df/a0/5e4d355c48a9f125b8bec7b1b98d4d2dcd8324ff1d4dfcb03678a03c1414/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:657a7354ea16ed4d29f8127ed477c6fee3915c111d135f020ca835a991438e90", size = 1562328, upload-time = "2026-10-03T12:24:12.404Z" }, - { url = "https://files.pythonhosted.org/packages/8a/5f/3d40f6a7908200f2f0ed9ce1bad130d00e06d0405baa7918d4a4299b25dd/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:9eb9de7e59621acdb3e66984f374272b33d56d15a04763e2fd0604211e1c8303", size = 1557751, upload-time = "2026-10-03T12:24:14.455Z" }, - { url = "https://files.pythonhosted.org/packages/3f/13/d53e5a7e299d6dbaeab23a424eea2c98c821b46ed7b05abbe14743beeb63/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:09cc4d3103c1fc97f6845ba307af1db9cde5226bef47f8843220dde83f2276ba", size = 1666238, upload-time = "2026-10-03T12:24:16.171Z" }, - { url = "https://files.pythonhosted.org/packages/37/5b/7638ee3ae35a64e4467160a37dc7565cddfe2a87f06cef2fd07c93cfd503/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:c9e2a592706fd791c2271ce9c8f4e38c98d3ea0b4a86b511e09a4fe3ac44ab37", size = 1472620, upload-time = "2026-10-03T12:24:18.053Z" }, - { url = "https://files.pythonhosted.org/packages/fc/e7/6e9e621e0e4a3be5a9a5f8b6961982964d2367013dbe64feb5adaa43e56d/ast_serialize-0.12.1-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:f4ac042e95a575432c1730ca4cb9183066a2074886a599f46c1f1b0955fb8198", size = 1500141, upload-time = "2026-10-03T12:24:20.036Z" }, - { url = "https://files.pythonhosted.org/packages/4a/4f/3217da5b671711c09cc6be580095839cad539983662a6599405bd75c1a19/ast_serialize-0.12.1-cp315-abi3.abi3t-win32.whl", hash = "sha256:b3cd105995942cc6163a229674a86161ba1305f646493efd59bd6723a357ee14", size = 1118382, upload-time = "2026-10-03T12:24:21.599Z" }, - { url = "https://files.pythonhosted.org/packages/80/1e/6074cf29dca8ceff27d50e845c88e7a2eaaa7f0b6f3972909e878d844737/ast_serialize-0.12.1-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:a9cd24a26126088693ca054547ea0a391398a29cf1a3a2bec1009b4b6acc8b82", size = 1158034, upload-time = "2026-10-03T12:24:23.316Z" }, - { url = "https://files.pythonhosted.org/packages/92/a0/81ce428f9f3f1ca45f8b62c9711c30452bf8190476e8685cea0f72d8d008/ast_serialize-0.12.1-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:9649cd903db0dc047906c6dd740784a2ba665d54f7e43ba31457edbce76c9493", size = 1129437, upload-time = "2026-10-03T12:24:25.044Z" }, - { url = "https://files.pythonhosted.org/packages/3a/d9/1c08adb90728607d0d07d188df4558ae863d688b4458087efe9fafeca458/ast_serialize-0.12.1-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:5ef62601db3ce5c23445132262a193075e211fb2fc87b46b7550dd351fac0976", size = 897419, upload-time = "2026-10-03T12:24:26.699Z" }, - { url = "https://files.pythonhosted.org/packages/80/fb/1eabd2c0673283054468b1c6cb539aeb877636d6c84b280279f2d7a177a9/ast_serialize-0.12.1-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:98d91cd3a6cb76a39512ee090a539d1e3206b732ad8150eb38918cffa1ddf515", size = 1241515, upload-time = "2026-10-03T12:24:28.493Z" }, - { url = "https://files.pythonhosted.org/packages/1b/d7/c56955934a431a0fa3e4e9aa7af4a53ceab2a61241005427545208945eb4/ast_serialize-0.12.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:8a32f184ce3e4b1d0b06d642a1243281cf55b99e0323680b1b8f904029fd7700", size = 1228048, upload-time = "2026-10-03T12:24:30.556Z" }, - { url = "https://files.pythonhosted.org/packages/1e/4e/2b2ca4602baf92f842316ea617423402089df4fbd2ea42571ba28725ba46/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e62126ac2be2d9340ac1b3ee7a0466a883ecbed634cff0929c88ca0b671483b7", size = 1292087, upload-time = "2026-10-03T12:24:32.45Z" }, - { url = "https://files.pythonhosted.org/packages/d9/49/9ebd05218a87ca31f4f855d5e3df14239bba3c58f2aed9d02c7cba5d94f5/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0f93a70fa9826c04ea9f2c3a880f87f4cca09a828144ed5084682abd28110980", size = 1295355, upload-time = "2026-10-03T12:24:34.423Z" }, - { url = "https://files.pythonhosted.org/packages/dc/09/6db7c4327e7a56aba805f7190d377a159fc0bf6bdefb410dc7860624dfa3/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1858887be56a64a2aea899423dfe43787c34c75c18b0d7497de8e618d54b2790", size = 1541231, upload-time = "2026-10-03T12:24:36.05Z" }, - { url = "https://files.pythonhosted.org/packages/ed/85/7ab6097e5fe23cd4657b0e5a2fabb4f789f91e441a3ee40b3ca8b79be238/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:89a2bc39a820bc7785b60c53a5742b4e8dd4c1a599294e2dd68fae545883d44a", size = 1317324, upload-time = "2026-10-03T12:24:37.737Z" }, - { url = "https://files.pythonhosted.org/packages/c0/60/58961e7fd129e226ce36788fe328d20034f3105f5d3380df690050517737/ast_serialize-0.12.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50a9eaedf1db4857dad7cc47dd757ed70bfcc40b89d44d516c4a2f0d5033bd76", size = 1311809, upload-time = "2026-10-03T12:24:39.306Z" }, - { url = "https://files.pythonhosted.org/packages/15/c7/09d973db87d4575cba470fd80a3fa489322f7d6882546e43a4b21012468e/ast_serialize-0.12.1-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:c30b609e8fea426b310543126de876592236a25aa8ebd59f1e2b323dd52a4085", size = 1306678, upload-time = "2026-10-03T12:24:40.891Z" }, - { url = "https://files.pythonhosted.org/packages/84/27/84f69c22bcdaa5256b4fe43ff972fc117668fff8e68495807d5792eadcce/ast_serialize-0.12.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7b1ad06513022cfa1337744959255af0ef16119d2beb1e547b67f37ad9433d4a", size = 1362009, upload-time = "2026-10-03T12:24:42.809Z" }, - { url = "https://files.pythonhosted.org/packages/43/46/76ee342ef22cd6d82ccd6089d5e2f7163246de73d1816ccb4b6ec0550db6/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6add54b495e37ae3cf3a1f0d5eaba364814eb72e93026adc41b7791e4b0d45d3", size = 1467959, upload-time = "2026-10-03T12:24:44.496Z" }, - { url = "https://files.pythonhosted.org/packages/31/4d/18e48154bbf6058eed8d9b54fcebb2e130f8a380db1a2a202b4faac48626/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:5fc136cd08001b817ad0b3e7426f50a7d2b8982dc7c6491f0af78af4c3dd8672", size = 1571157, upload-time = "2026-10-03T12:24:46.156Z" }, - { url = "https://files.pythonhosted.org/packages/34/76/6b16ddf0510e713613a5f5441b13407c1bde6158df04946b9f1fdc65add3/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:45a9e6b700bbd973d49942668a5cdbeffa693f2e250b8a5409abd1fa9d351854", size = 1566873, upload-time = "2026-10-03T12:24:48.542Z" }, - { url = "https://files.pythonhosted.org/packages/75/33/9f6169ae7f60c2da4baec03450073d3f1edb39e95ab538be0d25a7d2f72e/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:a1d8267f83c613ea0a31f2518df074bd62e98a4b3a4892f6a529d74e08e02dba", size = 1673030, upload-time = "2026-10-03T12:24:50.566Z" }, - { url = "https://files.pythonhosted.org/packages/11/51/0d78755bd61d6cf8980f0cfdc7fa8ede38df46a5423c9f7a3da0cff587ec/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:9a0cbab9796e6ce841197feeba4008faa96b4cc7741129542fd81c882d7a4f01", size = 1480052, upload-time = "2026-10-03T12:24:52.277Z" }, - { url = "https://files.pythonhosted.org/packages/01/ae/ad4c0e5129991f2761f388420c5ded37cb134ec5882e3e59043d33c1ad87/ast_serialize-0.12.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b4282695f1d51a3c6ef76560351bad5af880eff7d755aefea325bffb9bf68c25", size = 1508940, upload-time = "2026-10-03T12:24:53.846Z" }, - { url = "https://files.pythonhosted.org/packages/38/6b/3299182794d38815ae6e9c7ede9bb8f2e4aa93c3578bed201c1ea746643a/ast_serialize-0.12.1-cp39-abi3-win32.whl", hash = "sha256:119d1b0cadaba4a6e475f9bbe79eecc79351e373142eabe7c353f0250d70aebb", size = 1125618, upload-time = "2026-10-03T12:24:55.405Z" }, - { url = "https://files.pythonhosted.org/packages/86/14/5d4fb733c18a1d69e237c067b183842f3a7ea1c999a51ddc87093a281c88/ast_serialize-0.12.1-cp39-abi3-win_amd64.whl", hash = "sha256:3d6ed63d4fc1ec867b8cb522d58c36df0e8f05e487bea0ffd102043a37636d72", size = 1165798, upload-time = "2026-10-03T12:24:57.052Z" }, - { url = "https://files.pythonhosted.org/packages/f1/f4/b54123680025c0b7253117418f023d1b2487f1102552acbdd9d8ee96b622/ast_serialize-0.12.1-cp39-abi3-win_arm64.whl", hash = "sha256:610a41351de68199de9a1434499083b4256c0df7658ec1cfc0a0a7b20b08d317", size = 1136560, upload-time = "2026-10-03T12:24:58.689Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/d2/7c/dcde7ac261e0bfbb0360c207a1e3f9fd467f9bfaeaff7b62d01625bc86d2/ast_serialize-0.13.0.tar.gz", hash = "sha256:a0bdcef01e643e0810d2dedfb64d924bcfe079a15dc20d1c067870cc01d5c5e6", size = 954999, upload-time = "2026-10-12T17:06:22.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/86/6d1e047a41fc360ca4c7f8d1d3d0727302d7e0b0af4933a462c18390a00a/ast_serialize-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:4d1e15da4b6afc6fe80b87704be452aa0639df93d019a516e9ac9540357fc9b2", size = 897213, upload-time = "2026-10-12T17:04:48.544Z" }, + { url = "https://files.pythonhosted.org/packages/e2/1e/4a55b8d219616dcb9523c062dd4110ca7dd9d3c99a0ea169ce1de049e8dc/ast_serialize-0.13.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:619050b18705310e19e254cdb7554289fe14da374cbfbb1362cd84635896fb7f", size = 1236396, upload-time = "2026-10-12T17:04:50.401Z" }, + { url = "https://files.pythonhosted.org/packages/be/ad/93ec0502d21691152a26103f76064c4a5bef6945866cadd8288485907306/ast_serialize-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7ce1b50c5a68233e890926405afc308a5f10f6f49ec3a094d3dfa8b6733e4496", size = 1217965, upload-time = "2026-10-12T17:04:52.692Z" }, + { url = "https://files.pythonhosted.org/packages/86/37/12412da699bdfde52233836c98a30abc6086dbe17bdf0ad3497788e719c3/ast_serialize-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bab08a6f287cd620578084f9974cfaf3bef71959105f62af85fa70298b24851", size = 1282094, upload-time = "2026-10-12T17:04:54.128Z" }, + { url = "https://files.pythonhosted.org/packages/74/a4/42ba2ca24865442c6aea14bc648a0800181f76dde7e6e3f4036887cf7ce6/ast_serialize-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4e0bc018a457052d4638b469f90674e6ec0e32d86ac4a7bfa1f7d1c71a961426", size = 1287429, upload-time = "2026-10-12T17:04:55.546Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ec/3a5923554ea5f7148ea5742ffbef2f65deed68a8a5cbad6ca5f744adfcf2/ast_serialize-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:452fdaf5ff0b791870bb332254e083107d7abe29ef43251411c265c5b138f9a2", size = 1531892, upload-time = "2026-10-12T17:04:57.147Z" }, + { url = "https://files.pythonhosted.org/packages/ae/b4/e61038bf0dfb42c208e96e3ef953855e3bdb435f015f9dbf14e721c98066/ast_serialize-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12441bc7e41e495db5634c98adc8f8886b619ce2f1e68effe3f792a2adca9f47", size = 1309544, upload-time = "2026-10-12T17:04:59.026Z" }, + { url = "https://files.pythonhosted.org/packages/46/a2/5fc3e341f00711931bae32af6937122e205173d8521d1f7282b6cc2cc0f1/ast_serialize-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84cd9efdd3cd780b1f5361049becd91e0bcb0f16c2c216f41ee82e728a98390b", size = 1301823, upload-time = "2026-10-12T17:05:00.671Z" }, + { url = "https://files.pythonhosted.org/packages/42/51/592a4640cb7a88cd27c457a0448c5a635cf5c01c78b693dd6ef4dfe4d3bf/ast_serialize-0.13.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4dc7a24c734aded0557ef90bfabd2278b37502aacde84f49b53b4cb6a0711ea9", size = 1298520, upload-time = "2026-10-12T17:05:02.229Z" }, + { url = "https://files.pythonhosted.org/packages/66/f6/7d938404c3b94e3a18b2b53a4250abf3f28f38caf46b6e381020e83977ab/ast_serialize-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c95c04f1781cbefe89d512ce30e051d10823827ae542d9f18d5c2e7ba0fad08", size = 1355363, upload-time = "2026-10-12T17:05:03.631Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2e/88d437318054d5ce5c6c2be477258ae8d756620463221276fb0ae267fe93/ast_serialize-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fadba24386498ed745c848b0d45e4939a506694bd2b474c57576e9640f3defe2", size = 1459864, upload-time = "2026-10-12T17:05:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/e3/6c/6e502e9d9e8296d621bcb9173ec4eda6498b80757c91974be3d6e89058d2/ast_serialize-0.13.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:771cf5ee8329ee8472dd7a3d8bea7dbc480032ed8ddb4d37d40b57b95ee19ef2", size = 1563855, upload-time = "2026-10-12T17:05:07.017Z" }, + { url = "https://files.pythonhosted.org/packages/70/0b/f162a027c5f0c7f3f782803ee58f10ba355e49ba3e7931649635bacf2c07/ast_serialize-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a1714ee591a8e19833c0530a89e5a9faa7f62a11fc88f62fc5b722c7425dcc1f", size = 1557563, upload-time = "2026-10-12T17:05:08.709Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/71b81259135a68122486ce1522ddb1d8acdd3c11df260a3e7a337d428d90/ast_serialize-0.13.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:220a993dfc8b173e7062f690f9e00f4ebefe56718171bf35dccd73a9f8cea100", size = 1664708, upload-time = "2026-10-12T17:05:10.505Z" }, + { url = "https://files.pythonhosted.org/packages/a7/dc/9851e6b6c4771fa0d4b10fce536d427f184f85a0fb83ae19633f4c9337b9/ast_serialize-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:446de6067d853f61b4bde8741762c83d06b57ea81f96dd47977714d9f31837fa", size = 1472153, upload-time = "2026-10-12T17:05:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/b5/5c/73bcf627607855408c500106baf849cd1cb24d2a95e480eda3c5245c908e/ast_serialize-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0cc1f94fcd3b67005a32ee3e4c6b27cdc41659f697840d00fbb1e815ec27044", size = 1500997, upload-time = "2026-10-12T17:05:14.15Z" }, + { url = "https://files.pythonhosted.org/packages/7e/b4/ee6942656e725a1e5e15d8680425356a3c96c2fbfb519f476cd82261e93d/ast_serialize-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:77efef815ecae1195ac9889f616bd518d53b2173e87157043253b864af1c81c5", size = 1122428, upload-time = "2026-10-12T17:05:15.569Z" }, + { url = "https://files.pythonhosted.org/packages/23/9b/dacfec064d40c3be7051a95140bb4f343491aba0de06b612aec64a84a8bf/ast_serialize-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c77e5b62dfbfdfc1b025105f5038114ae988a0e616d48a845e0e57173f3f37c8", size = 1158940, upload-time = "2026-10-12T17:05:17.497Z" }, + { url = "https://files.pythonhosted.org/packages/70/75/f65e883e7cd0e804fdda0ee690aeebbf3a28b96eebc1588f99e9a24a9b3e/ast_serialize-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:8e7c6fec7fe03cb8f40c4af81d742aa0cf690cf0b7bded47508a8a392093f414", size = 1133534, upload-time = "2026-10-12T17:05:19.13Z" }, + { url = "https://files.pythonhosted.org/packages/bf/b4/a791bafbc7b9ecc3e727ab54651667372f3b33a6d1449c28783aeb1e3835/ast_serialize-0.13.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:9eaa20714acf43ef0a0c82850a2ec8097f834648f527c38f1483e2fd9a52cd3e", size = 1236335, upload-time = "2026-10-12T17:05:20.89Z" }, + { url = "https://files.pythonhosted.org/packages/30/c9/4a8d26f08d8053d4623fde6c83989b240120718cbea0b59727284fedd311/ast_serialize-0.13.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:3f5d4a7fcc916026010bae2a154e5be2c05040e67ef5c494c66a5cf315c41e30", size = 1217138, upload-time = "2026-10-12T17:05:22.586Z" }, + { url = "https://files.pythonhosted.org/packages/82/53/68ef7ef55880e2087dc585f319847bc6670192987aad2e3a79ba4fb700f9/ast_serialize-0.13.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:807875ed8c5de739c8c55a45944336fcb9b8601d77fcee384a743cd0497211d6", size = 1282493, upload-time = "2026-10-12T17:05:23.929Z" }, + { url = "https://files.pythonhosted.org/packages/e4/e7/ef9e47cd8b11cc3cfed2b37d64d3166197db2aa03e9707d04af440b58c82/ast_serialize-0.13.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e611937e6e77448496489627ae2558b6f6143449b1fb33f8a495665212eee58a", size = 1287893, upload-time = "2026-10-12T17:05:25.5Z" }, + { url = "https://files.pythonhosted.org/packages/d6/58/3426be929c1888ef63e4defcb053e900ecee819c646db55c5a7f60de32ca/ast_serialize-0.13.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bb8dd779c0a25478fe1db1a8b06dd4dd5e66077d6d0afe354acadb6d9aee7d0", size = 1533846, upload-time = "2026-10-12T17:05:26.924Z" }, + { url = "https://files.pythonhosted.org/packages/0a/3d/1eab7d9974578c2d1c514934ed88d174bdd680780857958a72aad5b8c9d3/ast_serialize-0.13.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a49f2a01a6df3150e022087cec0bf0ad580fec8f38a17f124d07dbb115106d1", size = 1308580, upload-time = "2026-10-12T17:05:28.466Z" }, + { url = "https://files.pythonhosted.org/packages/50/a2/6164467e272527c107dd2209c6c4963c48a1b40c914a560c0ce77027566d/ast_serialize-0.13.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f30f0e59be30c0c9540e8908b14874bc8d3c1d52a4562a4cfb426b003bd6c28b", size = 1302242, upload-time = "2026-10-12T17:05:29.999Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/9024bdeead34b560f8278fb5ac96098be9a3d65ba74c6d7accd9cab21e0e/ast_serialize-0.13.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:be6b1a4ee49866c77eb8a50e9cbc845370e6c15230a126d0a71aeab35f31c78c", size = 1299515, upload-time = "2026-10-12T17:05:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ef/d5/d1f5b9c0990fd0455d054c52bf461baa2b3ec4a93f6d8c633dbf895dd7fe/ast_serialize-0.13.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8da1a31e941adbea886a85fb25efc6f09353d58c665fdbc523a914e3d2e49fe", size = 1355301, upload-time = "2026-10-12T17:05:33.384Z" }, + { url = "https://files.pythonhosted.org/packages/c7/0d/7c05f233ca26f1837a44880c2167de440191bc5d44c0f3c880162952d702/ast_serialize-0.13.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:3a9469e4b93d87e4ee8f5c7e9462f973032793a24b9ae37ffee860220f17586a", size = 1459499, upload-time = "2026-10-12T17:05:34.977Z" }, + { url = "https://files.pythonhosted.org/packages/06/72/04bf634442d7dc3246d77ac1738be7ee9524869e78097332c497178640a4/ast_serialize-0.13.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:192aed400b2b92ebe41da17e856b9b6e17dbcebe0011ce4c6370d4a8a0486233", size = 1564419, upload-time = "2026-10-12T17:05:37.641Z" }, + { url = "https://files.pythonhosted.org/packages/01/4d/0c693c6b60c2abd9e03450e37c88ec86e2ac57d7734f06c296f336a2f0cb/ast_serialize-0.13.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:ee58f0db40f121ff0820242b286702700bc0ec58a53b6ac43ce4f43714d42e0d", size = 1557221, upload-time = "2026-10-12T17:05:39.108Z" }, + { url = "https://files.pythonhosted.org/packages/44/35/f35b73694cf0daf539bfd74ab51502428b403d1ccc42358730b7e93ccd3f/ast_serialize-0.13.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:e241f68cf5060bff9b161b202b60d6d52161ff3777fe56eb6a9a6764fdb7fdd9", size = 1665471, upload-time = "2026-10-12T17:05:40.654Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/fd6ec20c4cc6063ca67671df8ce59ea9e1512deb149289e50f36b7d413a2/ast_serialize-0.13.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:bd89da715b857a27c33fad713ea0561912c56f773ebd0fed2760cedd99724dc6", size = 1473045, upload-time = "2026-10-12T17:05:42.243Z" }, + { url = "https://files.pythonhosted.org/packages/da/53/8fc26dac873859e5b9fc3117cae2ed6b4afb6dc6f9e2d6fb62ee9fcade72/ast_serialize-0.13.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:6cbfa6dae34d5686056ef7c40ce1d3e7e1de48555e3a2fed985aef2d1f869d9a", size = 1501600, upload-time = "2026-10-12T17:05:44.089Z" }, + { url = "https://files.pythonhosted.org/packages/4f/35/363f4c3428b382d981d64fa871e468ddd9d48c9f0b969babbd462150ec98/ast_serialize-0.13.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:6a406251363eeb5c7b85a405eddd123e627a531bd150dc673a7f5dd087743b5c", size = 1120541, upload-time = "2026-10-12T17:05:45.595Z" }, + { url = "https://files.pythonhosted.org/packages/8b/07/df7ecee097d62214d042216fd2945207a10b681bb933daa64f238765cdb5/ast_serialize-0.13.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:cdd8fd066858b57ea2761b3d3989c90ea23913825bbb5453cc684c28bba3fb19", size = 1157906, upload-time = "2026-10-12T17:05:47.326Z" }, + { url = "https://files.pythonhosted.org/packages/be/4f/7781e45103d530f9fc963416e0a0beef6a2608a04d18290902ae6b074073/ast_serialize-0.13.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:841262622499585f0610a927434db526578553d8dae270b90d4c419a385e46b7", size = 1133990, upload-time = "2026-10-12T17:05:49.17Z" }, + { url = "https://files.pythonhosted.org/packages/6d/15/7284905fbd1600016f014ac3edc82199cc0f108f31d42221faa6067fe847/ast_serialize-0.13.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:efaab6400de8ee2d0e38695feccb0758acf11c1d0f8bc58a7090c566dd3ae88e", size = 897415, upload-time = "2026-10-12T17:05:50.722Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d1/2bbd7fe3ea2381410d0efb529e8af64027ea78bb58afad3c7ff641b33934/ast_serialize-0.13.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c51c855d8b7d5403925599acd3c6fc91b321eba3bf46dcc9fe88fd2d6619dac7", size = 1243056, upload-time = "2026-10-12T17:05:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/09/b8/a7c8d5ccc0a31589750e476f629bd8681f9bd08421112c71a9c388bf8d7e/ast_serialize-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:d47c8f0eedf0a41681c10a7c497fef3691c4a6b4af4de6c93a4916bb29712554", size = 1227662, upload-time = "2026-10-12T17:05:53.996Z" }, + { url = "https://files.pythonhosted.org/packages/64/54/4d64557c43abbd425b49c8e0c5b27093ee915c27e22bd46ea84c07d94908/ast_serialize-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d7376c611055f5ee44e5e13a2620dbc6846f47c583952c1704c8805154c2d2f", size = 1292779, upload-time = "2026-10-12T17:05:55.629Z" }, + { url = "https://files.pythonhosted.org/packages/7c/6f/93ab5d55402ede8444d01848cf35546c5e7ae24e9bedced6b17f530e2ef5/ast_serialize-0.13.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe2a3c8480e8e5eb41eaa958279b8c530b77b45065423f4ebd5223e118293055", size = 1295902, upload-time = "2026-10-12T17:05:57.191Z" }, + { url = "https://files.pythonhosted.org/packages/f6/5f/7292ef873948a99d26c6648fe92f4911b10c0396216388adfdbfc79a70dc/ast_serialize-0.13.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98edcd24240fa217d903c8f221bc05575baf38a87a207264ee7c800d21eef5a4", size = 1542236, upload-time = "2026-10-12T17:05:59.002Z" }, + { url = "https://files.pythonhosted.org/packages/ad/bf/4028224723d2038392ca176cfa1f982b15f12f4606d62a7f681550600c80/ast_serialize-0.13.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19b1e8f4c088ce91053df310b444fceeddb39f728ca7d04272c983646e36314b", size = 1319397, upload-time = "2026-10-12T17:06:00.527Z" }, + { url = "https://files.pythonhosted.org/packages/00/ef/8d710fb1a5cc0e1985d023bfdee62c77cccaec6be1dee2ed841074a1ee0b/ast_serialize-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f55668338bcb871e83ee21ba865fb08b7b4b13af9312742f9878c39f9d84ee3", size = 1312759, upload-time = "2026-10-12T17:06:02.096Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a3/d0cff408a37f253c1ce5fd061389217d4e2b19645fb3a4bd8f9582f5ed56/ast_serialize-0.13.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:a918572608ceb20fba8c2b83560f3d20be90effa4614589477b46d81672f0c3d", size = 1308696, upload-time = "2026-10-12T17:06:03.985Z" }, + { url = "https://files.pythonhosted.org/packages/41/c5/0a84554655da9c235e4f80a4799c6a3d7ee5d8c1ea16009077a5d0e6f289/ast_serialize-0.13.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b004c3bdab0beb45194cd66c0b8feea40d676e461d26296f9c791c8e3e1b7061", size = 1362306, upload-time = "2026-10-12T17:06:05.939Z" }, + { url = "https://files.pythonhosted.org/packages/b1/12/d85d300fb0a628b8183bd9301df624a819160b9fb7a3db3745c097ea2672/ast_serialize-0.13.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5e88733df5ffff9062b5ff2779cf402e0aa1a61ca3f7f84b577a1af2b7e09676", size = 1470459, upload-time = "2026-10-12T17:06:07.699Z" }, + { url = "https://files.pythonhosted.org/packages/c6/96/5201ebd0eaa758de11e9b4802e094d995c8d7306ce07bf62cc54ba396122/ast_serialize-0.13.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:017ddd4f22e727ef93e66df2d53340a6ff809b7e34cc2218f67918ae6239aad0", size = 1572881, upload-time = "2026-10-12T17:06:09.132Z" }, + { url = "https://files.pythonhosted.org/packages/08/62/05e0447e112ebaabcfc5cbf86b6f2ea455e3e08b2aebbf1f957546fd6991/ast_serialize-0.13.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:4b2ee61692de03009e6a8f372f24fbc2d4b768a2f51ecd426bb84acdfd6da3d1", size = 1568287, upload-time = "2026-10-12T17:06:10.744Z" }, + { url = "https://files.pythonhosted.org/packages/69/04/eaa3aa0543a747375d2a6e0b58efbf20cf149d9c3014898c63b13aef70da/ast_serialize-0.13.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:dffcffa543c8fcfb1ca941038eeae23e7f97ad8994e4d6f81fbd658cfa8cb440", size = 1674375, upload-time = "2026-10-12T17:06:12.407Z" }, + { url = "https://files.pythonhosted.org/packages/6a/25/cf850c03635876e6c6e53cf853d18aaed1fecc8b4e9b8f8a9300c269dc90/ast_serialize-0.13.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3e20e9ca3952196b91798f77ef267c36c7b3470021f950aa361d9be003fb655f", size = 1482386, upload-time = "2026-10-12T17:06:13.944Z" }, + { url = "https://files.pythonhosted.org/packages/28/b3/305c5144eade6c39a871f6ab33d6cbb6fbf7367c89bd4860cdbfd3453c71/ast_serialize-0.13.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:359fcebc49f855bd189cf568235dc84eabf521e00d03fce43135cad6484906bc", size = 1512130, upload-time = "2026-10-12T17:06:15.849Z" }, + { url = "https://files.pythonhosted.org/packages/2d/a8/0b26e4e4b16e3ddc9b77b6b42a29b0d6cf11a9342523ed7f0eaf4f1e1c45/ast_serialize-0.13.0-cp39-abi3-win32.whl", hash = "sha256:684e191dd41b08b0b92692a181380a70cd76e3b6469606a40aae1ca6dab39e7d", size = 1130669, upload-time = "2026-10-12T17:06:17.293Z" }, + { url = "https://files.pythonhosted.org/packages/5d/09/862169d471439eb11961692f867b09f34f0560977cafa042305705d2d0a4/ast_serialize-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:8f672c8e6d3b9ef6e365a5543aee2012247d1d58d948ceddb75b33a6679609ca", size = 1167803, upload-time = "2026-10-12T17:06:18.918Z" }, + { url = "https://files.pythonhosted.org/packages/d6/85/c1c583ec96be412c4119a0ba4354c1e3519464036dd8d2909536661bed6a/ast_serialize-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:8aff1682f9fa3e119a1cf8ef47504d38f7019b22b79e0f2b5c2135b9532d14db", size = 1138203, upload-time = "2026-10-12T17:06:20.466Z" }, ] [[package]] @@ -1430,11 +1430,11 @@ wheels = [ [[package]] name = "extra-platforms" -version = "13.11.0" +version = "13.12.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/e9/805ffa0a5d7bbb2eee2297a2fe1c22e41b2900daa2f1144d335e827e2a1b/extra_platforms-13.11.0.tar.gz", hash = "sha256:79bd4a79df48a873c6cd46e2c7318a7604f3d07c7a14b405b22f63b1368eef11", size = 492121, upload-time = "2026-10-01T16:06:47.514Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/7e/b92eb5fe6c14b00c1469357622ee32e8b96ba8247b90b9da4cba3743ab32/extra_platforms-13.12.0.tar.gz", hash = "sha256:883feefe546402e1ccfe6f53b3f248dbaee3cd72d82e137549769db452c4712c", size = 494651, upload-time = "2026-10-12T18:13:30.702Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/65/51/faf97cdf3da209138b9b4f265bfb9bcc23394a9e5a405f380cca494586f3/extra_platforms-13.11.0-py3-none-any.whl", hash = "sha256:6ee4e9972dccc82951140c8acb166714a49e45b79c38157b4141203ff73abbc0", size = 107041, upload-time = "2026-10-01T16:06:45.824Z" }, + { url = "https://files.pythonhosted.org/packages/36/26/7f61812ba2cc540edd3889802e90deeee03cd9569fafe41c64f1e35e03f7/extra_platforms-13.12.0-py3-none-any.whl", hash = "sha256:68a0cc9a16be81e6a9795bf413da7e14b1e8707dee260a47bedff7b6a5db58b3", size = 107721, upload-time = "2026-10-12T18:13:28.764Z" }, ] [[package]] @@ -1738,15 +1738,15 @@ grpc = [ [[package]] name = "google-auth" -version = "2.61.0" +version = "2.62.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, { name = "pyasn1-modules" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/0b/9788e913f2202da49068c27ce821eebcf96319240a89d7bb11f206d6471f/google_auth-2.61.0.tar.gz", hash = "sha256:37f0815967322e8c32b12bf422531e8b637cafdaae0acbb9141117cfe6a96f23", size = 398379, upload-time = "2026-10-07T20:13:14.827Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/b9/4b2528f30114b106e3c3a7298ba38d85e682fd1f83fe7c4a5b77c7677082/google_auth-2.62.0.tar.gz", hash = "sha256:0bef0ce54bdf9ce226c5d66e4264413bd918141c31bbe49fb52eac882f513d69", size = 398180, upload-time = "2026-10-12T19:20:48.328Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/68/c2ff6043d93fda69e5c12a834d0be3b3fa6c762e89732160532566be621b/google_auth-2.61.0-py3-none-any.whl", hash = "sha256:ca60266a37475ae68b63bac007272f46094b3d571abe92aded329b6cfb568025", size = 268021, upload-time = "2026-10-07T20:13:13.2Z" }, + { url = "https://files.pythonhosted.org/packages/eb/a3/87fe9b7203ec2e56ea6a57c75e6d632eaf9487c7f7978cdad1d801bee0af/google_auth-2.62.0-py3-none-any.whl", hash = "sha256:4ff4319aeb4ad128409759d397a9fcafad126d0031d241cc0dd6b9a00b43e3f3", size = 267911, upload-time = "2026-10-12T19:20:46.355Z" }, ] [package.optional-dependencies] @@ -1933,7 +1933,7 @@ wheels = [ [[package]] name = "google-genai" -version = "2.29.0" +version = "2.30.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1947,9 +1947,9 @@ dependencies = [ { name = "typing-extensions" }, { name = "websockets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/21/2b/977616ef350e51d3d263eb43be23a91be793544cd937b51c0fac313be233/google_genai-2.29.0.tar.gz", hash = "sha256:124d3eef9ff6b9e4ea548f95ff5b7721683e7990c0a936b006d9e2ab61ea897d", size = 706902, upload-time = "2026-10-07T22:22:57.153Z" } +sdist = { url = "https://files.pythonhosted.org/packages/71/84/1d8817ac6999730ba0ce92cd2f0d373300ca8bafd0b3a432b8694737904e/google_genai-2.30.0.tar.gz", hash = "sha256:bd370da8366cef7efebdb4cee959b2180b402cb81ebd7a7fcfe0f00869dae73a", size = 736285, upload-time = "2026-10-13T00:38:19.253Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/27/a3dede4a1deb21f7fde6a171daa039bf0ea149a5e7826a785e8f109a6e13/google_genai-2.29.0-py3-none-any.whl", hash = "sha256:7adda55cb6746fb46d38ad3202c3372840b96eede964b5fb3df1469cd4998570", size = 1167603, upload-time = "2026-10-07T22:22:55.215Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b4/8cd54d2631371677f8157f45abce53f02fb19200b661451df618cd8ca851/google_genai-2.30.0-py3-none-any.whl", hash = "sha256:d59236ca3d5cbb93add212069d363d51eeea31b3628358f519b6a1d8f4bf43b4", size = 1198375, upload-time = "2026-10-13T00:38:16.299Z" }, ] [[package]] @@ -4482,7 +4482,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.14.0" +version = "2.14.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -4490,142 +4490,142 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/6b/fb/6e44b63b26efea1cec48c26d8362313310202ef5ed6e7a52f1669e64e2cd/pydantic-2.14.0.tar.gz", hash = "sha256:8a51a7aaddd60f55566d1f07bdd87b92b463903f39a8f26b71a06314cd1548ae", size = 898613, upload-time = "2026-10-08T14:34:48.341Z" } +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362, upload-time = "2026-10-11T18:37:55.396Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2d/eb/9146591cc819d040475bf7f2be786710c7f7eb8083693bf859728da2ca9c/pydantic-2.14.0-py3-none-any.whl", hash = "sha256:15fab1bea6f1dc5003b54fc2ecab230c1fd1dbade2acd4addc52d81e32416d4b", size = 487109, upload-time = "2026-10-08T14:34:46.864Z" }, + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106, upload-time = "2026-10-11T18:37:53.437Z" }, ] [[package]] name = "pydantic-core" -version = "2.50.0" +version = "2.50.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e6/6d/196e8c819e0e934f35a1a33b3530396feadb0af4ca38fe9f995249e55794/pydantic_core-2.50.0.tar.gz", hash = "sha256:84d2d38f7d163c4dec292f379e9de1960c661795442aca6c90d706436cb3749e", size = 513355, upload-time = "2026-10-08T14:30:58.245Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f1/99/114c2e71405da7610c884c64f8370f85b2fbb69bd427158a3f714515a803/pydantic_core-2.50.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:55684bc850059fc85ff8dc21a3e342b722d3178993c49ebdfdee8b698a432720", size = 2152583, upload-time = "2026-10-08T14:26:12.913Z" }, - { url = "https://files.pythonhosted.org/packages/75/a7/fabcfea81a5c3525507af4347fa3a50e51036d5db6611a63e47379776e56/pydantic_core-2.50.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c3ae59461625518449f800acc4f874753ae96fcf993c47a27be07beb651371fd", size = 2045652, upload-time = "2026-10-08T14:26:14.38Z" }, - { url = "https://files.pythonhosted.org/packages/f5/80/226019008a041da423dc59d34f980b2b6e60a71971d07a8f52e10fc4747a/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a7080928078ff56c07da393392f772054e93a39c8033dc9c04547bd949f9b614", size = 2050325, upload-time = "2026-10-08T14:26:16.081Z" }, - { url = "https://files.pythonhosted.org/packages/b9/29/11c98cd9c49338f64aeb3d6e166c99b65d8949f00f285c5b66de75ade316/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4b1bae96f41806b14dd8bb1568d39b5e33b5af7fb27f7be36c78b8fa84708917", size = 2189027, upload-time = "2026-10-08T14:26:17.581Z" }, - { url = "https://files.pythonhosted.org/packages/cf/b7/af7d9c4de715cc13e947d1135b73f0c181bce2f01e9f1d9e3c428f9ee31c/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8facaf0a16121ac82cc403a71399a061b74624c1ae2034eec9ed6169c5d16ecb", size = 2388815, upload-time = "2026-10-08T14:26:19.306Z" }, - { url = "https://files.pythonhosted.org/packages/26/87/cd95dcd4d066180b2e22eff3dca01ac3af56607f9ecc43b7ba0748496f47/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:610e9f483ac53c5cc59c2d3687224029b54eb494feec2d40a2bcfdd187635192", size = 2431821, upload-time = "2026-10-08T14:26:20.958Z" }, - { url = "https://files.pythonhosted.org/packages/f0/14/7122b78e9915e7dd50f9dcec6041a4e32874907b41b9e0880db2f1c7ae1a/pydantic_core-2.50.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac223ea031905319a64d0df116979ae438b42d6265862a3d63c9f9808c2905a7", size = 2157622, upload-time = "2026-10-08T14:26:22.947Z" }, - { url = "https://files.pythonhosted.org/packages/97/86/fcddead5bda54fc8efa0d80dbba5962e9a91193570e227404c7593707833/pydantic_core-2.50.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:75b451ec5e64a1d4b803317f34710131742ff2e42f4bf5403f597d0af857d7d8", size = 2254921, upload-time = "2026-10-08T14:26:24.607Z" }, - { url = "https://files.pythonhosted.org/packages/6c/e8/041d1c3e3e1f203a656ce592f8c1548e1ef2b0939d19c5ce66432c30e4bd/pydantic_core-2.50.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7eee44c3f1f8acc220a743a5ed5588949a4f18b33df4cfbef2b1277470285401", size = 2268163, upload-time = "2026-10-08T14:26:26.207Z" }, - { url = "https://files.pythonhosted.org/packages/8a/ac/f515994ef1351e883276e85af0a50d839ead9d0d22743fa2c6dfd552f7ce/pydantic_core-2.50.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:f44107b5fdfecc03438feb165431652c8006650471326e53dd86d4c19124f5c5", size = 2302005, upload-time = "2026-10-08T14:26:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/04/6c/b970c4c87d92c4c974a5c5dc16bcf747f89b4e5729c5c45131267ef0421b/pydantic_core-2.50.0-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:718d05d4e078c7828f40ad3e310870fe4b837d93a32e7e8497a080c1f1040490", size = 2465387, upload-time = "2026-10-08T14:26:29.529Z" }, - { url = "https://files.pythonhosted.org/packages/4a/84/c0a92f97f9de7f133dfe694d0e8ca033a1c90fdf692dc69dfee3bf8a2e7b/pydantic_core-2.50.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:031867348c98ab49c6d3a16ad39738369ea48da59900d76d71c1e64de71fe79b", size = 2478673, upload-time = "2026-10-08T14:26:31.898Z" }, - { url = "https://files.pythonhosted.org/packages/f1/11/88e851761351cfef90382f175b07cede48edafc62a1509575dbc879934ef/pydantic_core-2.50.0-cp311-cp311-win32.whl", hash = "sha256:e41f9d1d9240e8e0d8a670ad3e66c0c00f0b1f7150a31bc6c445a4f87c1cb3ba", size = 2121424, upload-time = "2026-10-08T14:26:33.67Z" }, - { url = "https://files.pythonhosted.org/packages/c4/1f/89184f13b9ebaf6d8710cd0d1cfb466d7dc829de23468ab471b543dd07eb/pydantic_core-2.50.0-cp311-cp311-win_amd64.whl", hash = "sha256:90874428bc6678b26434336c77931c9734ecebedf50132c179205e5a9908761d", size = 2176278, upload-time = "2026-10-08T14:26:35.388Z" }, - { url = "https://files.pythonhosted.org/packages/ff/7a/5a630fff51218a860aa4e7699f781a7c9bf41c031987b613629c98f16618/pydantic_core-2.50.0-cp311-cp311-win_arm64.whl", hash = "sha256:b9be297ffe1015bfb2db4a23b6e1fec7e48361cf7c7b4f4f6bbdd008451b0a7b", size = 2183464, upload-time = "2026-10-08T14:26:37.485Z" }, - { url = "https://files.pythonhosted.org/packages/e3/d8/e0fe374bc0082dfd337ca319505c9302ce383aeeeacc33d93383edd159e5/pydantic_core-2.50.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:79e8fc9c135ef628c45cb8aa5deef8d21de13d33bb4c59a859fa73d335ceb40a", size = 2222860, upload-time = "2026-10-08T14:26:39.575Z" }, - { url = "https://files.pythonhosted.org/packages/ef/7d/0a2f829e3e1d809393faab907e3d9307245cd6043ebf54fad439f72f0003/pydantic_core-2.50.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0abe1b44d361b948404b6b2ed80be2583e0077340572e071afa6e0eda4e1de30", size = 2025752, upload-time = "2026-10-08T14:26:41.786Z" }, - { url = "https://files.pythonhosted.org/packages/67/d4/e2808af12de4809dd6a4b532e3877baa5f1000df51bd8ebb8756450c6c92/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae45853d25a23fba56681d2f9ed41f3e3f12f0a3b2393fefa08ff6406320a1f5", size = 2048432, upload-time = "2026-10-08T14:26:43.593Z" }, - { url = "https://files.pythonhosted.org/packages/18/58/46ad42a321051585d89f4e563b115015332caf79ba4e96f2785715c7f52d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd841dcf394ff261c26763a9d7be754176d4f1e6d26cb2a5d5331e91b6b56a5f", size = 2173565, upload-time = "2026-10-08T14:26:45.299Z" }, - { url = "https://files.pythonhosted.org/packages/92/69/541206e657ecea865e5f6d49a4a0d8062586d05da482d9d7ed77fdcdfc86/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2e97985641fe53ad7824d1b5bfeb7990a5ff788c4559ee44d7522642560ddc2", size = 2361974, upload-time = "2026-10-08T14:26:47.024Z" }, - { url = "https://files.pythonhosted.org/packages/f6/c0/cf4850441d0d3d3736abe5e8b6fcb49a50166aa0651ed8444172f81f4b4f/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7987866a2569396e6765c54d643fd6a7b3b234e89ee8d45a3729a7b4b2726145", size = 2440817, upload-time = "2026-10-08T14:26:48.937Z" }, - { url = "https://files.pythonhosted.org/packages/ca/e2/2f793fa2f1b338aa03124efd4362956c84d7bf1b331dc86050bc0151e67d/pydantic_core-2.50.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f187030fc3d62c668feb0f09e92852e0eb414d7fcefc4748f2e67d245aade37e", size = 2160358, upload-time = "2026-10-08T14:26:50.74Z" }, - { url = "https://files.pythonhosted.org/packages/db/00/35e314c08e721ec1f070408a4e41926b90347ecd2863c08c78daad5f8a16/pydantic_core-2.50.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d187f43d1c5b844adc871c5b8c22b4aa12a116aaca4e9bd1521bc9ce479aae1f", size = 2231416, upload-time = "2026-10-08T14:26:52.733Z" }, - { url = "https://files.pythonhosted.org/packages/0f/3a/6f7c36afe35a9eca24e5f74f33ca641ac8cf308aae4ac7af4e585d464a40/pydantic_core-2.50.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cb4fcaabb28cabf21396a9b816b9afe091f8c776805fdf03e2cbc606b64dfa7c", size = 2242496, upload-time = "2026-10-08T14:26:54.485Z" }, - { url = "https://files.pythonhosted.org/packages/8d/af/77adf30285836c25f6a927e170df45f8dd4af713c89135b22abcbf3c6d67/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:1751e92d56fbb623b937d73985e500b1a2b1053e56f718a6c564660d99bb9cb0", size = 2279176, upload-time = "2026-10-08T14:26:56.432Z" }, - { url = "https://files.pythonhosted.org/packages/08/37/4e2a05247f82b59337bf45e9bb14aed85e2d83aecd21e49ad6cedf91882b/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:70df7ff903aea05383298715ea53551c8f27c8f70cbe54ba7f05606cd822e7e4", size = 2450488, upload-time = "2026-10-08T14:26:58.218Z" }, - { url = "https://files.pythonhosted.org/packages/85/e6/75f25906212ecb94d69a0c8ed16f5c4ac312dc9961c2e93ff2a7fdf9a07a/pydantic_core-2.50.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:24302bf47319a64e5c5c7c29e971d2b7a20a190c64e59035a3df9582dec636fa", size = 2476664, upload-time = "2026-10-08T14:26:59.98Z" }, - { url = "https://files.pythonhosted.org/packages/75/72/ebb97b3becd0c0f2c722f698d34dec951ab1aa6086edf535f8769b545e3e/pydantic_core-2.50.0-cp312-cp312-win32.whl", hash = "sha256:5dbf9f18c8af11db719e67633be0af556d7d765bee0ca9419bd706fe4b7ed9fe", size = 2102409, upload-time = "2026-10-08T14:27:01.846Z" }, - { url = "https://files.pythonhosted.org/packages/3f/7b/5ebf3e62f5d0f6e3503ffdfffd7d0c2f8d45690afd37fa0cfdcb1786d7d6/pydantic_core-2.50.0-cp312-cp312-win_amd64.whl", hash = "sha256:1541c334af5d42cb9eb03862a9b4d2cfbfc670fd05172ec51f3ce02d704550f1", size = 2171536, upload-time = "2026-10-08T14:27:03.797Z" }, - { url = "https://files.pythonhosted.org/packages/58/1c/879ee9d5b63c60e5a077bab74ad93a4a48476090ca9c97534ab4263d0bae/pydantic_core-2.50.0-cp312-cp312-win_arm64.whl", hash = "sha256:b1399f918aea8fb76ffa99b474c9b768accea1f079fde407ee538bec89f20fa7", size = 2162946, upload-time = "2026-10-08T14:27:05.876Z" }, - { url = "https://files.pythonhosted.org/packages/81/25/f9a6958f73d92f66d620e3e1b091becf6b7a2ea89437118d397e2c6ca9ba/pydantic_core-2.50.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:049b0404792dcb942f1092bfdae5f819ef30445b0d174782e1909fbdd91bb48b", size = 2222904, upload-time = "2026-10-08T14:27:07.869Z" }, - { url = "https://files.pythonhosted.org/packages/c5/41/7f299b2ecf0ddbec8c2a68057ed53d29458ea5b0850615570849c454dd89/pydantic_core-2.50.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:8f16bc5bb12f4c581b0f40facc28dbf286db32d1bf4e7e4f4c4e0f7f4e34ecf9", size = 2024863, upload-time = "2026-10-08T14:27:09.78Z" }, - { url = "https://files.pythonhosted.org/packages/89/db/a9852fa8780acca5dd81a21bb66d4b2fb41c39dc5672cec66c32cc7f13e9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d1d084e92d0f4a096a5155b23d1ac603db8073ef98a3d1384badf1455e5ae742", size = 2047340, upload-time = "2026-10-08T14:27:11.85Z" }, - { url = "https://files.pythonhosted.org/packages/90/82/cd174e776e71ebcb1d289a57cb3e89565123a84c5708865e12efa82313f9/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f0af2d2f745ec00a6616c4dfba4477f9156ccfb45690f6ffa5fd34f42e871ed4", size = 2173461, upload-time = "2026-10-08T14:27:13.948Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5c/9c4b2aea09ec7b7af966e79631b1970f1adeb6a8ed001bc8bb64a2d1c23d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f28a5a299d4cefd1066a6883d22600b9ae3606f881c3015d255784634647580", size = 2362629, upload-time = "2026-10-08T14:27:15.909Z" }, - { url = "https://files.pythonhosted.org/packages/28/d9/93afd007b61c50425c3c8f0402425779341c8d498bd224e5788eaf419c4c/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bdd70c2d9e73bca09fad54605dfeaae2b4e770b2800c65f5f5342901ed567b9f", size = 2440134, upload-time = "2026-10-08T14:27:17.716Z" }, - { url = "https://files.pythonhosted.org/packages/c9/93/a4ef199535547aaa7c5d2cbf009a1d6a2b252885b14c6e70404e1c661716/pydantic_core-2.50.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e58acd43ac8d3905659c1d5309318dd576243e723dd7c3b5dd4f555479b77d4b", size = 2158480, upload-time = "2026-10-08T14:27:19.618Z" }, - { url = "https://files.pythonhosted.org/packages/de/20/f216e028d3bfbd9f8c67f7a2f0f8db04240926f630e2e66a5a8ac45fe00d/pydantic_core-2.50.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:5a07c644047f5abc268b4c39f8cb5a30e08a3c349228cb70142c7d3ed87587c0", size = 2231837, upload-time = "2026-10-08T14:27:21.742Z" }, - { url = "https://files.pythonhosted.org/packages/56/3f/57a6acf26e0acb82238ba4e25b35cbd6013b1d74eac99c4f358ff56bf6d6/pydantic_core-2.50.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8ad8dad549cd1645be591a50b4573995ee7e018f6619ddc2bc6ea44b2ad9f694", size = 2242240, upload-time = "2026-10-08T14:27:23.564Z" }, - { url = "https://files.pythonhosted.org/packages/94/41/f5f4014b40f91db4479e87dc038ac48a460a60baadea7ff9c942bc64dc62/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:1773001198030e16f946a7ff7760fc3ebd45b12150f66fd0f433780043dc13d8", size = 2277852, upload-time = "2026-10-08T14:27:25.517Z" }, - { url = "https://files.pythonhosted.org/packages/f8/fa/485e4db093e10f29676fc696abf9d88f3626ee520e99cc488fb091222db9/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:8ab9e74878172948e0426e3d27fba333fd6a1c3f9456d8e75643e8e6868ac1a1", size = 2450132, upload-time = "2026-10-08T14:27:27.493Z" }, - { url = "https://files.pythonhosted.org/packages/d3/48/fd07063cfc65e498528cea59b27b979eb5c890a54dcf5bb19ab0a589516d/pydantic_core-2.50.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:e15bb1535f68a27f28e579ba3a8f74e7b05d350c45f5622b76a311d5a19ae48a", size = 2477011, upload-time = "2026-10-08T14:27:29.972Z" }, - { url = "https://files.pythonhosted.org/packages/fa/96/6f34c285dc87ed2fae5f1b53c51bb732eda21dc0c6c73d6a5d9dcc7033cd/pydantic_core-2.50.0-cp313-cp313-win32.whl", hash = "sha256:c21e6a6e4e6d32fb6acbc4f0fa69e8319cac0d65eeaa8298d757371cc2a9c687", size = 2102027, upload-time = "2026-10-08T14:27:32.497Z" }, - { url = "https://files.pythonhosted.org/packages/5e/50/dbdb3ba6699d494e59db2f145aeb97990e28d1503bc9f7bdd57eb4c15677/pydantic_core-2.50.0-cp313-cp313-win_amd64.whl", hash = "sha256:4f96ccd9368ecbf6685d8f6981584ab72b4f0ffd20685e747737e7e340277b8d", size = 2169974, upload-time = "2026-10-08T14:27:34.692Z" }, - { url = "https://files.pythonhosted.org/packages/7d/ae/cfe0e52a9b45b5ba12b3a839db928669543948760a059a72f13cf0346c29/pydantic_core-2.50.0-cp313-cp313-win_arm64.whl", hash = "sha256:a2b88f9f9fa52e1c34938ff1a18ee7fbffe482df0bb43c087a9a60578d273d68", size = 2162504, upload-time = "2026-10-08T14:27:36.989Z" }, - { url = "https://files.pythonhosted.org/packages/59/d9/6dd838672e5ccddf01556bfd1b4a6767e0c75abf9aaa4092cb87a56dbc4a/pydantic_core-2.50.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:62a93a9d206a3580c975c3c1f65a869cd063844d016c004f8d786a9309b3c591", size = 2224496, upload-time = "2026-10-08T14:27:39.281Z" }, - { url = "https://files.pythonhosted.org/packages/b3/20/c57d2efcc63fb8fb6ae9ec2818baf30e2a68751e8d1dedefaf3dab2e45fd/pydantic_core-2.50.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5a8403eef4a66743e339102fb3cdd8c8b9016b8bd67924685893d062c88896f9", size = 2023860, upload-time = "2026-10-08T14:27:41.332Z" }, - { url = "https://files.pythonhosted.org/packages/26/c8/f44ea3f1b00288f715e2320cbcd0f604104325370ef4bee42c5ed2ab076e/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8354fbbe2abf0fb724b9303bef43bfd9b7a2779332813fa6d6559983954e7d8", size = 2049144, upload-time = "2026-10-08T14:27:43.695Z" }, - { url = "https://files.pythonhosted.org/packages/74/5c/2f5cf84ceaf6d7c351737124aebc135f50ce48a9dd28d2b5283103face54/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:00963fde61cf8880d9e7b5635a9830e0591edfa45168447fdc5b47635c0f6437", size = 2173456, upload-time = "2026-10-08T14:27:45.792Z" }, - { url = "https://files.pythonhosted.org/packages/97/de/dc0bd815a328e62b72bb93b5f3c762939670d4119fd27405b454970833da/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bbd7da16d8b2912cc56c9d0b85c4998a6cbf39b220f81c0d8c397c64672ae0e", size = 2365204, upload-time = "2026-10-08T14:27:48.117Z" }, - { url = "https://files.pythonhosted.org/packages/ca/a5/458c4a29f52fdecb16b192fb27f117359da9c9c58401ebfd956637822dca/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ed557fa2744617eac3e34dd39b85037efbf23cd33f06851c00fdb8f18ad8f4c2", size = 2445007, upload-time = "2026-10-08T14:27:50.637Z" }, - { url = "https://files.pythonhosted.org/packages/98/14/0c0e72e0663be91a456167a2f7ed49c65195b388e8f1e8b2bb5083b27458/pydantic_core-2.50.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59f816dc04e99627a5a6352ae51dfb30e603f2cb0b7009c91dc640c533def014", size = 2159911, upload-time = "2026-10-08T14:27:52.881Z" }, - { url = "https://files.pythonhosted.org/packages/62/7f/64af6921e17ed04dda0e07ebbd394b29fb557d33618cb0f3f81ec59c0508/pydantic_core-2.50.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:779b6c74526596a86d38248dedaecfb7851bbaf319c234be042c57acddd2c8c4", size = 2235761, upload-time = "2026-10-08T14:27:55.194Z" }, - { url = "https://files.pythonhosted.org/packages/82/11/b6ff9f7207af629094f4deac947d04be3812d5c95054010ad8f710a052f0/pydantic_core-2.50.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:21b62d45f327eb0802f842c132fda6d01a8376a8077922dc4dda69011c64d34a", size = 2243726, upload-time = "2026-10-08T14:27:57.336Z" }, - { url = "https://files.pythonhosted.org/packages/8e/37/6f7101f3c746a1686987a32875ae2f7c19f518d783979cee1a8a32e0a9ef/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:4f31af62efd1fd0257735b72e6716b32d4f207654adeabfe524d44baf1bb6bed", size = 2277158, upload-time = "2026-10-08T14:27:59.714Z" }, - { url = "https://files.pythonhosted.org/packages/d2/74/67fe208f3ec7f5d8c47bf7cba70019a84b1cffad947ebc8caef14271b54f/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:933f2d639eb81a3e1f145aec415453cc00983236629933f028d9507222583a2e", size = 2450059, upload-time = "2026-10-08T14:28:02.17Z" }, - { url = "https://files.pythonhosted.org/packages/cd/10/4a9c56a69f5841bdd94ffff5876a12ad2733209d5b89cadb67638d69e7c7/pydantic_core-2.50.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:c05d035e72530f6b00297941b0162218601542b76870c3bf2756bd87f16fc538", size = 2479142, upload-time = "2026-10-08T14:28:04.494Z" }, - { url = "https://files.pythonhosted.org/packages/bf/c1/30d36746051e42bb67df986ee874f787526edb99205f6b1583c33dd81202/pydantic_core-2.50.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ab3f95f737fc1b258b8210308fc02ce1442cf5950cf6d30b09ad89ab9e8afebd", size = 1483929, upload-time = "2026-10-08T14:28:06.527Z" }, - { url = "https://files.pythonhosted.org/packages/04/2c/c0e8949be4f99a74f02ae02d603bdd518c8a9eba72982a34d675134a792a/pydantic_core-2.50.0-cp314-cp314-win32.whl", hash = "sha256:f12d9690634414fc04b1a7072fdc35c34a9242232c1851fe4518383578bb09d4", size = 2104255, upload-time = "2026-10-08T14:28:08.729Z" }, - { url = "https://files.pythonhosted.org/packages/ea/6f/a5a5baf99509c998e0f2335d66b815880c7d6c63231c8e4017b237d6837d/pydantic_core-2.50.0-cp314-cp314-win_amd64.whl", hash = "sha256:63263d64884688554fb025a3906c7b00573980cfee75f9afeb86239d577384bc", size = 2170939, upload-time = "2026-10-08T14:28:11.08Z" }, - { url = "https://files.pythonhosted.org/packages/40/bc/c89b93b69d59cfe61c1e91b1cd3d87cc740e57327ecfef84ce34b7dcadac/pydantic_core-2.50.0-cp314-cp314-win_arm64.whl", hash = "sha256:753863dd4317ec8cc9eb3e6d9d01a8ef1a1726a8003b4354658d68a1ae05f9db", size = 2157035, upload-time = "2026-10-08T14:28:13.445Z" }, - { url = "https://files.pythonhosted.org/packages/ff/5e/d0ad47a406c42db95876900f4bfc520313c28d07a0dd5080da4972220c27/pydantic_core-2.50.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:c4abc425789f8540e86ca4cfdbc8dc650433cc2ac7c6136fee9bcb29d5665a02", size = 2234007, upload-time = "2026-10-08T14:28:15.9Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f4/69a7ec8400c1e34e5e11ab967f850ffd8ca71bb3aa0a8db93df5912bb6f2/pydantic_core-2.50.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:cf150693a51ca21e8288cd08a9de05e5ab331776dfcfd0537b14523338f0502a", size = 2000888, upload-time = "2026-10-08T14:28:17.983Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f1/860bb499f7cdf4bb453e8080af5bc182487f2f025d94baeee29c907bde6f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:158749408ee19682b8a2a7e7135cced7f41d6f2f9de96088b1b1609858a6a158", size = 2022761, upload-time = "2026-10-08T14:28:20.197Z" }, - { url = "https://files.pythonhosted.org/packages/89/fd/f571420436e79b9f7cc8f8ec534dee1759c240b4dc086737608f14d05c93/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:dcbd1fe5083315243447c13f7252ae9fe9d128b1ae2857e3a916c609235dd863", size = 2192910, upload-time = "2026-10-08T14:28:22.502Z" }, - { url = "https://files.pythonhosted.org/packages/cc/94/b47c4a01ea978a7a3d02551e3625f0344ae1352d67ea78011c2eca5e39e4/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0fdeda6272d60b1f6fb63f6a3dade55e274af62e1514d24a6549a12150c385cc", size = 2390178, upload-time = "2026-10-08T14:28:24.861Z" }, - { url = "https://files.pythonhosted.org/packages/77/61/d109f26b3ee4443cca220eaaa4f3fbcf78ff07aeb577becc37d9224db63f/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8b16e164205a90b1050d2f5469f8a7829db7698ad198c69e6aab6cbfb5648b87", size = 2464453, upload-time = "2026-10-08T14:28:27.383Z" }, - { url = "https://files.pythonhosted.org/packages/de/a4/7be2f608f6f8e7655155057a82a7e2f135ad9b37fcbadd34fdf11b1ade24/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17a5ca9c197788a6424749a09a3dce824cb2c17b72f835f8a5e330935b973609", size = 2150682, upload-time = "2026-10-08T14:28:29.65Z" }, - { url = "https://files.pythonhosted.org/packages/80/ef/a8d867f6d3981c232d0f3c2254458cdee8471de7bff3f7edd252cc00c30c/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:01340f4fbb4f854b1f36a6fe9dcd2b26c8936aead4e4ad1205624ac025c875fc", size = 2245995, upload-time = "2026-10-08T14:28:31.934Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a6/7297a39c8814beab877be5ba4f594c2e166108cdd20c1f1c51fb71d5389e/pydantic_core-2.50.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:99e203d5c2a814facef78dcd993b0fb7a933124a3475a38979fc09cceae210b7", size = 2264090, upload-time = "2026-10-08T14:28:34.298Z" }, - { url = "https://files.pythonhosted.org/packages/c3/65/634fc407eaf61abec0d015d6fd4fd8456c18c71358e984f60b0a05092d49/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:2092ce156f92aff17e3345baba2b7c0c1701f32ba922c5de71bd6248fbe164e3", size = 2293708, upload-time = "2026-10-08T14:28:36.725Z" }, - { url = "https://files.pythonhosted.org/packages/3a/04/b87fcf8062b8907c77769385a4340a814fe359660593dbbe4d8f3f7e9e61/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:74cbb6cbd74445ca279668790e0c10eccac0428fd79fe061fce6c9e3982ad3fe", size = 2468464, upload-time = "2026-10-08T14:28:39.109Z" }, - { url = "https://files.pythonhosted.org/packages/77/f4/d7aacec95f9e00dcbad84bee2fd0fc081ae292d380dc8a3268e4103de957/pydantic_core-2.50.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:42fff617cb0b08505d8123d71e6e7a8e54210d9007498f564855bd843ce984b1", size = 2511897, upload-time = "2026-10-08T14:28:41.681Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d8/0a755b0069f9a0f4c57f98fd05b524473952556406244649d632d25f8646/pydantic_core-2.50.0-cp314-cp314t-win32.whl", hash = "sha256:36f9ed6ae1069913e4f6e86d8233e119e83e00a20c54f88faf9c81292f2fecc0", size = 2098906, upload-time = "2026-10-08T14:28:44.218Z" }, - { url = "https://files.pythonhosted.org/packages/27/4e/cd10a1fbd1ba1d730871e382ec2ac550487465c7c9be1a72b3f4f9181817/pydantic_core-2.50.0-cp314-cp314t-win_amd64.whl", hash = "sha256:980c81c2ec53ea9eb2227c14b3e6de35670b2de163b638f2a803e90a3bd5bbb0", size = 2148359, upload-time = "2026-10-08T14:28:46.47Z" }, - { url = "https://files.pythonhosted.org/packages/b0/b1/a766eadfbc16b58d401f63dde3bf4e0943dcd2402011b6b8d9de2031ebe0/pydantic_core-2.50.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dec0dafc116ac29a84d143fdbc3b83fdb5d4ed339276be2251154537ab30e14d", size = 2175241, upload-time = "2026-10-08T14:28:48.934Z" }, - { url = "https://files.pythonhosted.org/packages/ae/d8/43d0e765a80d6fc7d87b5f66a9023777aad6da7e7d64f486a4c2be8158b7/pydantic_core-2.50.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:af2b808a79bb04075e87c81a5b6179365b93f9a851f29dafd67abff72085d0c8", size = 2224750, upload-time = "2026-10-08T14:28:51.227Z" }, - { url = "https://files.pythonhosted.org/packages/9c/12/07e047c21ad90f184c7e8bfc6c9966b9ca51d4698e1a06460b65ca2ce105/pydantic_core-2.50.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2918547195ffb20118b829fdb8938e9dc92c9527fe6cbe58572594c96362880e", size = 2023936, upload-time = "2026-10-08T14:28:53.978Z" }, - { url = "https://files.pythonhosted.org/packages/d8/a9/fb26fdd343ead65a699245c9d2150e84c47496a9b250859214a90cceecc9/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0393763d66f6f61715488d074a2cefac04aeb3ee281e36fb4925dd44deaf9e17", size = 2048872, upload-time = "2026-10-08T14:28:56.342Z" }, - { url = "https://files.pythonhosted.org/packages/f8/4d/9fc4ea28a8ecc1b85fe77a616f8df9a51696476aaa93c5b256269d025044/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:62ec6568896e0abf258cbcd22c406c1c8bf27d16224b21bec8f75c4ae88a8173", size = 2173752, upload-time = "2026-10-08T14:28:58.805Z" }, - { url = "https://files.pythonhosted.org/packages/70/7e/74b55196339413d787b283c2568cca4679b0645e48229421228acfa8839f/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ec786cb9d597dd75d993f8c1273e31bb2114c9bc22f67fba611e654e8347701b", size = 2365284, upload-time = "2026-10-08T14:29:01.434Z" }, - { url = "https://files.pythonhosted.org/packages/ba/0e/4ebc7a851f0ef63fcaba205739c171e50dd183f8d79dcf0099c3b46aa60b/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3fce74add1099da1ea09473268950270071fd56773e2968604efb3ab1d240e02", size = 2445279, upload-time = "2026-10-08T14:29:04.034Z" }, - { url = "https://files.pythonhosted.org/packages/07/09/03d3524fc7d4960840e32fd88c8cf487b5516daa7b6845ffc74274e0247a/pydantic_core-2.50.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:243088c95e23b12db9f2cd7661d584a3f00814e087489f40cde7f9feac56b694", size = 2160424, upload-time = "2026-10-08T14:29:06.628Z" }, - { url = "https://files.pythonhosted.org/packages/f6/b9/7c530c84049089033dd746700bfbb51520ed9376bc90f3dbbcb319880acf/pydantic_core-2.50.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:42a56b0052ac11d9d0d87b1c94a1ce52e31914fd133f269585e0f63a4ed988f2", size = 2236109, upload-time = "2026-10-08T14:29:09.053Z" }, - { url = "https://files.pythonhosted.org/packages/0a/08/70e07379ebc2538c22f8c372a37c009b96a3a1bd7921aee1dad28ee41be0/pydantic_core-2.50.0-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:350001f5573451150d919722ee095aa28bec037d6e23b86d7f04581a910fe924", size = 2244011, upload-time = "2026-10-08T14:29:11.549Z" }, - { url = "https://files.pythonhosted.org/packages/66/49/294810baacae4b088bfac60edab06036951e5ae6e0c20e8f6cfef7d70f66/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:21e38a011783d8afc8b9d79928e273d06f349b52ae84edf63ec18ad07f077484", size = 2278392, upload-time = "2026-10-08T14:29:13.998Z" }, - { url = "https://files.pythonhosted.org/packages/c3/0e/6990b812f124cf3564b36492799df7a66cd3cf5678004ffe65dda85b136b/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:588d309ce5c85448379556d72011b191c0414ee2e80d7c3f1ebe2ceb2d9027b1", size = 2450012, upload-time = "2026-10-08T14:29:16.524Z" }, - { url = "https://files.pythonhosted.org/packages/a8/c4/f8f1a763550a077c51d2c69b7a32db56d1b54e61c613bc19e6c7fd2e7cb2/pydantic_core-2.50.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:a8ee3965e01f10e4ff92ba1727626328ab7ba93bcd5674ff2b78ea1048ee0cea", size = 2479233, upload-time = "2026-10-08T14:29:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/9d/83/7a09baa1e0e4710be47b84aaf808e7b86247d7fa43251ac8934099113f8d/pydantic_core-2.50.0-cp315-cp315-win32.whl", hash = "sha256:c05b75ef3574c9ee4e05bbcf8513f7ccb155d426514be9efef5f6f53152d5f5c", size = 2104507, upload-time = "2026-10-08T14:29:21.694Z" }, - { url = "https://files.pythonhosted.org/packages/ce/a9/f1cf61f747538834ea2c14c442264e4f29663c95294ad7933867633a1dea/pydantic_core-2.50.0-cp315-cp315-win_amd64.whl", hash = "sha256:8447678b49412294801c9ea15ae31ea3bae7da65425268c92d40e032c38eac6f", size = 2171379, upload-time = "2026-10-08T14:29:24.613Z" }, - { url = "https://files.pythonhosted.org/packages/30/c8/9871261b760672cfa334498d94bc7dcbff2495345e639e806127f7087254/pydantic_core-2.50.0-cp315-cp315-win_arm64.whl", hash = "sha256:92016718bcf3e6f35a6bd986880191a8da7a35aa1f5b1e97544582ef938464cf", size = 2157182, upload-time = "2026-10-08T14:29:27.082Z" }, - { url = "https://files.pythonhosted.org/packages/de/3c/5107269aee5ee7855fa370ff837998792dbf124b8545244c370bec251a66/pydantic_core-2.50.0-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:71061800a3c225e730f7997f8a7fad6e0d0dcbe36609cdc7e60576a099a2832b", size = 2234440, upload-time = "2026-10-08T14:29:29.705Z" }, - { url = "https://files.pythonhosted.org/packages/fe/ac/cb179b0c404337ef517515539f9899164a6d3d43d5c79c2556165da211c1/pydantic_core-2.50.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bc87bd34239835c8d73171acd039e591cea0a2ca8615e6188044ad170a40fca1", size = 2001074, upload-time = "2026-10-08T14:29:32.327Z" }, - { url = "https://files.pythonhosted.org/packages/b0/59/ef1714204f145e9497ab71e8bd82c5e40b5adf365d420be777d1e047cdaf/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:57c0e5b26d82bf31ab2b044781527b1b1a36e9ed400856b6aca5097eb1abb909", size = 2023016, upload-time = "2026-10-08T14:29:35.06Z" }, - { url = "https://files.pythonhosted.org/packages/87/e9/08a3ade34b4af09a83740b62a4442b7623127d388fc21d4938798dd85a26/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41b9f2821f0a105f88cd54ad03fe1392ec600618ffa8610f7c5e466ecd98c531", size = 2193165, upload-time = "2026-10-08T14:29:37.721Z" }, - { url = "https://files.pythonhosted.org/packages/2a/56/29aa3e540d3aca72139e5b7dc56395a940849d85da2b7a9ccfbff71d0928/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:364f62d8024997536e57865cb1c8b36effad3249bf392a29a2379ea69db28238", size = 2390198, upload-time = "2026-10-08T14:29:40.616Z" }, - { url = "https://files.pythonhosted.org/packages/76/63/a5cbdde0a2090c47002819d85317428e15b41d22e581cf38e9714b4903af/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:955d7878130dfc124d6a5343e1b87d2933244fe14a4a0a3e98787e8e660a8eb4", size = 2464777, upload-time = "2026-10-08T14:29:43.29Z" }, - { url = "https://files.pythonhosted.org/packages/c5/55/fff3a363b0dcf72fdd72311c676b1fa769f2c13342129c694b2eb9886580/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cf81432281af66ba3285b26b09c2d478d84730dc50ff90926c1bdcef54048a33", size = 2150389, upload-time = "2026-10-08T14:29:46.271Z" }, - { url = "https://files.pythonhosted.org/packages/b7/f3/0062361fd2377185d322dc84cd05ec4b96da352195c89939c7b509664721/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:34a4a0938eb30931baca56e55786c5a6871ac7aa891a38c8cabcdb7e49dab91b", size = 2246225, upload-time = "2026-10-08T14:29:49.173Z" }, - { url = "https://files.pythonhosted.org/packages/f9/45/662e3870143d5f1632a02ceb9ce2d5168e88caf3da80e9b42f495ceb1284/pydantic_core-2.50.0-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f517a417cd02aa8fb05b603a0ac6d87b7b004c3ba4fcd279cad25cab7229043b", size = 2264404, upload-time = "2026-10-08T14:29:51.699Z" }, - { url = "https://files.pythonhosted.org/packages/32/5e/8b14ebf111700362c6e519e40209d86f521da81ef171b2891af0bb5de311/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:d01029d54ff1f45c195b1f7e6fbf6e58fb7e7e12cda9a6e639570d9c581decb2", size = 2294152, upload-time = "2026-10-08T14:29:54.349Z" }, - { url = "https://files.pythonhosted.org/packages/55/29/85c486e0d25a8b523803032e97d7138edb7b7bc887fa8c6dd085e8824cdb/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d06dbbfe8da01a0574de27afc19915bb3e7dddfbd184bb97e958f94051d8531b", size = 2469160, upload-time = "2026-10-08T14:29:57.193Z" }, - { url = "https://files.pythonhosted.org/packages/12/bf/c451db7567e92d6601c4d2f6bdbb945cf60104182e2b241d8fe0b6f01414/pydantic_core-2.50.0-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:5307a8bd49158b57a0ca4e10950d31085aa35d9007e934047043ccc6d28eea97", size = 2512204, upload-time = "2026-10-08T14:30:00.177Z" }, - { url = "https://files.pythonhosted.org/packages/43/1e/5b93a2513ced099acb0dc4dd6684742d6564ade60a59abcbabd161624e07/pydantic_core-2.50.0-cp315-cp315t-win32.whl", hash = "sha256:c2b246fa7cbdf9918488d1542a82bbb928cf71bcba66905c24131981e759ff0b", size = 2099026, upload-time = "2026-10-08T14:30:03.154Z" }, - { url = "https://files.pythonhosted.org/packages/37/f5/1b5967e31b025a23f2f627ffea1f5a874cff0adb72fa5f5af6bb36394ad9/pydantic_core-2.50.0-cp315-cp315t-win_amd64.whl", hash = "sha256:36c49d4e1769127461b609f110d91963790091ffcc2de401ac1d3b2f6a63bd54", size = 2148456, upload-time = "2026-10-08T14:30:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/48/15/213d6fe84816e8a5ed7b7539ecebb3f14c20739c2469f4e372f0f15bb042/pydantic_core-2.50.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f3abcabb04503023e1053add24472e78878de0bfd5c8a93686be01f4032c363c", size = 2175368, upload-time = "2026-10-08T14:30:08.989Z" }, - { url = "https://files.pythonhosted.org/packages/11/ff/8d126ca04417cbc76a81648c135296ecc2f36d07adfb0b3376638efd1dd0/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:38b03c5439c6a7a952f56e3e6596dae44bc78c3fb6a69df46cabfb2f888e5f0c", size = 2162541, upload-time = "2026-10-08T14:30:12.075Z" }, - { url = "https://files.pythonhosted.org/packages/b9/3f/e3f11c40950d0f37b3891c1ed015f04a4ef36c881d04ef1a5d1ac91d1c6f/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:4faa766450ef44d9eabed5b65a280f63f903d1e1ed6d2e278961eb22345ec49f", size = 2075402, upload-time = "2026-10-08T14:30:14.968Z" }, - { url = "https://files.pythonhosted.org/packages/42/f2/727d2b23ff6ca67c9c050c4ff4e5abd7b599d9ebcd819a74141d3b11c7c1/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50920d66aaab60dbc6023c3035d55fbabfa58b6d205d0b39d47d820c4a7a13a8", size = 2108552, upload-time = "2026-10-08T14:30:17.774Z" }, - { url = "https://files.pythonhosted.org/packages/4a/da/a4321058eea61f0504366668a09472a4c37514be30cce77967c35ab7ffaa/pydantic_core-2.50.0-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:586699b43066ca6038f96bd71d0eff0b6c292929e3d66ff63d5dc79d0f06d589", size = 2267745, upload-time = "2026-10-08T14:30:20.619Z" }, - { url = "https://files.pythonhosted.org/packages/a9/c8/45e2cbac6379b8cec8cffd7e6aed50754fce13d93f0cff28bbbfe18c1d1c/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:b916ff828d604d4311a5639b7b3da51eaea3923833ec3e300a5ee35eade99691", size = 2217049, upload-time = "2026-10-08T14:30:23.532Z" }, - { url = "https://files.pythonhosted.org/packages/61/8c/b81c4139bff6305e7a98fa7845a99e05df1461d3c33d0fef417e02114ae7/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:732efbb50977ab7cf18f01d4c255834aeb14cb2425459f7bff681a1ba3a4ffa1", size = 2039121, upload-time = "2026-10-08T14:30:26.423Z" }, - { url = "https://files.pythonhosted.org/packages/0f/49/679293741809cf835290bccf80216aebfcf5d94cb84a34709f18b21abfd5/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad9185366893714cd4514ae5e1a098227704fa395cb41a7855d75968ecedc826", size = 2109280, upload-time = "2026-10-08T14:30:29.216Z" }, - { url = "https://files.pythonhosted.org/packages/f5/49/76f167aec9b9d77a82e38131539ca6c6fcf69d720965f76eda20b87b454d/pydantic_core-2.50.0-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0def1dc09802a790e4a1b3cbc4401f0b53c58f273ce3671df9867f7bdf1fbe20", size = 2269313, upload-time = "2026-10-08T14:30:32.096Z" }, - { url = "https://files.pythonhosted.org/packages/1f/50/a6bd3c397b609aade207ffb78ce46f4ad4e29bb42a39007c87ee00e778f3/pydantic_core-2.50.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:68421ba548f6d86c7dededd70bfe530b4faf67eb811c53c70a1be69438d3ce99", size = 2154371, upload-time = "2026-10-08T14:30:34.965Z" }, - { url = "https://files.pythonhosted.org/packages/11/3c/2d32a1f945b9809b65701d2b72315bdcd700f13c093df5e60baaa784bc6a/pydantic_core-2.50.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:23e0940b7d73f405d92e98b2a05b93b16db163be13b7478bb19e1db2e486ade6", size = 2087028, upload-time = "2026-10-08T14:30:37.814Z" }, - { url = "https://files.pythonhosted.org/packages/aa/57/c2aea271192d8c4b81afeea9d61de8f5dc8686a1fcd1ace80e70a482fbce/pydantic_core-2.50.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ee9913fa2b5bfa6111c11158cf481bb13544f8bd5d10382fb6ee2612b16fcf28", size = 2259611, upload-time = "2026-10-08T14:30:40.581Z" }, - { url = "https://files.pythonhosted.org/packages/35/88/32128d5498b95722fe914a21a25f59edd7dc8917474f47924005682532a8/pydantic_core-2.50.0-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ad3532291deedfdfcad3cf5d351d0076d646c68a0c63869a1bebf87c22159600", size = 2268636, upload-time = "2026-10-08T14:30:43.511Z" }, - { url = "https://files.pythonhosted.org/packages/8e/37/7dae31279e07aaa63dd4b17756f4ebd5647aed6bee5581ee3b3e85d82a0c/pydantic_core-2.50.0-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:9279f4be22bc3765dd9f61f5b4d38d9cb219d167bbd6f2e0ffa3368b1b71b4d1", size = 2305416, upload-time = "2026-10-08T14:30:46.386Z" }, - { url = "https://files.pythonhosted.org/packages/10/2a/61e47defbd490618fcde2641f80d5c077f2961de62e88500edacb49297f9/pydantic_core-2.50.0-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:3b2f3e44af4c6512dd73557621385a71b18094c8be81d60e5a8e73a8ce96b7f4", size = 2466713, upload-time = "2026-10-08T14:30:49.214Z" }, - { url = "https://files.pythonhosted.org/packages/61/57/81327752c007834d684b0b7d20ce00087206f512c7347a6236fd89ef0223/pydantic_core-2.50.0-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:593dd4f24abeb3ed90222f98bc6eefcac68cd5aa96fc7745f93862a802952bce", size = 2481971, upload-time = "2026-10-08T14:30:52.088Z" }, - { url = "https://files.pythonhosted.org/packages/48/c3/e321d3a4b2737372cf775ffdd1a02428c7d2791ee5afc9f228793a0ee245/pydantic_core-2.50.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:b123f9d8702106f39dc3af63a031ca8b5279862a3747ec6c03ca49dbe78b71b9", size = 2337958, upload-time = "2026-10-08T14:30:55.045Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515, upload-time = "2026-10-11T18:35:44.82Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/1b/ca81811da3d2aea724fb3c4ba6b83f50ab8f210576306ccd601973142d35/pydantic_core-2.50.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c531166c42ea7bdfecc8c50049581f05dd1993b09cc7c52bb36a14e96deaec7d", size = 2152145, upload-time = "2026-10-11T18:31:58.046Z" }, + { url = "https://files.pythonhosted.org/packages/59/20/938ef7e0541af0a314e2f9d63848dbd1656ba2ebfe5ca0469f7ebee00d5e/pydantic_core-2.50.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6d0c2183008c188e19f4906d426b293bdc4f67ab17df8e180fe16cda208fa71", size = 2045761, upload-time = "2026-10-11T18:31:59.35Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4b/456497fe2affa18f49b226e89bde31e8a204751ee343dd9beaac5438123e/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94be440c03fede26969a5ce75468e0e6a9927a1b46d9b679ee8adc1b057b0350", size = 2049557, upload-time = "2026-10-11T18:32:01.121Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/cd5dfe13928f55e236dff3a1bee06721d318ca6c955d9ca993b0a5d09f39/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:36c426eac0af8d1529ff8467e612b933346caec1fdc0d774f78f67a1a11e16c1", size = 2188153, upload-time = "2026-10-11T18:32:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/fb/8d/d0b25bc484f8d9136ea76ece69e0b2d24ab370cc2e7bbd1b79e61723b763/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028e2f212273d4a39b1ec1e0de8166b1165a65fc0f1111452a9d94fc7c625c63", size = 2388308, upload-time = "2026-10-11T18:32:03.767Z" }, + { url = "https://files.pythonhosted.org/packages/c5/64/6513cac7c9f78492abafa01d57a69de1d246bf3085fd7b0330bf14b025b3/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6f0cc1bb9900dc558960894adeb30b0c083366fc1d69b856209fb2ca5c36fe5", size = 2431187, upload-time = "2026-10-11T18:32:05.159Z" }, + { url = "https://files.pythonhosted.org/packages/ed/60/7f2ae89c37e69a3dc0da9ff57b0388c285542c688d05f36767770429de85/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8812592c85d0edf423f10eadcef42716d71e8219085ad9e85b775057b7306133", size = 2157623, upload-time = "2026-10-11T18:32:06.616Z" }, + { url = "https://files.pythonhosted.org/packages/87/02/e3a71373b85a7973099b9a229a4302c7792262717e0b4997e41c01f7dd16/pydantic_core-2.50.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:bbce99252ba3167b2b6277f1829d5bf4b43b754524bddf7f944707c3db7d2253", size = 2254539, upload-time = "2026-10-11T18:32:07.968Z" }, + { url = "https://files.pythonhosted.org/packages/3f/d5/5b620aaf40dc631d911565b64d49d3fa8422027c046a7a96dc4e4921ae98/pydantic_core-2.50.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:476f6ed8e43cd1e0b460920e23571700872b284e77331cb30c4faf459cf48a4b", size = 2267344, upload-time = "2026-10-11T18:32:09.399Z" }, + { url = "https://files.pythonhosted.org/packages/e0/48/f46baf4580d8a56ca4782aa65842e16d3cf39736b29fcce8e9c0521b3422/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2cbd1b75b09e976ed0d6b6ca297675632ca35df86130088457cdc60ef36970ae", size = 2300877, upload-time = "2026-10-11T18:32:10.903Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9d/e7d28505e6092c1fe075838a4f793ccbe6dd676e44ab16175815ee06cfe0/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:5958c72adb417c39b12ac87525ac60b0d73315fcdc59e21f44ee4a5e2512c9ef", size = 2464270, upload-time = "2026-10-11T18:32:12.329Z" }, + { url = "https://files.pythonhosted.org/packages/22/b1/3bf63b82efd93ebc9968f9c12fc46037a28bcb452c88e4688f33e5ff7dca/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d8f9e8a6c4ab04b78d61f78627370d834eb004b2869dcb28cfffa647b4ea1980", size = 2478653, upload-time = "2026-10-11T18:32:13.735Z" }, + { url = "https://files.pythonhosted.org/packages/28/17/26d1e4b9c2ba6a92b7dc75bbcb99ef44456b7e3af1bced868403667ea7d3/pydantic_core-2.50.1-cp311-cp311-win32.whl", hash = "sha256:4be846f55c9477f5f3ddde8f2ce941137e16862a56d018ed885d422bb6ae02f2", size = 2120246, upload-time = "2026-10-11T18:32:15.591Z" }, + { url = "https://files.pythonhosted.org/packages/24/65/3e875e7991eaecd4d70686293ff0d8a99aa8f93dc295e6a21c3d702912fb/pydantic_core-2.50.1-cp311-cp311-win_amd64.whl", hash = "sha256:0048b6dddc8ef4b64fccaad878bd143b0c3882ea9936279dc11d613f6b7dd1bc", size = 2176332, upload-time = "2026-10-11T18:32:17.153Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5c/58dbd17c61f209d91c70f549eb7a3bbec7023e89ea12dead091462d6d658/pydantic_core-2.50.1-cp311-cp311-win_arm64.whl", hash = "sha256:6a733778df2f7087ec1100ed0b41533e4f3001976e99570fa34f57c66e7f8e3e", size = 2182471, upload-time = "2026-10-11T18:32:18.49Z" }, + { url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428, upload-time = "2026-10-11T18:32:20.228Z" }, + { url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779, upload-time = "2026-10-11T18:32:21.723Z" }, + { url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429, upload-time = "2026-10-11T18:32:23.3Z" }, + { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274, upload-time = "2026-10-11T18:32:24.697Z" }, + { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403, upload-time = "2026-10-11T18:32:26.281Z" }, + { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799, upload-time = "2026-10-11T18:32:27.844Z" }, + { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157, upload-time = "2026-10-11T18:32:29.553Z" }, + { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523, upload-time = "2026-10-11T18:32:31.027Z" }, + { url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700, upload-time = "2026-10-11T18:32:32.622Z" }, + { url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452, upload-time = "2026-10-11T18:32:34.165Z" }, + { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098, upload-time = "2026-10-11T18:32:35.547Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839, upload-time = "2026-10-11T18:32:37.119Z" }, + { url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425, upload-time = "2026-10-11T18:32:38.593Z" }, + { url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522, upload-time = "2026-10-11T18:32:40.112Z" }, + { url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366, upload-time = "2026-10-11T18:32:41.606Z" }, + { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774, upload-time = "2026-10-11T18:32:43.103Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738, upload-time = "2026-10-11T18:32:44.587Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049, upload-time = "2026-10-11T18:32:46.164Z" }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415, upload-time = "2026-10-11T18:32:47.722Z" }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719, upload-time = "2026-10-11T18:32:49.217Z" }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803, upload-time = "2026-10-11T18:32:51.025Z" }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402, upload-time = "2026-10-11T18:32:52.714Z" }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755, upload-time = "2026-10-11T18:32:54.126Z" }, + { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303, upload-time = "2026-10-11T18:32:55.641Z" }, + { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345, upload-time = "2026-10-11T18:32:57.38Z" }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947, upload-time = "2026-10-11T18:32:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889, upload-time = "2026-10-11T18:33:00.665Z" }, + { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118, upload-time = "2026-10-11T18:33:02.329Z" }, + { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840, upload-time = "2026-10-11T18:33:03.919Z" }, + { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633, upload-time = "2026-10-11T18:33:05.518Z" }, + { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428, upload-time = "2026-10-11T18:33:07.153Z" }, + { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740, upload-time = "2026-10-11T18:33:08.763Z" }, + { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435, upload-time = "2026-10-11T18:33:10.366Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975, upload-time = "2026-10-11T18:33:12.368Z" }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197, upload-time = "2026-10-11T18:33:14.244Z" }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259, upload-time = "2026-10-11T18:33:15.79Z" }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857, upload-time = "2026-10-11T18:33:17.51Z" }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954, upload-time = "2026-10-11T18:33:19.591Z" }, + { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876, upload-time = "2026-10-11T18:33:21.393Z" }, + { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908, upload-time = "2026-10-11T18:33:23.324Z" }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285, upload-time = "2026-10-11T18:33:24.942Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013, upload-time = "2026-10-11T18:33:26.925Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400, upload-time = "2026-10-11T18:33:28.693Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222, upload-time = "2026-10-11T18:33:30.326Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586, upload-time = "2026-10-11T18:33:32.139Z" }, + { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773, upload-time = "2026-10-11T18:33:33.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017, upload-time = "2026-10-11T18:33:35.588Z" }, + { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016, upload-time = "2026-10-11T18:33:37.57Z" }, + { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015, upload-time = "2026-10-11T18:33:39.433Z" }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543, upload-time = "2026-10-11T18:33:41.381Z" }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704, upload-time = "2026-10-11T18:33:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118, upload-time = "2026-10-11T18:33:44.856Z" }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142, upload-time = "2026-10-11T18:33:46.661Z" }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577, upload-time = "2026-10-11T18:33:48.574Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728, upload-time = "2026-10-11T18:33:50.527Z" }, + { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630, upload-time = "2026-10-11T18:33:52.362Z" }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048, upload-time = "2026-10-11T18:33:54.118Z" }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159, upload-time = "2026-10-11T18:33:55.99Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406, upload-time = "2026-10-11T18:33:58.043Z" }, + { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481, upload-time = "2026-10-11T18:33:59.913Z" }, + { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972, upload-time = "2026-10-11T18:34:01.875Z" }, + { url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599, upload-time = "2026-10-11T18:34:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191, upload-time = "2026-10-11T18:34:05.901Z" }, + { url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252, upload-time = "2026-10-11T18:34:07.757Z" }, + { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376, upload-time = "2026-10-11T18:34:09.592Z" }, + { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559, upload-time = "2026-10-11T18:34:11.534Z" }, + { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697, upload-time = "2026-10-11T18:34:13.535Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961, upload-time = "2026-10-11T18:34:15.56Z" }, + { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581, upload-time = "2026-10-11T18:34:17.502Z" }, + { url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090, upload-time = "2026-10-11T18:34:19.402Z" }, + { url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948, upload-time = "2026-10-11T18:34:21.317Z" }, + { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688, upload-time = "2026-10-11T18:34:23.321Z" }, + { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286, upload-time = "2026-10-11T18:34:25.214Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683, upload-time = "2026-10-11T18:34:27.42Z" }, + { url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681, upload-time = "2026-10-11T18:34:29.508Z" }, + { url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251, upload-time = "2026-10-11T18:34:31.455Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363, upload-time = "2026-10-11T18:34:33.65Z" }, + { url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972, upload-time = "2026-10-11T18:34:36.037Z" }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925, upload-time = "2026-10-11T18:34:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867, upload-time = "2026-10-11T18:34:39.921Z" }, + { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687, upload-time = "2026-10-11T18:34:42.184Z" }, + { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415, upload-time = "2026-10-11T18:34:44.384Z" }, + { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823, upload-time = "2026-10-11T18:34:46.392Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806, upload-time = "2026-10-11T18:34:48.805Z" }, + { url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160, upload-time = "2026-10-11T18:34:50.876Z" }, + { url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031, upload-time = "2026-10-11T18:34:52.876Z" }, + { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423, upload-time = "2026-10-11T18:34:54.995Z" }, + { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598, upload-time = "2026-10-11T18:34:57.149Z" }, + { url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535, upload-time = "2026-10-11T18:34:59.313Z" }, + { url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645, upload-time = "2026-10-11T18:35:01.571Z" }, + { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297, upload-time = "2026-10-11T18:35:04.079Z" }, + { url = "https://files.pythonhosted.org/packages/e7/73/a23a327237d9bb985298ce47702e196f995ce850224c12c70886bd66bb5c/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:062e891facce5ca296a1c37098e5e466780457f86413894b399f0cf22934f769", size = 2162560, upload-time = "2026-10-11T18:35:06.274Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/33b37b5e82408f402a5e82649a43e7c95cd5dab7beaeef2c35cedbfd18f2/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:49c2cbb2397fe4d0987e84606e691af6cb87bc0ee1bd3e7b737f7e10b4c142f9", size = 2075250, upload-time = "2026-10-11T18:35:08.455Z" }, + { url = "https://files.pythonhosted.org/packages/04/62/c7d5b9249466ccab90f3ce4790c2399dbfb8172ae9e74a7753dd295fe65a/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e4472072de0137ee0d8e72d6620e85939c271d2f90f6bbb4b15c24638b79f92", size = 2107891, upload-time = "2026-10-11T18:35:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d4/7e/28d29bc1657195933147f772dee0558d9e0f045bbc11db7f992f40c37bb5/pydantic_core-2.50.1-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6ed4f3cef55164b026fefb41341b7754cc6b624c75dfe7142d2ecceb5ad21c87", size = 2268026, upload-time = "2026-10-11T18:35:13.336Z" }, + { url = "https://files.pythonhosted.org/packages/a5/09/1bcf160f3cd6333e2a76982fae5963e5032de0bcaaba38464223dc266bfe/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:76e2e83fa6ec8cdc972d438dafc2522b3a47bee4ec0ae668b29cfb1977ab5242", size = 2217070, upload-time = "2026-10-11T18:35:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/3d/81/f02de95eec7794dfc7a5bc69a834f0e834b31b3009014ce6de903855f775/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:a51eee75939cf811ac09b278745a6cee7dc873ccfbc8b9af3cc88fe4b7ce25b5", size = 2039027, upload-time = "2026-10-11T18:35:18.07Z" }, + { url = "https://files.pythonhosted.org/packages/4d/16/227746ed3771d9bf2304568b55134849ece34e13b38b1d76de763015c77f/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae28183297fb0d2b8dc46a1f01d51f5e45825fc5afe76a835a6cb7fb34821295", size = 2109009, upload-time = "2026-10-11T18:35:20.218Z" }, + { url = "https://files.pythonhosted.org/packages/e5/b9/7664d1592a0e5a74903f357cd5b28ceb7336f3473256c5a4e8c8432f1881/pydantic_core-2.50.1-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88e492e8b9d0312e7dc13667c30222abf284dc3b79b5302b3607b41a5784ce61", size = 2269883, upload-time = "2026-10-11T18:35:22.48Z" }, + { url = "https://files.pythonhosted.org/packages/bd/35/002a378302ff91d2a7f149bc8d22363faf581ba40ac39d0123444315a32d/pydantic_core-2.50.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7456d699b13954e9c0164dcb267250a10ae0dfb03e6e26d6796ab0d46e189c84", size = 2154076, upload-time = "2026-10-11T18:35:24.743Z" }, + { url = "https://files.pythonhosted.org/packages/4b/5c/7a034330082c00f3c9795052bd9b161aaaf6e1951e897ac00a094823ef7e/pydantic_core-2.50.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0d955195bbbe489ad343fcc956eacea9357b79cb22192c66cacdefcbc14b32f", size = 2085850, upload-time = "2026-10-11T18:35:27.009Z" }, + { url = "https://files.pythonhosted.org/packages/57/fa/d5b44a7d3d4eceed6ef8713a0003af56fefde6ec75915513bcdd5d730cd9/pydantic_core-2.50.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2c634642694e6a0dad2ab1d375589fa671fd442edd5caf7d9737b8f6ca22906", size = 2259754, upload-time = "2026-10-11T18:35:29.763Z" }, + { url = "https://files.pythonhosted.org/packages/b6/88/e01dd37301710bd8b3b2daf34ec9e2a6e83a333130831c0d468b0292a48c/pydantic_core-2.50.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6db2fbed51a7991302e8fac498cd67e336246026d0dfa84cf5166ce1412760", size = 2268712, upload-time = "2026-10-11T18:35:32.654Z" }, + { url = "https://files.pythonhosted.org/packages/3e/43/5bb80d4a4d2b2d84612a30205662cc521a20d209c94abc079ea48b4e0df2/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:79490e33c4c0fcb933bbbcfc3a62184d8803b99f535863dfbb925e1bcb6945ad", size = 2305049, upload-time = "2026-10-11T18:35:35.392Z" }, + { url = "https://files.pythonhosted.org/packages/e8/7b/e3ef76b269cfe4a5bd3bdcf5d7124cf05e5a8909d443b91639307116bdae/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:48569b0ade9edfbe065cad1d700175546592aebbb42f02adcebcc26e75b896fe", size = 2466001, upload-time = "2026-10-11T18:35:37.791Z" }, + { url = "https://files.pythonhosted.org/packages/8f/7a/ea0947aadc1d9f72d4be00d2e19939611ec17363bcb5e6eb9ec7d8221c34/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5f3cae32fc46121f787cb2486de9cf95a8bf72aec5cc78f64c606fa1735a6ef5", size = 2481777, upload-time = "2026-10-11T18:35:40.287Z" }, + { url = "https://files.pythonhosted.org/packages/24/f3/d15bc0b1fb0c4f1e07b3326ffb7489f7ae70da2b56a0884d22641b0eb47c/pydantic_core-2.50.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7f476456ac2bb0d937f75191494a09c83a30765fea4f70f3b404942fe25f6cdf", size = 2336787, upload-time = "2026-10-11T18:35:42.558Z" }, ] [[package]] @@ -5762,7 +5762,7 @@ wheels = [ [[package]] name = "sqlspec" -version = "0.66.0" +version = "0.67.0" source = { editable = "." } dependencies = [ { name = "mypy-extensions" }, @@ -6398,11 +6398,11 @@ wheels = [ [[package]] name = "truststore" -version = "0.10.4" +version = "0.10.5" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/a3/1585216310e344e8102c22482f6060c7a6ea0322b63e026372e6dcefcfd6/truststore-0.10.4.tar.gz", hash = "sha256:9d91bd436463ad5e4ee4aba766628dd6cd7010cf3e2461756b3303710eebc301", size = 26169, upload-time = "2025-08-12T18:49:02.73Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/9f/c5201d42a484c061e528825fc8e2d565f5abd50a4ced6fb7d29c4ec99b2b/truststore-0.10.5.tar.gz", hash = "sha256:30d36967ccaded5cbb38d602c433f53600036c79d502f4533a49b60a03bbefcd", size = 28091, upload-time = "2026-10-12T22:27:31.808Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/97/56608b2249fe206a67cd573bc93cd9896e1efb9e98bce9c163bcdc704b88/truststore-0.10.4-py3-none-any.whl", hash = "sha256:adaeaecf1cbb5f4de3b1959b42d41f6fab57b2b1666adb59e89cb0b53361d981", size = 18660, upload-time = "2025-08-12T18:49:01.46Z" }, + { url = "https://files.pythonhosted.org/packages/51/e9/3a7820be2bb0fe53b6bc9c3be26d3d1158004e4c3ab953aa6840b955b1e9/truststore-0.10.5-py3-none-any.whl", hash = "sha256:9aaaedaefaf06d8b206278cf8b5012bc897f485a874503501e12d776df78951c", size = 19017, upload-time = "2026-10-12T22:27:30.377Z" }, ] [[package]] From 665e05892f64bddb160d39eb4f33780bd9424fb4 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 13 Oct 2026 22:32:08 +0000 Subject: [PATCH 5/7] docs(changelog): update v0.67.0 release notes and fix Sphinx autodoc warnings --- docs/changelog.rst | 101 +++++++++++++++++++++--------- sqlspec/adapters/duckdb/driver.py | 3 +- sqlspec/core/result/_base.py | 1 - 3 files changed, 72 insertions(+), 33 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 4f0e3c321..3e27b5f5a 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,47 +9,80 @@ important operational fixes. Recent Updates ============== -v0.67.0 - Spanner graph and hybrid search, ADK artifact stores, and Python 3.11+ baseline ------------------------------------------------------------------------------------------ +v0.67.0 - Enterprise ADBC drivers, Spanner graph and hybrid search, hot-path execution, and Python 3.11+ +-------------------------------------------------------------------------------------------------------- **Added:** -* Spanner and Spangres SQLGlot dialects support ``CREATE PROPERTY GRAPH`` and - ``CREATE OR REPLACE PROPERTY GRAPH`` DDL (with ``NODE TABLES``, ``EDGE TABLES``, - ``SOURCE KEY``, ``DESTINATION KEY``, ``LABEL``, and ``PROPERTIES`` clauses) as - well as ``GRAPH_TABLE`` queries with ``MATCH`` path patterns and ``COLUMNS`` - projections. +* ``sqlspec.adapters.adbc`` adds pure-Go GizmoData ADBC driver support for + Oracle (``adbc-driver-oracle``), IBM Db2 (``adbc-driver-db2``), native + GizmoSQL (``adbc-driver-gizmosql`` with automatic preference over + ``adbc-driver-flightsql``, ``gizmosql://`` URIs, and + ``gizmosql_auth_type="external"`` browser OAuth/SSO), and DuckDB Quack + (``adbc-driver-quack`` with ``quack://`` / ``quack+tls://`` URIs and + ``adbc.quack.token`` bearer authentication), including dialect resolution, + parameter binding (``:1`` positional colon for Oracle, ``?`` qmark for Db2), + savepoints, SQLSTATE/vendor exception mapping, and default + ``enable_lowercase_column_names=True`` normalization for Oracle and Db2. + (`#839 `_) +* ``ADBCStore`` (Litestar sessions), ``AdbcEventQueueStore`` (event channels), + ``AdbcADKStore``, and ``AdbcADKMemoryStore`` (Google ADK sessions and memory) + add Oracle (``oracle``) and IBM Db2 (``db2``) backend support with idempotent + DDL guards (PL/SQL ``SQLCODE`` blocks for Oracle and ``SYSCAT`` catalog probes + for Db2) and dialect-specific timestamps, types, and search predicates. + (`#839 `_) +* Spanner and Spangres SQLGlot dialects support ``CREATE [OR REPLACE] PROPERTY + GRAPH [IF NOT EXISTS]`` and ``DROP PROPERTY GRAPH [IF EXISTS]`` DDL (with + ``NODE TABLES``, ``EDGE TABLES``, ``SOURCE KEY``, ``DESTINATION KEY``, + ``LABEL``, and ``PROPERTIES`` clauses), ``GRAPH_TABLE`` queries with ``MATCH`` + path patterns and ``COLUMNS`` projections via ``graph_table()``, ``CREATE + VECTOR INDEX [IF NOT EXISTS] ... STORING (...)``, ``DROP VECTOR INDEX [IF + EXISTS]``, ``DROP SEARCH INDEX [IF EXISTS]``, ``HIDDEN`` generated column + constraints (``TOKENLIST AS (TOKENIZE_FULLTEXT(...)) HIDDEN``), and + ``approx_cosine_distance(..., options=...)``, ``snippet()``, and + ``score_ngrams()`` expression helpers. (`#834 `_) -* Added ``sql.hybrid_rrf(...)`` on the ``SQL`` builder factory to generate - Reciprocal Rank Fusion (RRF) hybrid search queries combining full-text BM25 - scoring and vector distance ranking on Spanner and Spangres. +* ``SpannerSyncADKMemoryStore`` and ``SpannerAsyncADKMemoryStore`` add vector + embedding columns (``ARRAY``) with ``CREATE VECTOR INDEX`` DDL, + optional ``CREATE PROPERTY GRAPH`` DDL overlays, batched ``UNNEST`` + deduplication on ``insert_memory_entries()``, and 4-mode ``search_entries()`` + supporting Hybrid Reciprocal Rank Fusion (RRF) combining + ``APPROX_COSINE_DISTANCE`` and ``SEARCH``/``SCORE``, vector similarity search, + full-text ``SCORE`` ranking, and case-insensitive substring fallback. (`#834 `_) -* Added Google ADK artifact store base classes (``BaseAsyncADKArtifactStore`` - and ``BaseSyncADKArtifactStore``) and Spanner implementations - (``SpannerSyncADKArtifactStore`` and ``SpannerAsyncADKArtifactStore``) with - optional Cloud Storage URI offloading for large payloads. +* Added ``SpannerSyncADKArtifactStore`` and ``SpannerAsyncADKArtifactStore`` + implementations of ``BaseSyncADKArtifactStore`` and + ``BaseAsyncADKArtifactStore`` with composite primary keys + ``(app_name, user_id, session_id, filename, version)``, user-scoped + ``session_id`` normalization, and optional ``ROW DELETION POLICY`` TTL + cleanup. (`#834 `_) -* Spanner event queue stores add a composite queue index - ``(channel, status, available_at)`` with ``FORCE_INDEX`` query hints, row - deletion TTL policies, and single-statement ``THEN RETURN`` claim execution. +* ``SpannerSyncEventQueueStore`` and ``SpannerAsyncEventQueueStore`` add + snapshot-read candidate polling (``provide_session(transaction=False)``), + automatic ``Aborted`` retry wrapping via ``run_in_transaction()``, native + ``ROW DELETION POLICY`` / ``TTL INTERVAL`` retention (``cleanup_on_ack=False``), + optional ``shard_id`` hash-shard prefixing (``shard_count > 1`` on + ``EventsConfig``), covering queue indexes (``STORING`` on GoogleSQL, + ``INCLUDE`` on ``spangres``), full ``spangres`` DDL generation, and + ``database.update_ddl()`` execution for ``create_table()`` and + ``drop_table()``. (`#834 `_) -* Added Python 3.15 support across package metadata and CI test workflows. +* Added Python 3.15 support across package metadata, CI test matrices, and + ``cibuildwheel`` MyPyC wheel builds. (`#835 `_) -* Added pure-Go GizmoData ADBC driver support in ``sqlspec.adapters.adbc`` and - extension stores (``ADBCStore``, ``AdbcEventQueueStore``, ``AdbcADKStore``, - and ``AdbcADKMemoryStore``) for Oracle (``adbc-driver-oracle``), IBM Db2 - (``adbc-driver-db2``), native GizmoSQL (``adbc-driver-gizmosql`` with - ``auth_type="external"`` OAuth SSO), and DuckDB Quack (``adbc-driver-quack``). - (`#839 `_) **Changed:** -* Upgraded the minimum supported Python version to 3.11+ and modernized runtime - typing, ``datetime.UTC``, and ``asyncio.timeout`` usage across the codebase. +* Upgraded the minimum supported Python version to 3.11+ (``requires-python = + ">=3.11, <4.0"``), removed the ``tomli`` fallback in favor of stdlib + ``tomllib``, and modernized ``enum.StrEnum``, ``datetime.UTC``, builtin + ``TimeoutError``, and stdlib ``typing`` usage across the codebase. (`#835 `_) * Standardized driver session lifecycles, statement compilation caching, SQLGlot - builder immutability, migration version tracking, and ADK/Litestar/Events - store contracts across all 20 database adapters. + builder immutability, migration version tracking, ``driver_features`` + type-system coverage, PEP 562 lazy package exports, and MyPyC compilation + across all 20 database adapters (``core``, ``type_converter``, ``pool``, and + ``_typing`` modules). (`#837 `_) * Unified DuckDB row materialization across ``execute()``, ``select()``, and ``fetch*`` methods to consistently return DuckDB-native ``fetchall()`` Python @@ -68,9 +101,15 @@ v0.67.0 - Spanner graph and hybrid search, ADK artifact stores, and Python 3.11+ **Fixed:** -* Hardened parameter conversion, arrow batch streaming, transaction rollback and - cleanup handling, exception mapping, and dialect generation across core, - storage, migrations, extensions, and database adapters. +* Hardened parameter conversion, Arrow batch streaming (``adbc_ingest`` stream + forwarding and ``mssql_python`` batch streaming), transaction and savepoint + rollback recovery, ``SlotPooledStatement`` cloning on ``SQLResult.statement``, + ``sqlglot >= 30.22.0`` single-table ``MappingSchema`` column qualification, + dialect transform isolation (``Spanner``, ``Spangres``, ``DB2``, ``PGVector``, + and ``ParadeDB``), self-referential table dependency ordering and composite + foreign key introspection, Windows ``file:///D:/...`` storage URI + normalization, and exception mapping across core, storage, migrations, + extensions, and database adapters. (`#837 `_) * Prevented unparsed or ``COMMAND``/``UNKNOWN`` fallback compilations from populating the driver statement cache, and cached parsed target-table metadata diff --git a/sqlspec/adapters/duckdb/driver.py b/sqlspec/adapters/duckdb/driver.py index 6194f7a3f..7707a28dc 100644 --- a/sqlspec/adapters/duckdb/driver.py +++ b/sqlspec/adapters/duckdb/driver.py @@ -52,10 +52,11 @@ from sqlspec.adapters.duckdb._typing import DuckDBConnection from sqlspec.builder import QueryBuilder from sqlspec.core import ArrowResult, SQLResult, Statement, StatementFilter + from sqlspec.core.result._base import RowFormat from sqlspec.driver import ExecutionResult from sqlspec.driver._query_cache import CachedQuery from sqlspec.storage import StorageBridgeJob, StorageDestination, StorageFormat, StorageTelemetry - from sqlspec.typing import ArrowReturnFormat, RowFormat, StatementParameters + from sqlspec.typing import ArrowReturnFormat, StatementParameters else: pa = import_optional("pyarrow") diff --git a/sqlspec/core/result/_base.py b/sqlspec/core/result/_base.py index fef16e5c4..4a9528ddf 100644 --- a/sqlspec/core/result/_base.py +++ b/sqlspec/core/result/_base.py @@ -67,7 +67,6 @@ class StatementResult: DELETE, and script operations. Attributes: - statement: The original SQL statement that was executed. data: The result data from the operation. rows_affected: Number of rows affected by the operation. last_inserted_id: Last inserted ID from INSERT operations. From 9588c33fa06b8c8016f60a60d253bdbca803a9ad Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 13 Oct 2026 22:54:36 +0000 Subject: [PATCH 6/7] style(bench): add trailing newline to perf baseline JSON files --- tools/perf_baselines/adbc.json | 2 +- tools/perf_baselines/aiosqlite.json | 2 +- tools/perf_baselines/asyncpg.json | 2 +- tools/perf_baselines/duckdb.json | 2 +- tools/perf_baselines/oracle.json | 2 +- tools/perf_baselines/postgres.json | 2 +- tools/perf_baselines/psqlpy.json | 2 +- tools/perf_baselines/psycopg_async.json | 2 +- tools/perf_baselines/psycopg_sync.json | 2 +- tools/perf_baselines/sqlite.json | 2 +- tools/perf_baselines/subsystems.json | 2 +- tools/scripts/bench.py | 1 + tools/scripts/bench_subsystems.py | 2 +- 13 files changed, 13 insertions(+), 12 deletions(-) diff --git a/tools/perf_baselines/adbc.json b/tools/perf_baselines/adbc.json index f783cdb23..d1149c8a3 100644 --- a/tools/perf_baselines/adbc.json +++ b/tools/perf_baselines/adbc.json @@ -52,4 +52,4 @@ "noisy": false } ] -} \ No newline at end of file +} diff --git a/tools/perf_baselines/aiosqlite.json b/tools/perf_baselines/aiosqlite.json index 4c3aa72c7..39e5f9f3f 100644 --- a/tools/perf_baselines/aiosqlite.json +++ b/tools/perf_baselines/aiosqlite.json @@ -506,4 +506,4 @@ "noisy": false } ] -} \ No newline at end of file +} diff --git a/tools/perf_baselines/asyncpg.json b/tools/perf_baselines/asyncpg.json index f2d65aa57..36cf258ba 100644 --- a/tools/perf_baselines/asyncpg.json +++ b/tools/perf_baselines/asyncpg.json @@ -452,4 +452,4 @@ "noisy": false } ] -} \ No newline at end of file +} diff --git a/tools/perf_baselines/duckdb.json b/tools/perf_baselines/duckdb.json index 8149ab4c0..9b95182b6 100644 --- a/tools/perf_baselines/duckdb.json +++ b/tools/perf_baselines/duckdb.json @@ -506,4 +506,4 @@ "noisy": false } ] -} \ No newline at end of file +} diff --git a/tools/perf_baselines/oracle.json b/tools/perf_baselines/oracle.json index 45ec58da6..83587ec21 100644 --- a/tools/perf_baselines/oracle.json +++ b/tools/perf_baselines/oracle.json @@ -468,4 +468,4 @@ "noisy": false } ] -} \ No newline at end of file +} diff --git a/tools/perf_baselines/postgres.json b/tools/perf_baselines/postgres.json index 79d2afb68..06ac84168 100644 --- a/tools/perf_baselines/postgres.json +++ b/tools/perf_baselines/postgres.json @@ -1028,4 +1028,4 @@ "noisy": false } ] -} \ No newline at end of file +} diff --git a/tools/perf_baselines/psqlpy.json b/tools/perf_baselines/psqlpy.json index 17727b0bc..91c79e360 100644 --- a/tools/perf_baselines/psqlpy.json +++ b/tools/perf_baselines/psqlpy.json @@ -212,4 +212,4 @@ "noisy": false } ] -} \ No newline at end of file +} diff --git a/tools/perf_baselines/psycopg_async.json b/tools/perf_baselines/psycopg_async.json index 038669615..e7f2f5e4b 100644 --- a/tools/perf_baselines/psycopg_async.json +++ b/tools/perf_baselines/psycopg_async.json @@ -212,4 +212,4 @@ "noisy": true } ] -} \ No newline at end of file +} diff --git a/tools/perf_baselines/psycopg_sync.json b/tools/perf_baselines/psycopg_sync.json index 1f80c8bab..40a57c101 100644 --- a/tools/perf_baselines/psycopg_sync.json +++ b/tools/perf_baselines/psycopg_sync.json @@ -212,4 +212,4 @@ "noisy": true } ] -} \ No newline at end of file +} diff --git a/tools/perf_baselines/sqlite.json b/tools/perf_baselines/sqlite.json index 0210af924..86ae88998 100644 --- a/tools/perf_baselines/sqlite.json +++ b/tools/perf_baselines/sqlite.json @@ -650,4 +650,4 @@ "noisy": false } ] -} \ No newline at end of file +} diff --git a/tools/perf_baselines/subsystems.json b/tools/perf_baselines/subsystems.json index 11610c060..832e37c05 100644 --- a/tools/perf_baselines/subsystems.json +++ b/tools/perf_baselines/subsystems.json @@ -333,4 +333,4 @@ "total_time": 0.05711644601251464 } ] -} \ No newline at end of file +} diff --git a/tools/scripts/bench.py b/tools/scripts/bench.py index f95ef1351..3821db516 100644 --- a/tools/scripts/bench.py +++ b/tools/scripts/bench.py @@ -3236,6 +3236,7 @@ def _write_json_results( } with Path(output_path).open("w") as f: json.dump(output, f, indent=2) + f.write("\n") def _optimize_raw_sqlite(conn: sqlite3.Connection) -> None: diff --git a/tools/scripts/bench_subsystems.py b/tools/scripts/bench_subsystems.py index 45721f7fe..d5fb0a5ac 100644 --- a/tools/scripts/bench_subsystems.py +++ b/tools/scripts/bench_subsystems.py @@ -1011,7 +1011,7 @@ def _write_json_results( "included_cases": [result["name"] for result in results], "results": results, } - Path(output_path).write_text(json.dumps(output, indent=2, sort_keys=True)) + Path(output_path).write_text(f"{json.dumps(output, indent=2, sort_keys=True)}\n") if __name__ == "__main__": From 4136de7de658c2c6084858a83d1d56ce3d64c5bd Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Tue, 13 Oct 2026 23:29:58 +0000 Subject: [PATCH 7/7] fix(driver): preserve row-returning profile for cached PRAGMA statements --- sqlspec/driver/_common.py | 14 ++++++++++---- tests/unit/driver/test_query_cache.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/sqlspec/driver/_common.py b/sqlspec/driver/_common.py index c54cbd1db..1303a43d1 100644 --- a/sqlspec/driver/_common.py +++ b/sqlspec/driver/_common.py @@ -15,6 +15,7 @@ from sqlspec.core import ( SQL, CachedStatement, + OperationProfile, ParameterDeclaration, ParameterStyle, SQLResult, @@ -1198,7 +1199,10 @@ def _cache_statement(self, statement: "SQL") -> None: if processed.parsed_expression is None or processed.operation_type in {"COMMAND", "UNKNOWN"}: return param_profile = processed.parameter_profile - cached_state = _clone_processed_state(processed) + operation_profile = processed.operation_profile + if not operation_profile.returns_rows and statement.returns_rows(): + operation_profile = OperationProfile(returns_rows=True, modifies_rows=operation_profile.modifies_rows) + cached_state = _clone_processed_state(processed, operation_profile) cached = CachedQuery( compiled_sql=processed.compiled_sql, parameter_profile=param_profile, @@ -1206,7 +1210,7 @@ def _cache_statement(self, statement: "SQL") -> None: applied_wrap_types=processed.applied_wrap_types, parameter_casts=cached_state.parameter_casts, operation_type=processed.operation_type, - operation_profile=processed.operation_profile, + operation_profile=operation_profile, param_count=param_profile.total_count, processed_state=cached_state, needs_style_remap=bool(_CACHED_NAMED_STYLES.intersection(param_profile.styles)), @@ -2043,7 +2047,9 @@ def _cache_param_values(params: "tuple[Any, ...] | list[Any] | dict[str, Any]") return params -def _clone_processed_state(processed: "ProcessedState") -> "ProcessedState": +def _clone_processed_state( + processed: "ProcessedState", operation_profile: "OperationProfile | None" = None +) -> "ProcessedState": return ProcessedState( compiled_sql=processed.compiled_sql, execution_parameters=processed.execution_parameters, @@ -2056,7 +2062,7 @@ def _clone_processed_state(processed: "ProcessedState") -> "ProcessedState": parameter_casts=dict(processed.parameter_casts), validation_errors=processed.validation_errors.copy(), parameter_profile=processed.parameter_profile, - operation_profile=processed.operation_profile, + operation_profile=operation_profile if operation_profile is not None else processed.operation_profile, is_many=False, ) diff --git a/tests/unit/driver/test_query_cache.py b/tests/unit/driver/test_query_cache.py index 9341b3c3b..2ba2f100e 100644 --- a/tests/unit/driver/test_query_cache.py +++ b/tests/unit/driver/test_query_cache.py @@ -1636,3 +1636,17 @@ def test_declared_parameters_execute_many_copies_only_when_default_missing() -> assert rows[1] is not row_missing assert rows[1] == {"id": 2, "note": None} assert "note" not in row_missing + + +def test_cached_pragma_returns_rows_on_repeated_execution(sqlite_sync_driver: SqliteDriver) -> None: + """Ensure repeated PRAGMA executions preserve row-returning behavior on statement cache hit.""" + sqlite_sync_driver.execute("CREATE TABLE pragma_test (id INTEGER PRIMARY KEY, name TEXT)") + + first_result = sqlite_sync_driver.execute("PRAGMA table_info(pragma_test)") + second_result = sqlite_sync_driver.execute("PRAGMA table_info(pragma_test)") + + first_columns = {row["name"] for row in first_result.get_data()} + second_columns = {row["name"] for row in second_result.get_data()} + + assert first_columns == {"id", "name"} + assert second_columns == {"id", "name"}