diff --git a/docs/changelog.rst b/docs/changelog.rst index 1b40cca05..bdea6a0bc 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -9,6 +9,33 @@ important operational fixes. 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 e62b9c9fa..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,52 +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 _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_batch_uuid_ordinals(parameters: Any) -> "tuple[int, ...]": +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) + 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{location} in batch row {row_number}; expected a UUID object, " + "parseable UUID string, or None." + ) + + +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_row(row: Any, ordinals: "tuple[int, ...]", row_number: int) -> Any: +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 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 + if not isinstance(value, (list, tuple)): + raise SQLSpecError(_uuid_binding_error(ordinal, value, row_number)) + 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 b31f2fe0f..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") @@ -200,6 +307,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 (?, ?)", @@ -255,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( @@ -304,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: