diff --git a/CHANGES.rst b/CHANGES.rst index 9fe3c3b2..b5be1aed 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -5,6 +5,14 @@ Changes for crate Unreleased ================ +- Breaking change: ``DefaultTypeConverter`` now decodes ``DataType.UUID`` + columns to Python ``uuid.UUID`` objects instead of returning the raw string. + +- Added CrateDB column type identifiers to ``DataType``: ``INTERVAL`` (17), + ``ROW`` (18), ``FLOAT_VECTOR`` (28), ``UUID`` (29), and ``REGTYPE`` (30). + Fixed ``Converter.get()`` raising ``ValueError`` for column type identifiers + it does not know. Unknown identifiers now fall back to the default converter. + - Breaking change: ``connect()`` now raises ``ConnectionError`` immediately if no configured server node responds. diff --git a/docs/by-example/cursor.rst b/docs/by-example/cursor.rst index 6f4a3dcc..dcca3291 100644 --- a/docs/by-example/cursor.rst +++ b/docs/by-example/cursor.rst @@ -343,7 +343,7 @@ Python data type conversion The cursor object can optionally convert database types to native Python data types. Currently, this is implemented for the CrateDB data types ``IP``, -``TIMESTAMP``, ``TIMETZ``, and ``BIT`` on behalf of the +``TIMESTAMP``, ``TIMETZ``, ``BIT``, and ``UUID`` on behalf of the ``DefaultTypeConverter``. >>> cursor = connection.cursor(converter=DefaultTypeConverter()) @@ -403,6 +403,29 @@ Executing the query and fetching the decoded result: ['0110'] +CrateDB's ``UUID`` type is returned over HTTP as a string. It is decoded to a +Python ``uuid.UUID`` object. + + >>> cursor = connection.cursor(converter=DefaultTypeConverter()) + +.. hide: set up the mocked response:: + + >>> connection.client.set_next_response({ + ... "col_types": [29], + ... "rows":[ [ "a5b3c1e0-1b7f-4f3e-9a2d-6c4e8f0a1b2c" ] ], + ... "cols":[ "id" ], + ... "rowcount":1, + ... "duration":1 + ... }) + +Executing the query and fetching the decoded result: + + >>> cursor.execute("select 'a5b3c1e0-1b7f-4f3e-9a2d-6c4e8f0a1b2c'::uuid") + + >>> cursor.fetchone() + [UUID('a5b3c1e0-1b7f-4f3e-9a2d-6c4e8f0a1b2c')] + + Custom data type conversion =========================== diff --git a/src/crate/client/converter.py b/src/crate/client/converter.py index 286dd80d..edae8de1 100644 --- a/src/crate/client/converter.py +++ b/src/crate/client/converter.py @@ -27,6 +27,7 @@ import datetime as dt import ipaddress import re +import uuid from copy import deepcopy from enum import Enum from typing import Any, Callable, Dict, List, Optional, Union @@ -89,6 +90,17 @@ def _to_bit_string(value: Optional[str]) -> Optional[str]: return match.group(1) +def _to_uuid(value: Optional[str]) -> Optional[uuid.UUID]: + """ + Convert a CrateDB UUID wire value to a Python ``uuid.UUID``. + + https://docs.python.org/3/library/uuid.html + """ + if value is None: + return None + return uuid.UUID(value) + + def _to_default(value: Optional[Any]) -> Optional[Any]: return value @@ -113,6 +125,8 @@ class DataType(Enum): GEOSHAPE = 14 TIMESTAMP_WITHOUT_TZ = 15 UNCHECKED_OBJECT = 16 + INTERVAL = 17 + ROW = 18 REGPROC = 19 TIME = 20 OIDVECTOR = 21 @@ -122,12 +136,25 @@ class DataType(Enum): BIT = 25 JSON = 26 CHARACTER = 27 + FLOAT_VECTOR = 28 + UUID = 29 + REGTYPE = 30 ARRAY = 100 ConverterMapping = Dict[DataType, ConverterFunction] +def _resolve(type_: Any) -> Optional[DataType]: + """ + Map a wire type identifier to a `DataType`. + """ + try: + return DataType(type_) + except ValueError: + return None + + # Map data type identifier to converter function. _DEFAULT_CONVERTERS: ConverterMapping = { DataType.IP: _to_ipaddress, @@ -135,6 +162,7 @@ class DataType(Enum): DataType.TIMESTAMP_WITHOUT_TZ: _to_datetime, DataType.TIME: _to_time, DataType.BIT: _to_bit_string, + DataType.UUID: _to_uuid, } @@ -149,9 +177,12 @@ def __init__( def get(self, type_: ColTypesDefinition) -> ConverterFunction: if isinstance(type_, int): - return self._mappings.get(DataType(type_), self._default) + data_type = _resolve(type_) + if data_type is None: + return self._default + return self._mappings.get(data_type, self._default) type_, inner_type = type_ - if DataType(type_) is not DataType.ARRAY: + if _resolve(type_) is not DataType.ARRAY: raise ValueError( f"Data type {type_} is not implemented as collection type" ) diff --git a/tests/client/test_cursor.py b/tests/client/test_cursor.py index 9f230890..239f58a0 100644 --- a/tests/client/test_cursor.py +++ b/tests/client/test_cursor.py @@ -20,6 +20,7 @@ # software solely pursuant to the terms of the relevant commercial agreement. import datetime +import uuid import zoneinfo from ipaddress import IPv4Address from unittest import mock @@ -32,6 +33,7 @@ DataType, DefaultTypeConverter, _to_bit_string, + _to_uuid, ) from crate.client.exceptions import ProgrammingError @@ -459,8 +461,8 @@ def test_execute_time_converter(mocked_connection): "col_types": [20], "cols": ["t"], "rows": [ - [[45045000000, 0]], # 12:30:45 UTC - [[45045123456, 7200]], # 12:30:45.123456 +02:00 + [[45045000000, 0]], # 12:30:45 UTC + [[45045123456, 7200]], # 12:30:45.123456 +02:00 [None], ], "rowcount": 3, @@ -474,10 +476,16 @@ def test_execute_time_converter(mocked_connection): result = cursor.fetchall() assert result == [ - [datetime.time(12, 30, 45, 0, - tzinfo=datetime.timezone.utc)], - [datetime.time(12, 30, 45, 123456, - tzinfo=datetime.timezone(datetime.timedelta(hours=2)))], + [datetime.time(12, 30, 45, 0, tzinfo=datetime.timezone.utc)], + [ + datetime.time( + 12, + 30, + 45, + 123456, + tzinfo=datetime.timezone(datetime.timedelta(hours=2)), + ) + ], [None], ] @@ -606,7 +614,176 @@ def test_bit_without_converter(mocked_connection): assert cursor.fetchone() == ["B'0110'"] -def test_execute_with_converter_and_invalid_data_type(mocked_connection): +@pytest.mark.parametrize( + ("type_id", "wire_value"), + [ + (17, "1 day 00:00:00"), + (18, ["over", "U", "unreserved"]), + (28, [0.1, 0.2]), + (30, "int4"), + ], + ids=["interval", "row", "float_vector", "regtype"], +) +def test_pass_through_data_types(mocked_connection, type_id, wire_value): + """ + Verify that types without a dedicated converter are passed through + unchanged instead of raising. + """ + converter = DefaultTypeConverter() + cursor = mocked_connection.cursor(converter=converter) + response = { + "col_types": [type_id], + "cols": ["foo"], + "rows": [[wire_value]], + "rowcount": 1, + "duration": 123, + } + with mock.patch.object( + mocked_connection.client, "sql", return_value=response + ): + cursor.execute("") + assert cursor.fetchone() == [wire_value] + + +def test_pass_through_data_types_registered_in_enum(): + """Verify the enum stays in sync with the documented type identifiers.""" + assert DataType(17) is DataType.INTERVAL + assert DataType(18) is DataType.ROW + assert DataType(28) is DataType.FLOAT_VECTOR + assert DataType(29) is DataType.UUID + assert DataType(30) is DataType.REGTYPE + + +def test_float_vector_with_time_zone(mocked_connection): + """ + Verify a `FLOAT_VECTOR` column on a timezone-aware cursor. + """ + cursor = mocked_connection.cursor(time_zone="+0000") + response = { + "col_types": [28], + "cols": ["embedding"], + "rows": [[[0.1, 0.2]]], + "rowcount": 1, + "duration": 123, + } + with mock.patch.object( + mocked_connection.client, "sql", return_value=response + ): + cursor.execute("") + assert cursor.fetchone() == [[0.1, 0.2]] + + +UUID_STR = "a5b3c1e0-1b7f-4f3e-9a2d-6c4e8f0a1b2c" + + +def test_execute_uuid_converter(mocked_connection): + """ + Verify that CrateDB's UUID wire format is decoded to `uuid.UUID`. + """ + converter = DefaultTypeConverter() + cursor = mocked_connection.cursor(converter=converter) + response = { + "col_types": [29], + "cols": ["id"], + "rows": [[UUID_STR], [None]], + "rowcount": 2, + "duration": 1, + } + with mock.patch.object( + mocked_connection.client, "sql", return_value=response + ): + cursor.execute("") + result = cursor.fetchall() + + assert result == [[uuid.UUID(UUID_STR)], [None]] + + +def test_uuid_converter_registered_by_default(): + """Verify DataType.UUID resolves to the UUID converter""" + converter = DefaultTypeConverter() + assert converter.get(DataType.UUID.value) is _to_uuid + + +def test_uuid_converter_can_be_overridden(mocked_connection): + """Verify a user-supplied UUID converter wins over the default.""" + converter = DefaultTypeConverter({DataType.UUID: lambda value: "custom"}) + cursor = mocked_connection.cursor(converter=converter) + response = { + "col_types": [29], + "cols": ["id"], + "rows": [[UUID_STR]], + "rowcount": 1, + "duration": 1, + } + with mock.patch.object( + mocked_connection.client, "sql", return_value=response + ): + cursor.execute("") + assert cursor.fetchone() == ["custom"] + + +def test_uuid_array_with_converter(mocked_connection): + """Verify UUID decoding inside an ARRAY column.""" + converter = DefaultTypeConverter() + cursor = mocked_connection.cursor(converter=converter) + response = { + "col_types": [[100, 29]], + "cols": ["ids"], + "rows": [[[UUID_STR, None]]], + "rowcount": 1, + "duration": 1, + } + with mock.patch.object( + mocked_connection.client, "sql", return_value=response + ): + cursor.execute("") + assert cursor.fetchone() == [[uuid.UUID(UUID_STR), None]] + + +def test_uuid_without_converter(mocked_connection): + """Verify that without an explicit converter, values stay untouched.""" + cursor = mocked_connection.cursor() + response = { + "col_types": [29], + "cols": ["id"], + "rows": [[UUID_STR]], + "rowcount": 1, + "duration": 1, + } + with mock.patch.object( + mocked_connection.client, "sql", return_value=response + ): + cursor.execute("") + assert cursor.fetchone() == [UUID_STR] + + +def test_uuid_converter_invalid_value(mocked_connection): + """ + Verify an unparseable value raises, matching the `IP` converter's + behaviour rather than passing malformed data through. + """ + converter = DefaultTypeConverter() + cursor = mocked_connection.cursor(converter=converter) + response = { + "col_types": [29], + "cols": ["id"], + "rows": [["not-a-uuid"]], + "rowcount": 1, + "duration": 1, + } + with mock.patch.object( + mocked_connection.client, "sql", return_value=response + ): + cursor.execute("") + with pytest.raises(ValueError, match="badly formed hexadecimal"): + cursor.fetchone() + + +def test_execute_with_converter_and_unknown_data_type(mocked_connection): + """ + Verify that a type identifier unknown to this client degrades to the + default converter. + """ converter = DefaultTypeConverter() # Create a `Cursor` object with converter. @@ -625,9 +802,7 @@ def test_execute_with_converter_and_invalid_data_type(mocked_connection): mocked_connection.client, "sql", return_value=response ): cursor.execute("") - with pytest.raises(ValueError) as e: - cursor.fetchone() - assert e.exception.args == "999 is not a valid DataType" + assert cursor.fetchone() == ["n/a"] def test_execute_array_with_converter(mocked_connection): @@ -675,6 +850,30 @@ def test_execute_array_with_converter_invalid(mocked_connection): ) +def test_execute_array_with_converter_unknown_outer_type(mocked_connection): + """ + Verify an unknown outer type in a collection definition still raises. + """ + converter = DefaultTypeConverter() + cursor = mocked_connection.cursor(converter=converter) + response = { + "col_types": [[999, 5]], + "cols": ["address"], + "rows": [[["10.10.10.1"]]], + "rowcount": 1, + "duration": 123, + } + with mock.patch.object( + mocked_connection.client, "sql", return_value=response + ): + cursor.execute("") + with pytest.raises( + ValueError, + match="Data type 999 is not implemented as collection type", + ): + cursor.fetchone() + + def test_execute_nested_array_with_converter(mocked_connection): converter = DefaultTypeConverter() cursor = mocked_connection.cursor(converter=converter)