Skip to content

Commit 6508d9a

Browse files
authored
Fix Register converter for DataType.BIT (#828)
* Add converter for DataType.BIT to decoder * Update change log and update regex matching in converter * Update example to execute a valid SQL statement for BIT type * Update docs with hiding mocked response
1 parent 68fedc6 commit 6508d9a

4 files changed

Lines changed: 178 additions & 2 deletions

File tree

‎CHANGES.rst‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@ Unreleased
88
- Breaking change: ``connect()`` now raises ``ConnectionError`` immediately if
99
no configured server node responds.
1010

11+
- Breaking change: ``DefaultTypeConverter`` now decodes ``DataType.BIT``
12+
columns, converting CrateDB's ``B'0110'`` wire format to a plain string of
13+
``0``/``1`` digits. Code that stripped the wrapper itself needs to be
14+
adjusted, or can restore the previous behaviour by mapping
15+
``DataType.BIT`` to a converter of its own.
16+
1117
2026/06/17 2.2.1
1218
================
1319

‎docs/by-example/cursor.rst‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -343,7 +343,8 @@ Python data type conversion
343343

344344
The cursor object can optionally convert database types to native Python data
345345
types. Currently, this is implemented for the CrateDB data types ``IP``,
346-
``TIMESTAMP``, and ``TIMETZ`` on behalf of the ``DefaultTypeConverter``.
346+
``TIMESTAMP``, ``TIMETZ``, and ``BIT`` on behalf of the
347+
``DefaultTypeConverter``.
347348

348349
>>> cursor = connection.cursor(converter=DefaultTypeConverter())
349350

@@ -379,6 +380,29 @@ and decoded to a ``datetime.time`` object with the appropriate timezone:
379380
[datetime.time(12, 30, 45, tzinfo=datetime.timezone.utc)]
380381

381382

383+
CrateDB's ``BIT`` type is returned over HTTP in its SQL literal form,
384+
``B'0110'``. It is decoded to a plain string of ``0``/``1`` digits.
385+
386+
>>> cursor = connection.cursor(converter=DefaultTypeConverter())
387+
388+
.. hide: set up the mocked response::
389+
390+
>>> connection.client.set_next_response({
391+
... "col_types": [25],
392+
... "rows":[ [ "B'0110'" ] ],
393+
... "cols":[ "flags" ],
394+
... "rowcount":1,
395+
... "duration":1
396+
... })
397+
398+
Executing the query and fetching the decoded result:
399+
400+
>>> cursor.execute("select b'0110'")
401+
402+
>>> cursor.fetchone()
403+
['0110']
404+
405+
382406
Custom data type conversion
383407
===========================
384408

‎src/crate/client/converter.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,13 +26,16 @@
2626

2727
import datetime as dt
2828
import ipaddress
29+
import re
2930
from copy import deepcopy
3031
from enum import Enum
3132
from typing import Any, Callable, Dict, List, Optional, Union
3233

3334
ConverterFunction = Callable[[Optional[Any]], Optional[Any]]
3435
ColTypesDefinition = Union[int, List[Union[int, "ColTypesDefinition"]]]
3536

37+
_BIT_LITERAL = re.compile(r"B'([01]*)'")
38+
3639

3740
def _to_ipaddress(
3841
value: Optional[str],
@@ -72,6 +75,20 @@ def _to_time(value: Optional[list]) -> Optional[dt.time]:
7275
return t.replace(tzinfo=tz)
7376

7477

78+
def _to_bit_string(value: Optional[str]) -> Optional[str]:
79+
"""
80+
Convert a CrateDB BIT wire value to a plain string of ``0``/``1`` digits.
81+
82+
https://cratedb.com/docs/crate/reference/en/latest/general/ddl/data-types.html#bit-strings
83+
"""
84+
if value is None:
85+
return None
86+
match = _BIT_LITERAL.fullmatch(value)
87+
if match is None:
88+
return value
89+
return match.group(1)
90+
91+
7592
def _to_default(value: Optional[Any]) -> Optional[Any]:
7693
return value
7794

@@ -117,6 +134,7 @@ class DataType(Enum):
117134
DataType.TIMESTAMP_WITH_TZ: _to_datetime,
118135
DataType.TIMESTAMP_WITHOUT_TZ: _to_datetime,
119136
DataType.TIME: _to_time,
137+
DataType.BIT: _to_bit_string,
120138
}
121139

122140

‎tests/client/test_cursor.py‎

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,11 @@
2828
import pytz
2929

3030
from crate.client import connect
31-
from crate.client.converter import DataType, DefaultTypeConverter
31+
from crate.client.converter import (
32+
DataType,
33+
DefaultTypeConverter,
34+
_to_bit_string,
35+
)
3236
from crate.client.exceptions import ProgrammingError
3337

3438

@@ -478,6 +482,130 @@ def test_execute_time_converter(mocked_connection):
478482
]
479483

480484

485+
def test_execute_bit_converter(mocked_connection):
486+
"""
487+
Verify that CrateDB's BIT wire format B'0110' is decoded to a plain
488+
string of 0/1 digits by DefaultTypeConverter.
489+
"""
490+
converter = DefaultTypeConverter()
491+
cursor = mocked_connection.cursor(converter=converter)
492+
response = {
493+
"col_types": [25, 25, 25],
494+
"cols": ["b1", "b8", "b64"],
495+
"rows": [
496+
["B'0'", "B'00000001'", "B'{}'".format("1" * 64)],
497+
[None, None, None],
498+
],
499+
"rowcount": 2,
500+
"duration": 1,
501+
}
502+
503+
with mock.patch.object(
504+
mocked_connection.client, "sql", return_value=response
505+
):
506+
cursor.execute("")
507+
result = cursor.fetchall()
508+
509+
assert result == [
510+
["0", "00000001", "1" * 64],
511+
[None, None, None],
512+
]
513+
514+
515+
@pytest.mark.parametrize(
516+
("wire_value", "expected"),
517+
[
518+
("B'0'", "0"),
519+
("B'0110'", "0110"),
520+
("B'" + "1" * 64 + "'", "1" * 64),
521+
(None, None),
522+
("", ""),
523+
("0110", "0110"),
524+
("B''", ""),
525+
("B'0110", "B'0110"),
526+
("b'0110'", "b'0110'"),
527+
("B'0notbits1'", "B'0notbits1'"),
528+
("B'0110' OR 1=1", "B'0110' OR 1=1"),
529+
("B'01\n10'", "B'01\n10'"),
530+
("B'0110'\n", "B'0110'\n"),
531+
],
532+
)
533+
def test_bit_converter_values(wire_value, expected):
534+
"""Verify _to_bit_string edge cases directly."""
535+
assert _to_bit_string(wire_value) == expected
536+
537+
538+
def test_bit_converter_registered_by_default():
539+
"""Verify DataType.BIT resolves to the BIT converter"""
540+
converter = DefaultTypeConverter()
541+
assert converter.get(DataType.BIT.value) is _to_bit_string
542+
543+
544+
def test_bit_converter_can_be_overridden(mocked_connection):
545+
"""
546+
Verify a user-supplied mapping still wins over the registered default
547+
"""
548+
converter = DefaultTypeConverter({DataType.BIT: lambda value: "custom"})
549+
cursor = mocked_connection.cursor(converter=converter)
550+
response = {
551+
"col_types": [25],
552+
"cols": ["b"],
553+
"rows": [["B'0110'"]],
554+
"rowcount": 1,
555+
"duration": 1,
556+
}
557+
558+
with mock.patch.object(
559+
mocked_connection.client, "sql", return_value=response
560+
):
561+
cursor.execute("")
562+
assert cursor.fetchone() == ["custom"]
563+
564+
565+
def test_bit_array_with_converter(mocked_connection):
566+
"""Verify ARRAY(BIT) is handled through the generic collection path."""
567+
converter = DefaultTypeConverter()
568+
cursor = mocked_connection.cursor(converter=converter)
569+
response = {
570+
"col_types": [[100, 25]],
571+
"cols": ["flags"],
572+
"rows": [
573+
[["B'0001'", "B'1000'", None]],
574+
[None],
575+
],
576+
"rowcount": 2,
577+
"duration": 1,
578+
}
579+
580+
with mock.patch.object(
581+
mocked_connection.client, "sql", return_value=response
582+
):
583+
cursor.execute("")
584+
result = cursor.fetchall()
585+
586+
assert result == [[["0001", "1000", None]], [None]]
587+
588+
589+
def test_bit_without_converter(mocked_connection):
590+
"""
591+
Verify that without an explicit converter, values stay untouched.
592+
"""
593+
cursor = mocked_connection.cursor()
594+
response = {
595+
"col_types": [25],
596+
"cols": ["b"],
597+
"rows": [["B'0110'"]],
598+
"rowcount": 1,
599+
"duration": 1,
600+
}
601+
602+
with mock.patch.object(
603+
mocked_connection.client, "sql", return_value=response
604+
):
605+
cursor.execute("")
606+
assert cursor.fetchone() == ["B'0110'"]
607+
608+
481609
def test_execute_with_converter_and_invalid_data_type(mocked_connection):
482610
converter = DefaultTypeConverter()
483611

0 commit comments

Comments
 (0)