Skip to content
Open
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
6 changes: 6 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ Unreleased
- Breaking change: ``connect()`` now raises ``ConnectionError`` immediately if
no configured server node responds.

- 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
================

Expand Down
22 changes: 21 additions & 1 deletion docs/by-example/cursor.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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())

Expand Down Expand Up @@ -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
===========================

Expand Down
18 changes: 18 additions & 0 deletions src/crate/client/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,16 @@

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

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],
Expand Down Expand Up @@ -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.fullmatch(value)
if match is None:
return value
return match.group(1)


def _to_default(value: Optional[Any]) -> Optional[Any]:
return value

Expand Down Expand Up @@ -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,
}


Expand Down
130 changes: 129 additions & 1 deletion tests/client/test_cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -478,6 +482,130 @@ 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'"),
("B'0110'\n", "B'0110'\n"),
],
)
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()

Expand Down
Loading