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
28 changes: 19 additions & 9 deletions docs/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,18 @@ v0.56.0

**Added:**

* New sync and async schema checks can create missing tables and add columns.
Use ``ensure_schema_sync()`` or ``ensure_schema_async()`` for each driver mode.
* New ``SchemaTarget`` and ``SchemaEnsureResult`` types plus sync and async
schema checks can create missing tables and add columns. Use
``ensure_schema_sync()`` or ``ensure_schema_async()`` for each driver mode.
ADK, Litestar session, and durable event stores expose ``manage_schema``,
``create_schema``, and ``run_migrations`` controls for this lifecycle.
* Oracle ADK, durable event, and Litestar session tables now share opt-in
compression, partitioning, In-Memory, and table-option configuration.
* BigQuery session and queue partition options, CockroachDB session hash
sharding and row-level TTL, PostgreSQL table/autovacuum tuning, and opt-in
SQLite extension PRAGMA profiles are now available across the applicable
Litestar, Events, and ADK stores.
* BigQuery session and queue partition options and CockroachDB session hash
sharding and row-level TTL are now available.
* Applicable Litestar, Events, and ADK stores now expose PostgreSQL table and
autovacuum tuning, MySQL and MariaDB table/index options, Spanner sharding and
table/index options, and opt-in SQLite extension PRAGMA profiles.

**Changed:**

Expand All @@ -36,14 +40,20 @@ v0.56.0
* ADK, Litestar session, and durable event stores now derive additive schema
currency from their canonical DDL. ADK no longer seeds or bumps a
``schema_version`` row for additive changes.
* Oracle extension-table optimizations are capability-gated through the
pool-scoped data dictionary and degrade to structured warnings when an
option is unavailable.
* Oracle server-version, JSON storage, and extension-table capability detection
now share the config/pool-scoped data dictionary cache. Requested storage
optimizations degrade to structured warnings when an option is unavailable.

**Fixed:**

* PostgreSQL-family ADBC connections now bind top-level UUID parameters as
PostgreSQL ``uuid`` values across ordinary, batch, streaming, and Arrow
execution routes. (`#650 <https://github.com/litestar-org/sqlspec/issues/650>`_)
* Psycopg sync and async transactions now restore the connection's original
autocommit mode after SQLSpec-owned commit or rollback operations (`#648`_).
* Spanner data-dictionary queries now cast nullable metadata filters to their
concrete ``STRING`` or ``TIMESTAMP`` types, avoiding conflicting parameter
inference when optional filters are omitted.
* Builder caching now reuses value-independent expression templates and binds
each call's current parameters and statement configuration. This also
isolates CTE bodies and returned ASTs instead of sharing mutable cached
Expand Down
179 changes: 179 additions & 0 deletions sqlspec/adapters/adbc/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@
import datetime
import decimal
from collections.abc import Sized
from functools import lru_cache
from typing import TYPE_CHECKING, Any, Final, cast
from uuid import UUID

import sqlglot
from sqlglot import exp
from sqlglot.errors import ParseError

from sqlspec.adapters.adbc.type_converter import get_adbc_type_converter
from sqlspec.core import (
Expand Down Expand Up @@ -68,6 +74,7 @@
"normalize_script_rowcount",
"prepare_parameters_with_casts",
"prepare_postgres_parameters",
"prepare_postgres_uuid_bindings",
"resolve_column_names",
"resolve_dialect_from_config",
"resolve_dialect_from_driver_path",
Expand All @@ -84,6 +91,7 @@
)

COLUMN_CACHE_MAX_SIZE: int = 256
_UUID_TYPES: Final[tuple[type[Any], ...]] = tuple(build_uuid_coercions())

DIALECT_PATTERNS: "dict[str, tuple[str, ...]]" = {
"postgres": ("postgres", "postgresql"),
Expand Down Expand Up @@ -847,6 +855,177 @@ def prepare_parameters_with_casts(
return parameters


def prepare_postgres_uuid_bindings(
compiled_sql: str, prepared_parameters: Any, *, is_many: bool, dialect: str
) -> "tuple[str, object]":
"""Bind top-level UUID values through PostgreSQL-family ADBC drivers.

Args:
compiled_sql: SQL after normal statement compilation and cache rebinding.
prepared_parameters: Current execution's driver parameters.
is_many: Whether parameters contain an execute-many batch.
dialect: Resolved ADBC dialect name.

Returns:
Rewritten SQL and current-call parameters with UUID values normalized,
or the inputs unchanged when no placeholder needs a new UUID cast.
"""
if not is_postgres_dialect(dialect):
return compiled_sql, prepared_parameters
if is_many:
uuid_ordinals = _detect_batch_uuid_ordinals(prepared_parameters)
elif isinstance(prepared_parameters, (list, tuple)):
uuid_ordinals = tuple(
index for index, value in enumerate(prepared_parameters, 1) if isinstance(value, _UUID_TYPES)
)
else:
uuid_ordinals = ()
if not uuid_ordinals:
return compiled_sql, prepared_parameters
sqlglot_dialect = "postgres" if dialect == "postgresql" else dialect
rewritten_sql, effective_ordinals = _rewrite_postgres_uuid_placeholders(
compiled_sql, tuple(sorted(uuid_ordinals)), sqlglot_dialect
)
if not effective_ordinals:
return compiled_sql, prepared_parameters
if is_many:
converted_rows = [
_convert_uuid_row(row, effective_ordinals, row_number)
for row_number, row in enumerate(prepared_parameters, 1)
]
converted_parameters = tuple(converted_rows) if isinstance(prepared_parameters, tuple) else converted_rows
else:
converted_parameters = _convert_uuid_row(prepared_parameters, effective_ordinals, 1)
return rewritten_sql, converted_parameters


def _parameter_ordinal(parameter: exp.Parameter) -> "int | None":
value = parameter.this
if not isinstance(value, exp.Literal) or value.is_string:
return None
try:
return int(value.this)
except (TypeError, ValueError):
return None


def _direct_parameter_cast(parameter: exp.Parameter) -> "exp.Cast | None":
current: exp.Expression = parameter
parent = current.parent
while isinstance(parent, exp.Paren):
current = parent
parent = current.parent
if isinstance(parent, exp.Cast) and parent.this is current:
return parent
return None


@lru_cache(maxsize=256)
def _rewrite_postgres_uuid_placeholders(
sql: str, uuid_ordinals: "tuple[int, ...]", dialect: str
) -> "tuple[str, tuple[int, ...]]":
"""Wrap requested UUID parameter placeholders in explicit UUID casts.

Placeholders that already carry a different explicit cast stay authoritative
and are excluded from the returned effective ordinals. Results are memoized
per ``(sql, uuid_ordinals, dialect)`` so repeated executions of the same
statement shape reuse the rewrite without parsing again.
"""
try:
expressions = cast(
"list[exp.Expression]",
[expression for expression in sqlglot.parse(sql, read=dialect) if expression is not None],
)
except ParseError as exc:
msg = f"Failed to parse PostgreSQL ADBC SQL for UUID parameter binding: {exc}"
raise SQLSpecError(msg) from exc
if not expressions:
msg = "Failed to parse PostgreSQL ADBC SQL for UUID parameter binding: SQLGlot returned no statements."
raise SQLSpecError(msg)

requested = set(uuid_ordinals)
authoritative: set[int] = set()
for expression in expressions:
for parameter in expression.find_all(exp.Parameter):
ordinal = _parameter_ordinal(parameter)
if ordinal is None or ordinal not in requested:
continue
cast_expression = _direct_parameter_cast(parameter)
if cast_expression is not None:
target = cast_expression.args.get("to")
target_kind = target.args.get("kind") if isinstance(target, exp.DataType) else None
if isinstance(target_kind, exp.Dot):
target_kind = target_kind.expression
is_uuid_cast = isinstance(target, exp.DataType) and (
target.this == exp.DataType.Type.UUID
or (
target.this == exp.DataType.Type.USERDEFINED
and isinstance(target_kind, exp.Identifier)
and target_kind.name.lower() == "uuid"
)
)
if not is_uuid_cast:
authoritative.add(ordinal)
effective = tuple(ordinal for ordinal in uuid_ordinals if ordinal not in authoritative)
effective_set = set(effective)

def wrap_parameter(node: exp.Expression) -> exp.Expression:
if not isinstance(node, exp.Parameter):
return node
ordinal = _parameter_ordinal(node)
if ordinal is None or ordinal not in effective_set:
return node
if _direct_parameter_cast(node) is not None:
return node
return exp.Cast(this=node.copy(), to=exp.DataType.build("UUID"))

for expression in expressions:
expression.transform(wrap_parameter, copy=False)
return "; ".join(expression.sql(dialect=dialect) for expression in expressions), effective


def _detect_batch_uuid_ordinals(parameters: Any) -> "tuple[int, ...]":
if not isinstance(parameters, (list, tuple)) or not parameters:
return ()
first_row = parameters[0]
if not isinstance(first_row, (list, tuple)):
return ()
expected_size = len(first_row)
for row in parameters:
if not isinstance(row, (list, tuple)) or len(row) != expected_size:
return ()
return tuple(
ordinal
for ordinal in range(1, expected_size + 1)
if any(isinstance(row[ordinal - 1], _UUID_TYPES) for row in parameters)
)


def _convert_uuid_row(row: Any, ordinals: "tuple[int, ...]", row_number: int) -> Any:
converted = list(row)
for ordinal in ordinals:
value = converted[ordinal - 1]
if value is None:
continue
if not isinstance(value, _UUID_TYPES) and not isinstance(value, str):
msg = (
f"ADBC PostgreSQL UUID parameter ordinal {ordinal} has incompatible "
f"{type(value).__name__} value in batch row {row_number}; expected a UUID object, "
"parseable UUID string, or None."
)
raise SQLSpecError(msg)
try:
converted[ordinal - 1] = str(UUID(str(value)))
except (AttributeError, TypeError, ValueError) as exc:
msg = (
f"ADBC PostgreSQL UUID parameter ordinal {ordinal} has incompatible "
f"{type(value).__name__} value in batch row {row_number}; expected a UUID object, "
"parseable UUID string, or None."
)
raise SQLSpecError(msg) from exc
return tuple(converted) if isinstance(row, tuple) else converted


def _create_adbc_error(error: Any, error_class: type[SQLSpecError], description: str) -> SQLSpecError:
"""Create an ADBC error instance without raising it."""
msg = f"ADBC {description}: {error}"
Expand Down
11 changes: 11 additions & 0 deletions sqlspec/adapters/adbc/driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
normalize_postgres_empty_parameters,
normalize_script_rowcount,
prepare_postgres_parameters,
prepare_postgres_uuid_bindings,
resolve_column_names,
resolve_dialect_name,
resolve_many_rowcount,
Expand Down Expand Up @@ -189,6 +190,16 @@ def __init__(
# CORE DISPATCH METHODS
# ─────────────────────────────────────────────────────────────────────────────

def _compiled_sql(
self, statement: "SQL", statement_config: "StatementConfig", flatten_single_parameters: bool = False
) -> "tuple[str, object]":
compiled_sql, prepared_parameters = super()._compiled_sql(
statement, statement_config, flatten_single_parameters=flatten_single_parameters
)
return prepare_postgres_uuid_bindings(
compiled_sql, prepared_parameters, is_many=statement.is_many, dialect=self._dialect_name
)

def dispatch_execute(self, cursor: "AdbcRawCursor", statement: SQL) -> "ExecutionResult":
"""Execute single SQL statement.

Expand Down
69 changes: 69 additions & 0 deletions tests/integration/adapters/postgres/adbc/test_driver.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
"""PostgreSQL-backed ADBC driver residuals."""

from uuid import uuid4

import pytest

from sqlspec.adapters.adbc import AdbcDriver
from tests.integration.adapters._shared.adbc_backends import postgresql_session, test_postgresql_specific_features
from tests.integration.adapters._shared.adbc_connection import (
test_connection,
Expand All @@ -22,3 +27,67 @@
"test_execute_script_edge_cases",
"test_postgresql_specific_features",
)


@pytest.mark.xdist_group("postgres")
@pytest.mark.adbc
def test_postgresql_uuid_identity_and_same_sql_cache_reuse(postgresql_session: AdbcDriver) -> None:
"""Distinct UUID objects bind through one cached INSERT statement without losing identity."""
table_name = "adbc_uuid_identity"
values = [uuid4(), uuid4()]
insert_sql = f"INSERT INTO {table_name} (position, value) VALUES (?, ?)"

try:
postgresql_session.execute_script(f"DROP TABLE IF EXISTS {table_name}")
postgresql_session.execute_script(f"CREATE TABLE {table_name} (position INTEGER PRIMARY KEY, value UUID)")
for position, value in enumerate(values, 1):
postgresql_session.execute(insert_sql, (position, value))

rows = postgresql_session.execute(
f"SELECT position, value::text AS value FROM {table_name} ORDER BY position"
).get_data()
assert [row["value"] for row in rows] == [str(value) for value in values]
finally:
postgresql_session.execute_script(f"DROP TABLE IF EXISTS {table_name}")


@pytest.mark.xdist_group("postgres")
@pytest.mark.adbc
@pytest.mark.parametrize("uuid_first", [False, True], ids=["ordinary-first", "uuid-first"])
def test_postgresql_uuid_binding_does_not_leak_through_same_sql_cache(
postgresql_session: AdbcDriver, uuid_first: bool
) -> None:
"""Value-aware UUID SQL never replaces the stable cached statement in either value order."""
statement = "SELECT pg_typeof($1)::text AS bound_type"
uuid_value = uuid4()
values = (uuid_value, "ordinary") if uuid_first else ("ordinary", uuid_value)

bound_types = [postgresql_session.select_value(statement, value) for value in values]
uuid_type, ordinary_type = bound_types if uuid_first else reversed(bound_types)

assert uuid_type == "uuid"
assert ordinary_type != uuid_type


@pytest.mark.xdist_group("postgres")
@pytest.mark.adbc
def test_postgresql_uuid_batch_inference(postgresql_session: AdbcDriver) -> None:
"""Batch binding infers UUID columns across rows and accepts strings and nulls."""
table_name = "adbc_uuid_batch"
first_value = uuid4()
last_value = uuid4()

try:
postgresql_session.execute_script(f"DROP TABLE IF EXISTS {table_name}")
postgresql_session.execute_script(f"CREATE TABLE {table_name} (position INTEGER PRIMARY KEY, value UUID)")
postgresql_session.execute_many(
f"INSERT INTO {table_name} (position, value) VALUES (?, ?)",
[(1, str(first_value).upper()), (2, None), (3, last_value)],
)

rows = postgresql_session.execute(
f"SELECT position, value::text AS value FROM {table_name} ORDER BY position"
).get_data()
assert [row["value"] for row in rows] == [str(first_value), None, str(last_value)]
finally:
postgresql_session.execute_script(f"DROP TABLE IF EXISTS {table_name}")
Loading
Loading