From 34420517ce603d83d042e1ada85b9035bf04cf85 Mon Sep 17 00:00:00 2001 From: Bilal Tonga Date: Wed, 2 Sep 2026 11:10:39 +0200 Subject: [PATCH 1/2] Add converter for DataType.BIT to decoder --- CHANGES.rst | 3 + docs/by-example/cursor.rst | 22 +++++- src/crate/client/converter.py | 18 +++++ tests/client/test_cursor.py | 129 +++++++++++++++++++++++++++++++++- 4 files changed, 170 insertions(+), 2 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 175545b5..75ba58a1 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,6 +8,9 @@ Unreleased - Breaking change: ``connect()`` now raises ``ConnectionError`` immediately if no configured server node responds. +- Added a converter for ``DataType.BIT``, decoding CrateDB's ``B'0110'`` wire + format to a plain string of ``0``/``1`` digits. + 2026/06/17 2.2.1 ================ diff --git a/docs/by-example/cursor.rst b/docs/by-example/cursor.rst index 90521db6..f855c75d 100644 --- a/docs/by-example/cursor.rst +++ b/docs/by-example/cursor.rst @@ -343,7 +343,8 @@ 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``, and ``TIMETZ`` on behalf of the ``DefaultTypeConverter``. +``TIMESTAMP``, ``TIMETZ``, and ``BIT`` on behalf of the +``DefaultTypeConverter``. >>> cursor = connection.cursor(converter=DefaultTypeConverter()) @@ -379,6 +380,25 @@ and decoded to a ``datetime.time`` object with the appropriate timezone: [datetime.time(12, 30, 45, tzinfo=datetime.timezone.utc)] +CrateDB's ``BIT`` type is returned over HTTP in its SQL literal form, +``B'0110'``. It is decoded to a plain string of ``0``/``1`` digits. + + >>> cursor = connection.cursor(converter=DefaultTypeConverter()) + + >>> connection.client.set_next_response({ + ... "col_types": [25], + ... "rows":[ [ "B'0110'" ] ], + ... "cols":[ "flags" ], + ... "rowcount":1, + ... "duration":1 + ... }) + + >>> cursor.execute('') + + >>> cursor.fetchone() + ['0110'] + + Custom data type conversion =========================== diff --git a/src/crate/client/converter.py b/src/crate/client/converter.py index 71c06aee..2bf759cd 100644 --- a/src/crate/client/converter.py +++ b/src/crate/client/converter.py @@ -26,6 +26,7 @@ import datetime as dt import ipaddress +import re from copy import deepcopy from enum import Enum from typing import Any, Callable, Dict, List, Optional, Union @@ -33,6 +34,8 @@ ConverterFunction = Callable[[Optional[Any]], Optional[Any]] ColTypesDefinition = Union[int, List[Union[int, "ColTypesDefinition"]]] +_BIT_LITERAL = re.compile(r"^B'([01]*)'$") + def _to_ipaddress( value: Optional[str], @@ -72,6 +75,20 @@ def _to_time(value: Optional[list]) -> Optional[dt.time]: return t.replace(tzinfo=tz) +def _to_bit_string(value: Optional[str]) -> Optional[str]: + """ + Convert a CrateDB BIT wire value to a plain string of ``0``/``1`` digits. + + https://cratedb.com/docs/crate/reference/en/latest/general/ddl/data-types.html#bit-strings + """ + if value is None: + return None + match = _BIT_LITERAL.match(value) + if match is None: + return value + return match.group(1) + + def _to_default(value: Optional[Any]) -> Optional[Any]: return value @@ -117,6 +134,7 @@ class DataType(Enum): DataType.TIMESTAMP_WITH_TZ: _to_datetime, DataType.TIMESTAMP_WITHOUT_TZ: _to_datetime, DataType.TIME: _to_time, + DataType.BIT: _to_bit_string, } diff --git a/tests/client/test_cursor.py b/tests/client/test_cursor.py index 6fb49c20..9815d694 100644 --- a/tests/client/test_cursor.py +++ b/tests/client/test_cursor.py @@ -28,7 +28,11 @@ import pytz from crate.client import connect -from crate.client.converter import DataType, DefaultTypeConverter +from crate.client.converter import ( + DataType, + DefaultTypeConverter, + _to_bit_string, +) from crate.client.exceptions import ProgrammingError @@ -478,6 +482,129 @@ def test_execute_time_converter(mocked_connection): ] +def test_execute_bit_converter(mocked_connection): + """ + Verify that CrateDB's BIT wire format B'0110' is decoded to a plain + string of 0/1 digits by DefaultTypeConverter. + """ + converter = DefaultTypeConverter() + cursor = mocked_connection.cursor(converter=converter) + response = { + "col_types": [25, 25, 25], + "cols": ["b1", "b8", "b64"], + "rows": [ + ["B'0'", "B'00000001'", "B'{}'".format("1" * 64)], + [None, None, None], + ], + "rowcount": 2, + "duration": 1, + } + + with mock.patch.object( + mocked_connection.client, "sql", return_value=response + ): + cursor.execute("") + result = cursor.fetchall() + + assert result == [ + ["0", "00000001", "1" * 64], + [None, None, None], + ] + + +@pytest.mark.parametrize( + ("wire_value", "expected"), + [ + ("B'0'", "0"), + ("B'0110'", "0110"), + ("B'" + "1" * 64 + "'", "1" * 64), + (None, None), + ("", ""), + ("0110", "0110"), + ("B''", ""), + ("B'0110", "B'0110"), + ("b'0110'", "b'0110'"), + ("B'0notbits1'", "B'0notbits1'"), + ("B'0110' OR 1=1", "B'0110' OR 1=1"), + ("B'01\n10'", "B'01\n10'"), + ], +) +def test_bit_converter_values(wire_value, expected): + """Verify _to_bit_string edge cases directly.""" + assert _to_bit_string(wire_value) == expected + + +def test_bit_converter_registered_by_default(): + """Verify DataType.BIT resolves to the BIT converter""" + converter = DefaultTypeConverter() + assert converter.get(DataType.BIT.value) is _to_bit_string + + +def test_bit_converter_can_be_overridden(mocked_connection): + """ + Verify a user-supplied mapping still wins over the registered default + """ + converter = DefaultTypeConverter({DataType.BIT: lambda value: "custom"}) + cursor = mocked_connection.cursor(converter=converter) + response = { + "col_types": [25], + "cols": ["b"], + "rows": [["B'0110'"]], + "rowcount": 1, + "duration": 1, + } + + with mock.patch.object( + mocked_connection.client, "sql", return_value=response + ): + cursor.execute("") + assert cursor.fetchone() == ["custom"] + + +def test_bit_array_with_converter(mocked_connection): + """Verify ARRAY(BIT) is handled through the generic collection path.""" + converter = DefaultTypeConverter() + cursor = mocked_connection.cursor(converter=converter) + response = { + "col_types": [[100, 25]], + "cols": ["flags"], + "rows": [ + [["B'0001'", "B'1000'", None]], + [None], + ], + "rowcount": 2, + "duration": 1, + } + + with mock.patch.object( + mocked_connection.client, "sql", return_value=response + ): + cursor.execute("") + result = cursor.fetchall() + + assert result == [[["0001", "1000", None]], [None]] + + +def test_bit_without_converter(mocked_connection): + """ + Verify that without an explicit converter, values stay untouched. + """ + cursor = mocked_connection.cursor() + response = { + "col_types": [25], + "cols": ["b"], + "rows": [["B'0110'"]], + "rowcount": 1, + "duration": 1, + } + + with mock.patch.object( + mocked_connection.client, "sql", return_value=response + ): + cursor.execute("") + assert cursor.fetchone() == ["B'0110'"] + + def test_execute_with_converter_and_invalid_data_type(mocked_connection): converter = DefaultTypeConverter() From 80c62fa913f9b19a3fa4cd5524ccc9f364629554 Mon Sep 17 00:00:00 2001 From: Bilal Tonga Date: Wed, 2 Sep 2026 13:34:33 +0200 Subject: [PATCH 2/2] Update change log and update regex matching in converter --- CHANGES.rst | 7 +++++-- src/crate/client/converter.py | 4 ++-- tests/client/test_cursor.py | 1 + 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGES.rst b/CHANGES.rst index 75ba58a1..9fe3c3b2 100644 --- a/CHANGES.rst +++ b/CHANGES.rst @@ -8,8 +8,11 @@ Unreleased - Breaking change: ``connect()`` now raises ``ConnectionError`` immediately if no configured server node responds. -- Added a converter for ``DataType.BIT``, decoding CrateDB's ``B'0110'`` wire - format to a plain string of ``0``/``1`` digits. +- Breaking change: ``DefaultTypeConverter`` now decodes ``DataType.BIT`` + columns, converting CrateDB's ``B'0110'`` wire format to a plain string of + ``0``/``1`` digits. Code that stripped the wrapper itself needs to be + adjusted, or can restore the previous behaviour by mapping + ``DataType.BIT`` to a converter of its own. 2026/06/17 2.2.1 ================ diff --git a/src/crate/client/converter.py b/src/crate/client/converter.py index 2bf759cd..286dd80d 100644 --- a/src/crate/client/converter.py +++ b/src/crate/client/converter.py @@ -34,7 +34,7 @@ ConverterFunction = Callable[[Optional[Any]], Optional[Any]] ColTypesDefinition = Union[int, List[Union[int, "ColTypesDefinition"]]] -_BIT_LITERAL = re.compile(r"^B'([01]*)'$") +_BIT_LITERAL = re.compile(r"B'([01]*)'") def _to_ipaddress( @@ -83,7 +83,7 @@ def _to_bit_string(value: Optional[str]) -> Optional[str]: """ if value is None: return None - match = _BIT_LITERAL.match(value) + match = _BIT_LITERAL.fullmatch(value) if match is None: return value return match.group(1) diff --git a/tests/client/test_cursor.py b/tests/client/test_cursor.py index 9815d694..9f230890 100644 --- a/tests/client/test_cursor.py +++ b/tests/client/test_cursor.py @@ -527,6 +527,7 @@ def test_execute_bit_converter(mocked_connection): ("B'0notbits1'", "B'0notbits1'"), ("B'0110' OR 1=1", "B'0110' OR 1=1"), ("B'01\n10'", "B'01\n10'"), + ("B'0110'\n", "B'0110'\n"), ], ) def test_bit_converter_values(wire_value, expected):