From a0afb394697f6c8d64e439775a3e48d6d80390b2 Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Fri, 24 Jul 2026 21:01:42 +0000 Subject: [PATCH 1/2] perf(adbc): convert UUID parameters without re-parsing PostgreSQL-family ADBC formatted every UUID parameter as str(UUID(str(value))), re-parsing values that were already UUID objects before formatting them a second time. Format UUID objects directly and reserve parsing for string inputs, which are the only values that need validating and canonicalizing. Bound values are unchanged. Measured over 10k single-ordinal rows, UUID-object conversion drops from 26.5ms to 10.0ms; the string path is unaffected. Also pins the conversion contract with tests: bare strings are never detected as UUIDs on the single-execution path, while every parseable string form canonicalizes on the batch path once a UUID object establishes the ordinal. --- docs/changelog.rst | 10 +++++ sqlspec/adapters/adbc/core.py | 32 +++++++------- .../adapters/test_adbc/test_uuid_binding.py | 42 +++++++++++++++++++ 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1b40cca05..1bcaf7cb2 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,16 @@ important operational fixes. Recent Updates ============== +v0.57.0 +------------------------------------------------------------------------------ + +**Changed:** + +* PostgreSQL-family ADBC drivers convert UUID parameters faster. UUID objects + are now formatted directly instead of being re-parsed on every execution, + which roughly halves the conversion cost of large ``execute_many`` batches. + Bound values are unchanged. + v0.56.0 ------------------------------------------------------------------------------ diff --git a/sqlspec/adapters/adbc/core.py b/sqlspec/adapters/adbc/core.py index e62b9c9fa..c4fe93e36 100644 --- a/sqlspec/adapters/adbc/core.py +++ b/sqlspec/adapters/adbc/core.py @@ -1001,28 +1001,30 @@ def _detect_batch_uuid_ordinals(parameters: Any) -> "tuple[int, ...]": ) +def _uuid_binding_error(ordinal: int, value: Any, row_number: int) -> str: + """Build the incompatible-value message for a UUID parameter ordinal.""" + return ( + 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." + ) + + 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) + if isinstance(value, _UUID_TYPES): + converted[ordinal - 1] = str(value) + continue + if not isinstance(value, str): + raise SQLSpecError(_uuid_binding_error(ordinal, value, row_number)) 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 + converted[ordinal - 1] = str(UUID(value)) + except (TypeError, ValueError) as exc: + raise SQLSpecError(_uuid_binding_error(ordinal, value, row_number)) from exc return tuple(converted) if isinstance(row, tuple) else converted diff --git a/tests/unit/adapters/test_adbc/test_uuid_binding.py b/tests/unit/adapters/test_adbc/test_uuid_binding.py index b31f2fe0f..91015b7f6 100644 --- a/tests/unit/adapters/test_adbc/test_uuid_binding.py +++ b/tests/unit/adapters/test_adbc/test_uuid_binding.py @@ -200,6 +200,48 @@ def test_uuid_utils_values_are_normalized() -> None: assert compiled_parameters == [str(UUID_VALUE)] +@pytest.mark.parametrize( + "value", + [ + pytest.param(str(UUID_VALUE), id="canonical-string"), + pytest.param(str(UUID_VALUE).upper(), id="uppercase-string"), + pytest.param(UUID_VALUE.hex, id="unhyphenated-string"), + ], +) +def test_string_values_are_never_detected_as_uuid_in_single_execution(value: str) -> None: + """A bare string is bound as text; only UUID objects select an ordinal for casting.""" + compiled_sql, compiled_parameters = _compile("SELECT ?", (value,)) + + assert compiled_sql == "SELECT $1" + assert compiled_parameters == [value] + + +@pytest.mark.parametrize( + "value", + [ + pytest.param(str(UUID_VALUE), id="canonical-string"), + pytest.param(str(UUID_VALUE).upper(), id="uppercase-string"), + pytest.param(f"{{{UUID_VALUE}}}", id="braced-string"), + pytest.param(UUID_VALUE.urn, id="urn-string"), + pytest.param(UUID_VALUE.hex, id="unhyphenated-string"), + ], +) +def test_batch_string_values_normalize_to_canonical_form(value: str) -> None: + """Once a UUID object establishes an ordinal, every parseable string form canonicalizes.""" + _, compiled_parameters = _compile("SELECT ?", [(UUID_VALUE,), (value,)], is_many=True) + + assert compiled_parameters == [(str(UUID_VALUE),), (str(UUID_VALUE),)] + + +def test_uuid_objects_convert_to_canonical_form() -> None: + """UUID objects emit the canonical dashed lowercase string on single and batch paths.""" + _, single = _compile("SELECT ?", (OTHER_UUID_VALUE,)) + _, batch = _compile("SELECT ?", [(UUID_VALUE,), (OTHER_UUID_VALUE,), (None,)], is_many=True) + + assert single == [str(OTHER_UUID_VALUE)] + assert batch == [(str(UUID_VALUE),), (str(OTHER_UUID_VALUE),), (None,)] + + def test_batch_uuid_inference_uses_every_row_and_accepts_none_and_uuid_strings() -> None: compiled_sql, compiled_parameters = _compile( "INSERT INTO values_table (identifier, label) VALUES (?, ?)", From deda1ebc301909c5674757039f45704155060d6a Mon Sep 17 00:00:00 2001 From: Cody Fincher Date: Fri, 24 Jul 2026 22:13:33 +0000 Subject: [PATCH 2/2] fix(#654): bind UUID array parameters for PostgreSQL ADBC Binding a list of UUIDs as one PostgreSQL array parameter failed before reaching the database. PyArrow 25 cannot build a nested extension array from Python objects at all, so the only workable representation is a string array plus an explicit UUID[] cast. Detection now classifies each parameter as a scalar UUID or a UUID array by peeking at the first non-null element of a sequence, and the SQL rewrite emits CAST($n AS UUID[]) for array ordinals. A caller-written UUID[] cast is recognized as satisfying that requirement rather than treated as a foreign cast, which previously caused the values to be left unconverted. Both ordinal kinds share the existing memoized rewrite. Also fixes a related failure where a UUID that is a statement's only parameter reached PostgreSQL as bytea. UUID types were absent from the ADBC type coercion map, so a lone UUID argument took the statement cache fast path and skipped UUID binding entirely on every execution after the first. Existing coverage missed this because its statements always bound an integer alongside the UUID, which forced the slower path. Arrays containing None are rejected with an explicit message: the PostgreSQL ADBC driver encodes null array elements as empty strings, which PostgreSQL rejects for UUID[]. Empty, all-null, and non-UUID-headed sequences are left untouched. Statement name generation now uses the shared UUID helper, which prefers the accelerated implementation when it is installed. Fixes #654. --- docs/changelog.rst | 17 ++ sqlspec/adapters/adbc/core.py | 204 ++++++++++++------ sqlspec/core/statement.py | 4 +- .../adapters/postgres/adbc/test_driver.py | 120 +++++++++++ .../adapters/test_adbc/test_uuid_binding.py | 152 +++++++++++-- 5 files changed, 418 insertions(+), 79 deletions(-) diff --git a/docs/changelog.rst b/docs/changelog.rst index 1bcaf7cb2..bdea6a0bc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -12,12 +12,29 @@ Recent Updates v0.57.0 ------------------------------------------------------------------------------ +**Added:** + +* PostgreSQL-family ADBC drivers accept a list or tuple of UUIDs as a single + array parameter, so queries such as + ``WHERE id = ANY(CAST(? AS UUID[]))`` now work. The cast is added + automatically when the query does not already supply one. + +**Fixed:** + +* PostgreSQL-family ADBC drivers no longer fail when a UUID is a statement's + only parameter. Repeated executions of such a statement previously reused a + cached plan that skipped UUID binding and reached PostgreSQL as ``bytea``. + **Changed:** * PostgreSQL-family ADBC drivers convert UUID parameters faster. UUID objects are now formatted directly instead of being re-parsed on every execution, which roughly halves the conversion cost of large ``execute_many`` batches. Bound values are unchanged. +* Binding a UUID array containing ``None`` now raises an explicit error. The + PostgreSQL ADBC driver encodes null array elements as empty strings, which + PostgreSQL rejects for ``UUID[]``; the previous behavior was an opaque + ``invalid input syntax for type uuid`` failure from the server. v0.56.0 ------------------------------------------------------------------------------ diff --git a/sqlspec/adapters/adbc/core.py b/sqlspec/adapters/adbc/core.py index c4fe93e36..aeac41acd 100644 --- a/sqlspec/adapters/adbc/core.py +++ b/sqlspec/adapters/adbc/core.py @@ -635,6 +635,8 @@ def _convert_array_for_postgres_adbc(value: Any) -> Any: tuple: _convert_array_for_postgres_adbc, list: _convert_array_for_postgres_adbc, dict: _identity, + UUID: _identity, + **build_uuid_coercions(native=True), } @@ -873,29 +875,27 @@ def prepare_postgres_uuid_bindings( if not is_postgres_dialect(dialect): return compiled_sql, prepared_parameters if is_many: - uuid_ordinals = _detect_batch_uuid_ordinals(prepared_parameters) + scalar_ordinals, array_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) - ) + scalar_ordinals, array_ordinals = _detect_uuid_ordinals(prepared_parameters) else: - uuid_ordinals = () - if not uuid_ordinals: + scalar_ordinals, array_ordinals = (), () + if not scalar_ordinals and not array_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 + rewritten_sql, effective_scalars, effective_arrays = _rewrite_postgres_uuid_placeholders( + compiled_sql, tuple(sorted(scalar_ordinals)), tuple(sorted(array_ordinals)), sqlglot_dialect ) - if not effective_ordinals: + if not effective_scalars and not effective_arrays: return compiled_sql, prepared_parameters if is_many: converted_rows = [ - _convert_uuid_row(row, effective_ordinals, row_number) + _convert_uuid_row(row, effective_scalars, effective_arrays, 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) + converted_parameters = _convert_uuid_row(prepared_parameters, effective_scalars, effective_arrays, 1) return rewritten_sql, converted_parameters @@ -920,16 +920,42 @@ def _direct_parameter_cast(parameter: exp.Parameter) -> "exp.Cast | None": return None +def _is_uuid_data_type(target: Any) -> bool: + """Report whether a cast target names the scalar ``UUID`` type.""" + if not isinstance(target, exp.DataType): + return False + if target.this == exp.DataType.Type.UUID: + return True + target_kind = target.args.get("kind") + if isinstance(target_kind, exp.Dot): + target_kind = target_kind.expression + return ( + target.this == exp.DataType.Type.USERDEFINED + and isinstance(target_kind, exp.Identifier) + and target_kind.name.lower() == "uuid" + ) + + +def _is_uuid_array_data_type(target: Any) -> bool: + """Report whether a cast target names a one-dimensional ``UUID[]`` type.""" + if not isinstance(target, exp.DataType) or target.this != exp.DataType.Type.ARRAY: + return False + element_types = target.expressions + return len(element_types) == 1 and _is_uuid_data_type(element_types[0]) + + @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. + sql: str, scalar_ordinals: "tuple[int, ...]", array_ordinals: "tuple[int, ...]", dialect: str +) -> "tuple[str, tuple[int, ...], tuple[int, ...]]": + """Wrap requested UUID parameter placeholders in explicit UUID or UUID[] casts. + + Placeholders already carrying the cast their ordinal requires keep that cast + and stay effective. Placeholders carrying any other explicit cast stay + authoritative and are excluded from the returned effective ordinals. Results + are memoized per ``(sql, scalar_ordinals, array_ordinals, dialect)`` so + repeated executions of the same statement shape reuse the rewrite without + parsing again. """ try: expressions = cast( @@ -943,7 +969,8 @@ def _rewrite_postgres_uuid_placeholders( msg = "Failed to parse PostgreSQL ADBC SQL for UUID parameter binding: SQLGlot returned no statements." raise SQLSpecError(msg) - requested = set(uuid_ordinals) + array_requested = set(array_ordinals) + requested = set(scalar_ordinals) | array_requested authoritative: set[int] = set() for expression in expressions: for parameter in expression.find_all(exp.Parameter): @@ -951,23 +978,18 @@ def _rewrite_postgres_uuid_placeholders( 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) + if cast_expression is None: + continue + target = cast_expression.args.get("to") + matches_required_cast = ( + _is_uuid_array_data_type(target) if ordinal in array_requested else _is_uuid_data_type(target) + ) + if not matches_required_cast: + authoritative.add(ordinal) + effective_scalars = tuple(ordinal for ordinal in scalar_ordinals if ordinal not in authoritative) + effective_arrays = tuple(ordinal for ordinal in array_ordinals if ordinal not in authoritative) + effective_array_set = set(effective_arrays) + effective_set = set(effective_scalars) | effective_array_set def wrap_parameter(node: exp.Expression) -> exp.Expression: if not isinstance(node, exp.Parameter): @@ -977,54 +999,112 @@ def wrap_parameter(node: exp.Expression) -> exp.Expression: return node if _direct_parameter_cast(node) is not None: return node - return exp.Cast(this=node.copy(), to=exp.DataType.build("UUID")) + target_type = "UUID[]" if ordinal in effective_array_set else "UUID" + return exp.Cast(this=node.copy(), to=exp.DataType.build(target_type, dialect=dialect)) for expression in expressions: expression.transform(wrap_parameter, copy=False) - return "; ".join(expression.sql(dialect=dialect) for expression in expressions), effective + rewritten = "; ".join(expression.sql(dialect=dialect) for expression in expressions) + return rewritten, effective_scalars, effective_arrays -def _detect_batch_uuid_ordinals(parameters: Any) -> "tuple[int, ...]": +def _sequence_holds_uuid(values: "list[Any] | tuple[Any, ...]") -> bool: + """Report whether a sequence's first non-null element is a UUID.""" + for value in values: + if value is not None: + return isinstance(value, _UUID_TYPES) + return False + + +def _detect_uuid_ordinals(parameters: "list[Any] | tuple[Any, ...]") -> "tuple[tuple[int, ...], tuple[int, ...]]": + """Split a parameter sequence into scalar-UUID and UUID-array ordinals.""" + scalar_ordinals: list[int] = [] + array_ordinals: list[int] = [] + for ordinal, value in enumerate(parameters, 1): + if isinstance(value, _UUID_TYPES): + scalar_ordinals.append(ordinal) + elif isinstance(value, (list, tuple)) and _sequence_holds_uuid(value): + array_ordinals.append(ordinal) + return tuple(scalar_ordinals), tuple(array_ordinals) + + +def _detect_batch_uuid_ordinals(parameters: Any) -> "tuple[tuple[int, ...], tuple[int, ...]]": if not isinstance(parameters, (list, tuple)) or not parameters: - return () + return (), () first_row = parameters[0] if not isinstance(first_row, (list, tuple)): - return () + 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 _uuid_binding_error(ordinal: int, value: Any, row_number: int) -> str: + return (), () + scalar_ordinals: list[int] = [] + array_ordinals: list[int] = [] + for ordinal in range(1, expected_size + 1): + values = [row[ordinal - 1] for row in parameters] + if any(isinstance(value, _UUID_TYPES) for value in values): + scalar_ordinals.append(ordinal) + elif any(isinstance(value, (list, tuple)) and _sequence_holds_uuid(value) for value in values): + array_ordinals.append(ordinal) + return tuple(scalar_ordinals), tuple(array_ordinals) + + +def _uuid_binding_error(ordinal: int, value: Any, row_number: int, element_index: "int | None" = None) -> str: """Build the incompatible-value message for a UUID parameter ordinal.""" + location = "" if element_index is None else f" at element {element_index}" return ( f"ADBC PostgreSQL UUID parameter ordinal {ordinal} has incompatible " - f"{type(value).__name__} value in batch row {row_number}; expected a UUID object, " + f"{type(value).__name__} value{location} in batch row {row_number}; expected a UUID object, " "parseable UUID string, or None." ) -def _convert_uuid_row(row: Any, ordinals: "tuple[int, ...]", row_number: int) -> Any: +def _convert_uuid_value(value: Any, ordinal: int, row_number: int, element_index: "int | None" = None) -> Any: + """Normalize one UUID-bound value to its canonical string form.""" + if value is None: + return None + if isinstance(value, _UUID_TYPES): + return str(value) + if not isinstance(value, str): + raise SQLSpecError(_uuid_binding_error(ordinal, value, row_number, element_index)) + try: + return str(UUID(value)) + except (TypeError, ValueError) as exc: + raise SQLSpecError(_uuid_binding_error(ordinal, value, row_number, element_index)) from exc + + +def _null_array_element_error(ordinal: int, row_number: int, element_index: int) -> str: + """Build the null-array-element message for a UUID array parameter ordinal.""" + return ( + f"ADBC PostgreSQL UUID parameter ordinal {ordinal} has a null value at element {element_index} " + f"in batch row {row_number}; the PostgreSQL ADBC driver encodes null array elements as empty " + "strings, which PostgreSQL rejects for UUID[]. Remove the null element or bind the array as text." + ) + + +def _convert_uuid_sequence(values: Any, ordinal: int, row_number: int) -> "list[Any]": + """Normalize every element of a UUID array parameter.""" + converted: list[Any] = [] + for element_index, value in enumerate(values, 1): + if value is None: + raise SQLSpecError(_null_array_element_error(ordinal, row_number, element_index)) + converted.append(_convert_uuid_value(value, ordinal, row_number, element_index)) + return converted + + +def _convert_uuid_row( + row: Any, scalar_ordinals: "tuple[int, ...]", array_ordinals: "tuple[int, ...]", row_number: int +) -> Any: converted = list(row) - for ordinal in ordinals: + for ordinal in scalar_ordinals: + converted[ordinal - 1] = _convert_uuid_value(converted[ordinal - 1], ordinal, row_number) + for ordinal in array_ordinals: value = converted[ordinal - 1] if value is None: continue - if isinstance(value, _UUID_TYPES): - converted[ordinal - 1] = str(value) - continue - if not isinstance(value, str): + if not isinstance(value, (list, tuple)): raise SQLSpecError(_uuid_binding_error(ordinal, value, row_number)) - try: - converted[ordinal - 1] = str(UUID(value)) - except (TypeError, ValueError) as exc: - raise SQLSpecError(_uuid_binding_error(ordinal, value, row_number)) from exc + converted[ordinal - 1] = _convert_uuid_sequence(value, ordinal, row_number) return tuple(converted) if isinstance(row, tuple) else converted diff --git a/sqlspec/core/statement.py b/sqlspec/core/statement.py index 9ea116beb..7b72c2fa4 100644 --- a/sqlspec/core/statement.py +++ b/sqlspec/core/statement.py @@ -1,7 +1,6 @@ """SQL statement and configuration management.""" import hashlib -import uuid from collections.abc import Mapping, Sequence from typing import TYPE_CHECKING, Any, Final, TypeAlias @@ -55,6 +54,7 @@ from sqlspec.typing import Empty, EmptyEnum from sqlspec.utils.logging import get_logger from sqlspec.utils.type_guards import is_statement_filter, supports_where +from sqlspec.utils.uuids import uuid4 if TYPE_CHECKING: from collections.abc import Callable @@ -1027,7 +1027,7 @@ def _next_parameter_name(self, base_name: str) -> str: while candidate in self._named_parameters: next_index += 1 if next_index > _MAX_PARAM_COLLISION_ATTEMPTS: - return f"{prefixed_base}_{uuid.uuid4().hex[:8]}" + return f"{prefixed_base}_{uuid4().hex[:8]}" candidate = f"{prefixed_base}_{next_index}" self._sql_param_counters[prefixed_base] = next_index diff --git a/tests/integration/adapters/postgres/adbc/test_driver.py b/tests/integration/adapters/postgres/adbc/test_driver.py index a20abb09c..2eb1429b7 100644 --- a/tests/integration/adapters/postgres/adbc/test_driver.py +++ b/tests/integration/adapters/postgres/adbc/test_driver.py @@ -5,6 +5,7 @@ import pytest from sqlspec.adapters.adbc import AdbcDriver +from sqlspec.exceptions import SQLSpecError from tests.integration.adapters._shared.adbc_backends import postgresql_session, test_postgresql_specific_features from tests.integration.adapters._shared.adbc_connection import ( test_connection, @@ -91,3 +92,122 @@ def test_postgresql_uuid_batch_inference(postgresql_session: AdbcDriver) -> None 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}") + + +@pytest.mark.xdist_group("postgres") +@pytest.mark.adbc +@pytest.mark.parametrize( + "predicate", + [ + pytest.param("id = ANY(CAST(? AS UUID[]))", id="caller-written-cast"), + pytest.param("id = ANY(?)", id="cast-free"), + ], +) +def test_postgresql_uuid_array_parameter_matches_rows(postgresql_session: AdbcDriver, predicate: str) -> None: + """A UUID list binds as one uuid[] parameter, with or without a caller-written cast.""" + table_name = "adbc_uuid_array" + wanted = [uuid4(), uuid4()] + unwanted = uuid4() + + try: + postgresql_session.execute_script(f"DROP TABLE IF EXISTS {table_name}") + postgresql_session.execute_script(f"CREATE TABLE {table_name} (id UUID PRIMARY KEY)") + for value in (*wanted, unwanted): + postgresql_session.execute(f"INSERT INTO {table_name} (id) VALUES (?)", (value,)) + + rows = postgresql_session.execute( + f"SELECT id::text AS id FROM {table_name} WHERE {predicate} ORDER BY id::text", (wanted,) + ).get_data() + + assert sorted(row["id"] for row in rows) == sorted(str(value) for value in wanted) + finally: + postgresql_session.execute_script(f"DROP TABLE IF EXISTS {table_name}") + + +@pytest.mark.xdist_group("postgres") +@pytest.mark.adbc +def test_postgresql_uuid_array_canonicalizes_mixed_string_forms(postgresql_session: AdbcDriver) -> None: + """Every parseable string form canonicalizes on the way to a uuid[] parameter.""" + first_value = uuid4() + second_value = uuid4() + parameters = [first_value, str(second_value).upper()] + + rows = postgresql_session.execute("SELECT unnest(CAST(? AS UUID[]))::text AS value", (parameters,)).get_data() + + assert [row["value"] for row in rows] == [str(first_value), str(second_value)] + + +@pytest.mark.xdist_group("postgres") +@pytest.mark.adbc +def test_postgresql_uuid_array_rejects_null_elements(postgresql_session: AdbcDriver) -> None: + """The ADBC driver encodes null array elements as empty strings, so they are rejected up front.""" + with pytest.raises(SQLSpecError, match="null value at element 2"): + postgresql_session.execute("SELECT CAST(? AS UUID[]) AS value", ([uuid4(), None],)) + + +@pytest.mark.xdist_group("postgres") +@pytest.mark.adbc +def test_postgresql_uuid_array_batch_binding(postgresql_session: AdbcDriver) -> None: + """Execute-many detects a UUID array column across rows.""" + table_name = "adbc_uuid_array_batch" + first_row = [uuid4(), uuid4()] + second_row = [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, identifiers UUID[])" + ) + postgresql_session.execute_many( + f"INSERT INTO {table_name} (position, identifiers) VALUES (?, ?)", [(1, first_row), (2, second_row)] + ) + + rows = postgresql_session.execute( + f"SELECT position, array_to_string(identifiers, ',') AS identifiers FROM {table_name} ORDER BY position" + ).get_data() + + assert [row["identifiers"] for row in rows] == [ + ",".join(str(value) for value in first_row), + ",".join(str(value) for value in second_row), + ] + finally: + postgresql_session.execute_script(f"DROP TABLE IF EXISTS {table_name}") + + +@pytest.mark.xdist_group("postgres") +@pytest.mark.adbc +@pytest.mark.parametrize("array_first", [False, True], ids=["ordinary-first", "array-first"]) +def test_postgresql_uuid_array_does_not_leak_through_same_sql_cache( + postgresql_session: AdbcDriver, array_first: bool +) -> None: + """Array-valued and ordinary-valued executions of one statement never reuse each other's SQL.""" + statement = "SELECT pg_typeof($1)::text AS bound_type" + uuid_array = [uuid4(), uuid4()] + values = (uuid_array, ["plain", "text"]) if array_first else (["plain", "text"], uuid_array) + + bound_types = [postgresql_session.select_value(statement, (value,)) for value in values] + array_type, ordinary_type = bound_types if array_first else reversed(bound_types) + + assert array_type == "uuid[]" + assert ordinary_type != array_type + + +@pytest.mark.xdist_group("postgres") +@pytest.mark.adbc +def test_postgresql_lone_uuid_parameter_survives_statement_cache_hits(postgresql_session: AdbcDriver) -> None: + """A UUID is the only parameter, so no sibling value forces the statement off the cache fast path.""" + table_name = "adbc_uuid_lone_parameter" + values = [uuid4(), uuid4(), uuid4()] + + try: + postgresql_session.execute_script(f"DROP TABLE IF EXISTS {table_name}") + postgresql_session.execute_script(f"CREATE TABLE {table_name} (value UUID PRIMARY KEY)") + for value in values: + postgresql_session.execute(f"INSERT INTO {table_name} (value) VALUES (?)", (value,)) + + rows = postgresql_session.execute( + f"SELECT value::text AS value FROM {table_name} ORDER BY value::text" + ).get_data() + assert sorted(row["value"] for row in rows) == sorted(str(value) for value in values) + finally: + postgresql_session.execute_script(f"DROP TABLE IF EXISTS {table_name}") diff --git a/tests/unit/adapters/test_adbc/test_uuid_binding.py b/tests/unit/adapters/test_adbc/test_uuid_binding.py index 91015b7f6..3d3e18d9c 100644 --- a/tests/unit/adapters/test_adbc/test_uuid_binding.py +++ b/tests/unit/adapters/test_adbc/test_uuid_binding.py @@ -178,15 +178,122 @@ def test_non_postgres_dialect_leaves_uuid_unchanged() -> None: assert compiled_parameters == [UUID_VALUE] -@pytest.mark.parametrize("nested", [[UUID_VALUE], {"identifier": UUID_VALUE}]) -def test_nested_uuid_values_are_not_rewritten(nested: object) -> None: - compiled_sql, compiled_parameters = _compile("SELECT ?", (nested,)) +def test_mapping_uuid_values_are_not_rewritten() -> None: + compiled_sql, compiled_parameters = _compile("SELECT ?", ({"identifier": UUID_VALUE},)) assert compiled_sql == "SELECT $1" - if isinstance(nested, dict): - assert compiled_parameters == ['{"identifier":"550e8400-e29b-41d4-a716-446655440000"}'] - else: - assert list(cast("Any", compiled_parameters)) == [nested] + assert compiled_parameters == ['{"identifier":"550e8400-e29b-41d4-a716-446655440000"}'] + + +@pytest.mark.parametrize( + "sequence", + [pytest.param([UUID_VALUE, OTHER_UUID_VALUE], id="list"), pytest.param((UUID_VALUE, OTHER_UUID_VALUE), id="tuple")], +) +def test_uuid_sequence_binds_as_uuid_array(sequence: object) -> None: + """A homogeneous UUID sequence binds as one uuid[] parameter.""" + compiled_sql, compiled_parameters = _compile("SELECT ?", (sequence,)) + + assert compiled_sql == "SELECT CAST($1 AS UUID[])" + assert list(cast("Any", compiled_parameters)) == [[str(UUID_VALUE), str(OTHER_UUID_VALUE)]] + + +def test_uuid_array_with_null_element_raises() -> None: + """ADBC encodes null array elements as empty strings, which PostgreSQL rejects for UUID[].""" + with pytest.raises(SQLSpecError, match="ordinal 1 has a null value at element 2"): + _compile("SELECT ?", ([UUID_VALUE, None, OTHER_UUID_VALUE],)) + + +def test_existing_uuid_array_cast_is_reused_without_double_wrapping() -> None: + """A caller-written UUID[] cast satisfies the requirement; values still convert.""" + compiled_sql, compiled_parameters = _compile("SELECT CAST(? AS UUID[])", ([UUID_VALUE],)) + + assert compiled_sql == "SELECT CAST($1 AS UUID[])" + assert list(cast("Any", compiled_parameters)) == [[str(UUID_VALUE)]] + + +def test_any_clause_with_uuid_array_cast_is_reused() -> None: + compiled_sql, compiled_parameters = _compile( + "SELECT id FROM example WHERE id = ANY(CAST(? AS UUID[]))", ([UUID_VALUE, OTHER_UUID_VALUE],) + ) + + assert compiled_sql.count("AS UUID[])") == 1 + assert list(cast("Any", compiled_parameters)) == [[str(UUID_VALUE), str(OTHER_UUID_VALUE)]] + + +def test_bare_any_clause_gains_a_uuid_array_cast() -> None: + """PostgreSQL has no uuid = text operator, so a cast-free ANY must be rewritten.""" + compiled_sql, _ = _compile("SELECT id FROM example WHERE id = ANY(?)", ([UUID_VALUE],)) + + assert compiled_sql == "SELECT id FROM example WHERE id = ANY(CAST($1 AS UUID[]))" + + +def test_different_explicit_array_cast_stays_authoritative() -> None: + compiled_sql, compiled_parameters = _compile("SELECT CAST(? AS TEXT[])", ([UUID_VALUE],)) + + assert compiled_sql == "SELECT CAST($1 AS TEXT[])" + assert list(cast("Any", compiled_parameters)) == [[UUID_VALUE]] + + +@pytest.mark.parametrize( + ("sequence", "expected"), + [ + pytest.param([], [], id="empty"), + pytest.param([None, None], [None, None], id="all-none"), + pytest.param((), [], id="empty-tuple-normalized-to-list"), + ], +) +def test_sequences_without_a_uuid_element_are_left_unchanged(sequence: object, expected: object) -> None: + """Detection cannot infer intent from an empty or all-null sequence.""" + compiled_sql, compiled_parameters = _compile("SELECT ?", (sequence,)) + + assert compiled_sql == "SELECT $1" + assert list(cast("Any", compiled_parameters)) == [expected] + + +def test_sequence_with_non_uuid_first_element_is_left_unchanged() -> None: + """The first non-null element decides; a non-UUID head leaves the parameter alone.""" + compiled_sql, compiled_parameters = _compile("SELECT ?", ([42, UUID_VALUE],)) + + assert compiled_sql == "SELECT $1" + assert list(cast("Any", compiled_parameters)) == [[42, UUID_VALUE]] + + +@pytest.mark.parametrize( + ("sequence", "element_index"), + [ + pytest.param([UUID_VALUE, 42], 2, id="incompatible-type"), + pytest.param([UUID_VALUE, "not-a-uuid"], 2, id="unparseable-string"), + pytest.param([UUID_VALUE, OTHER_UUID_VALUE, object()], 3, id="later-element"), + ], +) +def test_uuid_array_with_incompatible_element_raises(sequence: object, element_index: int) -> None: + with pytest.raises(SQLSpecError, match=f"ordinal 1 has incompatible .* element {element_index}"): + _compile("SELECT ?", (sequence,)) + + +def test_scalar_and_array_uuid_ordinals_coexist_in_one_statement() -> None: + compiled_sql, compiled_parameters = _compile("SELECT ?, ?, ?", (UUID_VALUE, [OTHER_UUID_VALUE], "ordinary")) + + assert compiled_sql == "SELECT CAST($1 AS UUID), CAST($2 AS UUID[]), $3" + assert list(cast("Any", compiled_parameters)) == [str(UUID_VALUE), [str(OTHER_UUID_VALUE)], "ordinary"] + + +def test_batch_uuid_array_ordinals_are_detected() -> None: + compiled_sql, compiled_parameters = _compile( + "INSERT INTO values_table (identifiers, label) VALUES (?, ?)", + [([UUID_VALUE], "first"), ([OTHER_UUID_VALUE, UUID_VALUE], "second")], + is_many=True, + ) + + assert compiled_sql == "INSERT INTO values_table (identifiers, label) VALUES (CAST($1 AS UUID[]), $2)" + assert compiled_parameters == [([str(UUID_VALUE)], "first"), ([str(OTHER_UUID_VALUE), str(UUID_VALUE)], "second")] + + +def test_non_postgres_dialect_leaves_uuid_array_unchanged() -> None: + compiled_sql, compiled_parameters = _compile("SELECT ?", ([UUID_VALUE],), dialect="sqlite") + + assert compiled_sql == "SELECT ?" + assert list(cast("Any", compiled_parameters)) == [[UUID_VALUE]] @pytest.mark.skipif(not UUID_UTILS_INSTALLED, reason="uuid_utils not installed") @@ -297,29 +404,44 @@ def test_structural_rewrite_cache_is_bounded_and_keyed_by_dialect_and_ordinals() rewrite = cast("Any", adbc_core)._rewrite_postgres_uuid_placeholders rewrite.cache_clear() - first = rewrite("SELECT $1, $2", (1,), "postgres") - second = rewrite("SELECT $1, $2", (1,), "postgres") - third = rewrite("SELECT $1, $2", (2,), "postgres") - fourth = rewrite("SELECT $1, $2", (1,), "pgvector") + first = rewrite("SELECT $1, $2", (1,), (), "postgres") + second = rewrite("SELECT $1, $2", (1,), (), "postgres") + third = rewrite("SELECT $1, $2", (2,), (), "postgres") + fourth = rewrite("SELECT $1, $2", (1,), (), "pgvector") - assert first == second == ("SELECT CAST($1 AS UUID), $2", (1,)) - assert third == ("SELECT $1, CAST($2 AS UUID)", (2,)) + assert first == second == ("SELECT CAST($1 AS UUID), $2", (1,), ()) + assert third == ("SELECT $1, CAST($2 AS UUID)", (2,), ()) assert fourth == first assert rewrite.cache_info().hits == 1 assert rewrite.cache_info().misses == 3 assert rewrite.cache_info().maxsize == 256 +def test_structural_rewrite_cache_distinguishes_scalar_from_array_ordinals() -> None: + """The same SQL and ordinal produce different rewrites depending on the ordinal's kind.""" + rewrite = cast("Any", adbc_core)._rewrite_postgres_uuid_placeholders + rewrite.cache_clear() + + scalar_only = rewrite("SELECT $1", (1,), (), "postgres") + array_only = rewrite("SELECT $1", (), (1,), "postgres") + + assert scalar_only == ("SELECT CAST($1 AS UUID)", (1,), ()) + assert array_only == ("SELECT CAST($1 AS UUID[])", (), (1,)) + assert rewrite.cache_info().misses == 2 + assert rewrite.cache_info().hits == 0 + + def test_structural_rewrite_uses_ast_and_ignores_literal_and_comment_placeholders() -> None: rewrite = cast("Any", adbc_core)._rewrite_postgres_uuid_placeholders - rewritten, effective = rewrite("SELECT '$1' AS literal, $1 -- $1\n", (1,), "postgres") + rewritten, effective, effective_arrays = rewrite("SELECT '$1' AS literal, $1 -- $1\n", (1,), (), "postgres") assert "'$1' AS literal" in rewritten assert "CAST($1" in rewritten assert rewritten.count("AS UUID)") == 1 assert "/* $1 */" in rewritten assert effective == (1,) + assert effective_arrays == () @pytest.mark.parametrize( @@ -346,7 +468,7 @@ def test_structural_rewrite_reports_sqlglot_parse_errors() -> None: rewrite = cast("Any", adbc_core)._rewrite_postgres_uuid_placeholders with pytest.raises(SQLSpecError, match="Failed to parse PostgreSQL ADBC SQL for UUID parameter binding"): - rewrite("SELECT (", (1,), "postgres") + rewrite("SELECT (", (1,), (), "postgres") def test_value_dependent_binding_does_not_leak_between_same_sql_calls() -> None: