Skip to content

Commit 5480e73

Browse files
Add RowsResponse tests for all value types and fix ISO8601 encode bug
The new tests exercise BOOLEAN, UNIXTIME, ISO8601, BLOB, and a mixed all-types row in full RowsResponse encode/decode round-trips. This uncovered a real bug: encode_value() with explicit ValueType.ISO8601 and a datetime object raised EncodeError because the datetime-to-string conversion only existed in the auto-inference path. The fix extracts _format_datetime_iso8601() and calls it from both the auto-inference and explicit-type paths. Fixes #020 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent e0feb25 commit 5480e73

2 files changed

Lines changed: 144 additions & 15 deletions

File tree

src/dqlitewire/types.py

Lines changed: 26 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,24 @@ def decode_blob(data: bytes) -> tuple[bytes, int]:
147147
return data[8 : 8 + length], total_size
148148

149149

150+
def _format_datetime_iso8601(value: datetime.datetime) -> str:
151+
"""Format a datetime to ISO 8601 string matching Go's time format."""
152+
formatted = value.strftime("%Y-%m-%d %H:%M:%S")
153+
if value.microsecond:
154+
formatted += f".{value.microsecond:06d}".rstrip("0")
155+
utcoffset = value.utcoffset()
156+
if utcoffset is not None:
157+
total_seconds = int(utcoffset.total_seconds())
158+
sign = "+" if total_seconds >= 0 else "-"
159+
hours, remainder = divmod(abs(total_seconds), 3600)
160+
minutes = remainder // 60
161+
formatted += f"{sign}{hours:02d}:{minutes:02d}"
162+
else:
163+
# Naive datetime: assume UTC to match Go's always-offset format
164+
formatted += "+00:00"
165+
return formatted
166+
167+
150168
def encode_value(value: Any, value_type: ValueType | None = None) -> tuple[bytes, ValueType]:
151169
"""Encode a Python value to wire format.
152170
@@ -165,21 +183,7 @@ def encode_value(value: Any, value_type: ValueType | None = None) -> tuple[bytes
165183
value_type = ValueType.FLOAT
166184
elif isinstance(value, datetime.datetime):
167185
value_type = ValueType.ISO8601
168-
# Format to match Go's time format: "2006-01-02 15:04:05.999999999-07:00"
169-
formatted = value.strftime("%Y-%m-%d %H:%M:%S")
170-
if value.microsecond:
171-
formatted += f".{value.microsecond:06d}".rstrip("0")
172-
utcoffset = value.utcoffset()
173-
if utcoffset is not None:
174-
total_seconds = int(utcoffset.total_seconds())
175-
sign = "+" if total_seconds >= 0 else "-"
176-
hours, remainder = divmod(abs(total_seconds), 3600)
177-
minutes = remainder // 60
178-
formatted += f"{sign}{hours:02d}:{minutes:02d}"
179-
else:
180-
# Naive datetime: assume UTC to match Go's always-offset format
181-
formatted += "+00:00"
182-
value = formatted
186+
value = _format_datetime_iso8601(value)
183187
elif isinstance(value, datetime.date):
184188
# Must come after datetime.datetime check (datetime is a subclass of date)
185189
value_type = ValueType.ISO8601
@@ -206,6 +210,13 @@ def encode_value(value: Any, value_type: ValueType | None = None) -> tuple[bytes
206210
raise EncodeError(f"Expected int or float for FLOAT, got {type(value).__name__}")
207211
return encode_double(float(value)), value_type
208212
elif value_type in (ValueType.TEXT, ValueType.ISO8601):
213+
# ISO8601 accepts datetime objects and converts them to string.
214+
# This is needed when encode_row_values passes a datetime with
215+
# explicit ISO8601 type (the auto-inference path converts earlier).
216+
if value_type == ValueType.ISO8601 and isinstance(value, datetime.datetime):
217+
value = _format_datetime_iso8601(value)
218+
elif value_type == ValueType.ISO8601 and isinstance(value, datetime.date):
219+
value = value.isoformat()
209220
if not isinstance(value, str):
210221
raise EncodeError(f"Expected str for {value_type.name}, got {type(value).__name__}")
211222
return encode_text(value), value_type

tests/test_messages_responses.py

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -570,6 +570,124 @@ def test_decode_rows_continuation_rejects_excessive_column_count(self) -> None:
570570
)
571571

572572

573+
class TestRowsResponseValueTypes:
574+
"""Full RowsResponse round-trips with BOOLEAN, UNIXTIME, ISO8601, and BLOB."""
575+
576+
def test_boolean_column(self) -> None:
577+
"""BOOLEAN (code 11) uses all 4 nibble bits — catches truncation bugs."""
578+
resp = RowsResponse(
579+
column_names=["flag"],
580+
column_types=[ValueType.BOOLEAN],
581+
rows=[[True], [False]],
582+
)
583+
data = resp.encode()
584+
decoded = RowsResponse.decode_body(data[HEADER_SIZE:])
585+
assert decoded.rows == [[True], [False]]
586+
assert decoded.column_types == [ValueType.BOOLEAN]
587+
588+
def test_unixtime_column(self) -> None:
589+
"""UNIXTIME (code 9) round-trips as raw int, not datetime."""
590+
resp = RowsResponse(
591+
column_names=["created_at"],
592+
column_types=[ValueType.UNIXTIME],
593+
row_types=[[ValueType.UNIXTIME], [ValueType.UNIXTIME]],
594+
rows=[[1700000000], [0]],
595+
)
596+
data = resp.encode()
597+
decoded = RowsResponse.decode_body(data[HEADER_SIZE:])
598+
assert decoded.rows == [[1700000000], [0]]
599+
assert decoded.column_types == [ValueType.UNIXTIME]
600+
601+
def test_iso8601_column(self) -> None:
602+
"""ISO8601 values go through _parse_iso8601 on decode."""
603+
from datetime import UTC, datetime
604+
605+
dt = datetime(2024, 6, 15, 10, 30, 0, tzinfo=UTC)
606+
resp = RowsResponse(
607+
column_names=["ts"],
608+
column_types=[ValueType.ISO8601],
609+
rows=[[dt]],
610+
)
611+
data = resp.encode()
612+
decoded = RowsResponse.decode_body(data[HEADER_SIZE:])
613+
assert decoded.rows[0][0] == dt
614+
615+
def test_blob_column(self) -> None:
616+
"""BLOB has variable-length encoding with padding in row context."""
617+
resp = RowsResponse(
618+
column_names=["data"],
619+
column_types=[ValueType.BLOB],
620+
rows=[[b"\xde\xad\xbe\xef"], [b""]],
621+
)
622+
data = resp.encode()
623+
decoded = RowsResponse.decode_body(data[HEADER_SIZE:])
624+
assert decoded.rows == [[b"\xde\xad\xbe\xef"], [b""]]
625+
626+
def test_mixed_all_types(self) -> None:
627+
"""Every value type in a single row exercises full encoding path."""
628+
from datetime import UTC, datetime
629+
630+
dt = datetime(2024, 1, 1, tzinfo=UTC)
631+
resp = RowsResponse(
632+
column_names=["i", "f", "t", "b", "n", "ut", "iso", "bo"],
633+
column_types=[
634+
ValueType.INTEGER,
635+
ValueType.FLOAT,
636+
ValueType.TEXT,
637+
ValueType.BLOB,
638+
ValueType.NULL,
639+
ValueType.UNIXTIME,
640+
ValueType.ISO8601,
641+
ValueType.BOOLEAN,
642+
],
643+
row_types=[
644+
[
645+
ValueType.INTEGER,
646+
ValueType.FLOAT,
647+
ValueType.TEXT,
648+
ValueType.BLOB,
649+
ValueType.NULL,
650+
ValueType.UNIXTIME,
651+
ValueType.ISO8601,
652+
ValueType.BOOLEAN,
653+
]
654+
],
655+
rows=[
656+
[42, 3.14, "hello", b"\x00\x01", None, 1700000000, dt, True],
657+
],
658+
)
659+
data = resp.encode()
660+
decoded = RowsResponse.decode_body(data[HEADER_SIZE:])
661+
row = decoded.rows[0]
662+
assert row[0] == 42
663+
assert row[1] == 3.14
664+
assert row[2] == "hello"
665+
assert row[3] == b"\x00\x01"
666+
assert row[4] is None
667+
assert row[5] == 1700000000
668+
assert row[6] == dt
669+
assert row[7] is True
670+
671+
def test_blob_multiple_sizes(self) -> None:
672+
"""Multiple BLOB rows with different sizes verify padding/offset interplay."""
673+
resp = RowsResponse(
674+
column_names=["data"],
675+
column_types=[ValueType.BLOB],
676+
rows=[
677+
[b""], # empty
678+
[b"x"], # 1 byte, needs 7 pad
679+
[b"12345678"], # exactly 8 bytes, no pad
680+
[b"123456789"], # 9 bytes, needs 7 pad
681+
],
682+
)
683+
data = resp.encode()
684+
decoded = RowsResponse.decode_body(data[HEADER_SIZE:])
685+
assert decoded.rows[0] == [b""]
686+
assert decoded.rows[1] == [b"x"]
687+
assert decoded.rows[2] == [b"12345678"]
688+
assert decoded.rows[3] == [b"123456789"]
689+
690+
573691
class TestEmptyResponse:
574692
def test_encode(self) -> None:
575693
msg = EmptyResponse()

0 commit comments

Comments
 (0)