Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,7 @@ jobs:
cockroachdb/cockroach:latest
mysql:8.4
mcr.microsoft.com/mssql/server:2022-latest
gvenzl/oracle-xe:18-slim-faststart
gvenzl/oracle-free:23-slim-faststart
ghcr.io/goccy/bigquery-emulator:latest
gcr.io/cloud-spanner-emulator/emulator:latest
Expand Down
39 changes: 39 additions & 0 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,45 @@ important operational fixes.
Recent Updates
==============

v0.56.2
------------------------------------------------------------------------------

**Added:**

* Added ``uuid_from_string()``, ``uuid_from_bytes()``, and ``uuid_from_int()``
in :mod:`sqlspec.utils.uuids`. They always return :class:`uuid.UUID`. Text
parsing uses Rust when ``uuid-utils`` is installed.

**Fixed:**

* PostgreSQL-family ADBC row APIs now decode scalar ``UUID`` and ``UUID[]`` data
to Python UUIDs. This works for buffered and streamed rows. Native Arrow
results keep the extension schema, and
``enable_arrow_extension_types=False`` restores raw storage bytes on row
APIs.
* Oracle 12c through 20c can bind direct Python JSON values to ``BLOB IS JSON``
columns. SQLSpec writes UTF-8 JSON to a BLOB locator for sync, async, batch,
and streaming calls. Oracle 21c and newer still use native ``JSON`` binding.
Explicit ``OracleClob`` values remain CLOBs.

**Changed:**

* BigQuery ``load_from_records()`` now reuses fully checked lists of plain
dictionaries when no fields must move. It still copies rows for explicit
columns, mapping subclasses, and different key orders.
* Spanner and Oracle now reuse bind data when no value needs a conversion. They
copy only after the first changed value. Bound values and checks are
unchanged.
* UUID parsing now uses :mod:`sqlspec.utils.uuids` across adapters and type
converters.

**Docs:**

* The Oracle guide now covers JSON storage, LOB, UUID, and VECTOR behavior. It
also covers driver options and an Oracle ``MERGE`` upsert recipe.
* The ADBC guide now explains UUID row and Arrow results. It also documents the
``enable_arrow_extension_types`` switch.

v0.56.1
------------------------------------------------------------------------------

Expand Down
39 changes: 35 additions & 4 deletions docs/reference/adapters/adbc.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@
ADBC
====

Arrow Database Connectivity adapter providing native Arrow result handling
without conversion overhead. SQLSpec can load the ADBC drivers for PostgreSQL,
SQLite, DuckDB, BigQuery, Snowflake, Flight SQL, and GizmoSQL from one
``AdbcConfig`` surface.
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.

Connection Configuration
========================
Expand Down Expand Up @@ -173,6 +173,37 @@ 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:

.. 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},
)

The compatibility alias ``arrow_extension_types`` controls the same feature.

PostgreSQL Extension Dialects
=============================

Expand Down
196 changes: 196 additions & 0 deletions docs/reference/adapters/oracledb.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,151 @@ Sync and async Oracle adapter using `python-oracledb <https://python-oracledb.re
Features native pipeline mode for multi-statement batching, BLOB support, and
LOB coercion with byte-length thresholds.

Type Handling
=============

SQLSpec installs composable Oracle input and output handlers when a pooled
connection is initialized. The handlers preserve Python values where the
database has a matching native type and use explicit Oracle storage conventions
where it does not.

.. list-table:: Bind behavior
:header-rows: 1
:widths: 24 31 45

* - Python value
- Oracle bind/storage
- Notes
* - ``dict`` or a non-numeric ``list``/``tuple``
- JSON storage selected for the server
- Numeric sequences are reserved for VECTOR binding. An empty sequence is
ambiguous and is not claimed automatically.
* - :class:`~sqlspec.adapters.oracledb.OracleJson`
- JSON storage selected for the server
- Expresses JSON intent. It does not force ``DB_TYPE_JSON`` when the
connected server does not expose native JSON storage.
* - :class:`~sqlspec.adapters.oracledb.OracleClob`
- ``DB_TYPE_CLOB``
- Bypasses the automatic string-size threshold.
* - :class:`~sqlspec.adapters.oracledb.OracleBlob`
- ``DB_TYPE_BLOB``
- Bypasses the automatic bytes-size threshold.
* - :class:`uuid.UUID`
- ``RAW(16)``
- Enabled by ``enable_uuid_binary=True``.
* - NumPy array or a numeric Python sequence
- ``VECTOR``
- Requires Oracle Database 23ai for VECTOR columns. Sparse vectors remain
python-oracledb ``SparseVector`` values.

.. list-table:: Read behavior
:header-rows: 1
:widths: 27 31 42

* - Oracle column
- Python value
- Notes
* - native ``JSON``
- ``dict`` or ``list``
- python-oracledb performs native conversion. JSON numbers may be
:class:`decimal.Decimal`.
* - ``BLOB``/``CLOB``/character data marked ``IS JSON``
- ``dict`` or ``list``
- SQLSpec uses fetch metadata to decode JSON. Textual JSON number lanes
produce ordinary ``int``/``float`` values.
* - OSON ``BLOB``
- ``dict`` or ``list``
- Decoded through python-oracledb when OSON metadata and support are
available.
* - unconstrained ``BLOB``/``CLOB``
- ``bytes``/``str`` or a LOB locator
- JSON-looking contents are not decoded without JSON metadata.
* - ``RAW(16)``
- :class:`uuid.UUID`
- Other RAW widths remain bytes.
* - ``VECTOR``
- NumPy array, ``list``, or ``array.array``
- Controlled by ``vector_return_format``.

JSON Storage By Oracle Version
==============================

The server version selects the automatic JSON bind rung:

.. list-table::
:header-rows: 1
:widths: 20 30 50

* - Oracle Database
- Automatic JSON bind
- Coverage and constraints
* - 21c and newer, including 23ai
- native ``JSON`` / ``DB_TYPE_JSON``
- The repository integration lane exercises 23ai. Native JSON numbers may
be returned as :class:`decimal.Decimal`.
* - 12c through 20c, including 18c and 19c
- ``BLOB`` with ``IS JSON``
- The automated compatibility lane uses Oracle 18c because the pinned
pytest-databases release does not provide a 19c service fixture.
* - 11g and earlier
- ``CLOB``
- Capability fallback only; it is not part of the automated service
matrix.

``BLOB IS JSON`` is the preferred pre-native JSON storage because UTF-8 byte
storage avoids CLOB character-set conversion and typically uses less space for
JSON. Keep CLOB storage as an explicit compatibility or application choice.

Explicit ``CLOB CHECK (payload IS JSON)`` columns remain readable through
metadata-driven conversion on supported servers. For ``BLOB IS JSON`` storage,
SQLSpec serializes direct Python JSON values to UTF-8 and binds a BLOB locator;
callers do not need to provide serialized strings.

Driver Feature Escape Hatches
=============================

Pass these keys through ``driver_features`` on
:class:`~sqlspec.adapters.oracledb.OracleSyncConfig` or
:class:`~sqlspec.adapters.oracledb.OracleAsyncConfig`:

.. list-table::
:header-rows: 1
:widths: 32 23 45

* - Key
- Default
- Effect
* - ``fetch_lobs``
- ``False``
- Return supported LOB values directly; set ``True`` to request native
locators.
* - ``fetch_decimals``
- driver default
- Request Decimal NUMBER results where python-oracledb supports them.
* - ``enable_uuid_binary``
- ``True``
- Convert between :class:`uuid.UUID` and ``RAW(16)``.
* - ``enable_numpy_vectors``
- whether NumPy is installed
- Enable NumPy VECTOR conversion.
* - ``vector_return_format``
- ``"numpy"`` with NumPy, otherwise ``"list"``
- Choose ``"numpy"``, ``"list"``, or ``"array"`` for dense VECTOR
results.
* - ``oracle_varchar2_byte_limit``
- ``4000``
- Route larger UTF-8 strings to CLOB; installations using
``MAX_STRING_SIZE=EXTENDED`` may choose ``32767``.
* - ``oracle_raw_byte_limit``
- ``2000``
- Route larger byte payloads to BLOB.
* - ``arraysize`` / ``prefetchrows``
- python-oracledb defaults
- Override per-cursor fetch buffering.
* - ``enable_lowercase_column_names``
- ``True``
- Normalize implicit uppercase Oracle identifiers for result mappings.

LOB And JSON Fetching
=====================

Expand All @@ -32,6 +177,57 @@ Unconstrained CLOB or BLOB columns are returned as text or bytes even when their
contents look like JSON. Add an Oracle JSON type or ``IS JSON`` constraint when
you want automatic JSON decoding.

MERGE Upserts
=============

Oracle uses ``MERGE`` for an update-or-insert operation. PostgreSQL
``INSERT ... ON CONFLICT`` syntax is not valid Oracle SQL, and SQLSpec does not
rewrite it into ``MERGE``. For a single row, select the named bind values from
``DUAL`` and use the same source aliases in both branches::

merge_widget = """
MERGE INTO widget t
USING (
SELECT :sku AS sku, :name AS name, :quantity AS quantity
FROM DUAL
) s
ON (t.sku = s.sku)
WHEN MATCHED THEN
UPDATE SET
t.name = s.name,
t.quantity = s.quantity,
t.updated_at = SYSTIMESTAMP
WHEN NOT MATCHED THEN
INSERT (id, sku, name, quantity, created_at, updated_at)
VALUES (
widget_seq.NEXTVAL,
s.sku,
s.name,
s.quantity,
SYSTIMESTAMP,
SYSTIMESTAMP
)
"""

await session.execute(
merge_widget,
{"sku": "W-100", "name": "Widget", "quantity": 3},
)

Do not add ``RETURNING`` to this ``MERGE``. When the caller needs an ID
generated by the insert branch, select it by the same unique key before the
transaction is committed::

widget_id = await session.select_value(
"SELECT id FROM widget WHERE sku = :sku",
{"sku": "W-100"},
)

Keeping the ``MERGE`` and follow-up ``SELECT`` in one SQLSpec session preserves
their transaction boundary. For large LOB values, the adapter's Litestar
session store uses the same pattern to merge an ``EMPTY_BLOB()``, select it
``FOR UPDATE``, and write through the returned locator.

.. _oracledb-extension-storage-options:

Extension Table Storage Options
Expand Down
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ maintainers = [{ name = "Litestar Developers", email = "hello@litestar.dev" }]
name = "sqlspec"
readme = "README.md"
requires-python = ">=3.10, <4.0"
version = "0.56.1"
version = "0.56.2"

[project.urls]
Discord = "https://discord.gg/litestar"
Expand Down Expand Up @@ -308,7 +308,7 @@ opt_level = "3" # Maximum optimization (0-3)
allow_dirty = true
commit = false
commit_args = "--no-verify"
current_version = "0.56.1"
current_version = "0.56.2"
ignore_missing_files = false
ignore_missing_version = false
message = "chore(release): bump to v{new_version}"
Expand Down
3 changes: 2 additions & 1 deletion sqlspec/adapters/adbc/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@
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
from sqlspec.utils.uuids import uuid_from_string

if TYPE_CHECKING:
from collections.abc import Callable, Mapping
Expand Down Expand Up @@ -1068,7 +1069,7 @@ def _convert_uuid_value(value: Any, ordinal: int, row_number: int, element_index
if not isinstance(value, str):
raise SQLSpecError(_uuid_binding_error(ordinal, value, row_number, element_index))
try:
return str(UUID(value))
return str(uuid_from_string(value))
except (TypeError, ValueError) as exc:
raise SQLSpecError(_uuid_binding_error(ordinal, value, row_number, element_index)) from exc

Expand Down
14 changes: 11 additions & 3 deletions sqlspec/adapters/adbc/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
)
from sqlspec.driver import BaseSyncExceptionHandler, SyncDriverAdapterBase, SyncRowStream
from sqlspec.exceptions import DatabaseConnectionError, SQLSpecError
from sqlspec.utils.arrow_helpers import arrow_reader_with_deferred_close
from sqlspec.utils.arrow_helpers import arrow_reader_with_deferred_close, arrow_table_to_pylist
from sqlspec.utils.logging import get_logger
from sqlspec.utils.module_loader import ensure_pyarrow
from sqlspec.utils.serializers import to_json
Expand Down Expand Up @@ -129,7 +129,12 @@ def fetch_chunk(self) -> "list[dict[str, Any]]":
batch = next(reader)
except StopIteration:
return []
rows = cast("list[dict[str, Any]]", batch.to_pylist())
rows = arrow_table_to_pylist(
batch,
decode_arrow_extension_types=bool(
self._driver.driver_features.get("enable_arrow_extension_types", True)
),
)
if rows:
return rows

Expand Down Expand Up @@ -224,7 +229,10 @@ def dispatch_execute(self, cursor: "AdbcRawCursor", statement: SQL) -> "Executio

if is_select_like:
arrow_table = cursor.fetch_arrow_table()
data = arrow_table.to_pylist()
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,
Expand Down
Loading
Loading