Skip to content

Commit 2242238

Browse files
Own datetime conversions at the DBAPI layer
Moves the wire-layer datetime handling down into the driver, matching PEP 249 (dates/times return datetime objects) and the convention used by psycopg/mysqlclient/asyncpg. The wire codec now hands us raw ISO8601 strings and UNIXTIME int64s; this layer turns them into datetime objects on read and stringifies datetime/date bind params on write. types.py adds private helpers: - _iso8601_from_datetime: formats datetime/date as the space-separated ISO layout the Go and C clients use. Accepts both naive and aware; naive formats without offset, aware preserves the offset. - _datetime_from_iso8601: parses the inverse. Naive string → naive datetime; aware string → aware. No silent UTC assumption. - _datetime_from_unixtime: int64 epoch seconds → UTC-aware datetime. - _convert_bind_param: wraps the outbound datetime/date → str step. cursor.py and aio/cursor.py: - Read path: switched from query_raw to query_raw_typed so we know the per-column wire type code. A per-column converter table maps ISO8601 and UNIXTIME values through the helpers above. Other types pass through unchanged (the wire codec already produced the right Python primitive). - Write path: _convert_bind_param runs over params before they hit the wire encoder, which now rejects non-primitive inputs. - description[i][1] is now populated with the wire ValueType integer instead of None — it's the natural type_code for PEP 249. tests/integration/test_datetime_conversion.py exercises the contracts end-to-end against a live cluster: naive-stays-naive, aware round-trips with its original offset (+05:30 verified), microseconds preserved, NULL decodes to None, datetime/date bind params reach the server, description carries the correct wire type codes, UNIXTIME-tagged columns (INTEGER values in DATETIME-typed columns, per dqlite server's query.c) decode to datetime, and the AsyncCursor path goes through the same conversion layer. Three mock-based unit tests updated to patch query_raw_typed instead of query_raw; the new method returns a 3-tuple (names, types, rows) where the old one returned 2. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 2dfee26 commit 2242238

7 files changed

Lines changed: 342 additions & 25 deletions

File tree

src/dqlitedbapi/aio/cursor.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from collections.abc import Sequence
44
from typing import TYPE_CHECKING, Any
55

6+
from dqlitedbapi.cursor import _convert_params, _convert_row
67
from dqlitedbapi.exceptions import InterfaceError
78

89
if TYPE_CHECKING:
@@ -33,7 +34,7 @@ class AsyncCursor:
3334

3435
def __init__(self, connection: "AsyncConnection") -> None:
3536
self._connection = connection
36-
self._description: list[tuple[str, None, None, None, None, None, None]] | None = None
37+
self._description: list[tuple[str, int | None, None, None, None, None, None]] | None = None
3738
self._rowcount = -1
3839
self._arraysize = 1
3940
self._rows: list[tuple[Any, ...]] = []
@@ -44,7 +45,7 @@ def __init__(self, connection: "AsyncConnection") -> None:
4445
@property
4546
def description(
4647
self,
47-
) -> list[tuple[str, None, None, None, None, None, None]] | None:
48+
) -> list[tuple[str, int | None, None, None, None, None, None]] | None:
4849
"""Column descriptions for the last query."""
4950
return self._description
5051

@@ -76,29 +77,40 @@ async def execute(
7677
) -> "AsyncCursor":
7778
"""Execute a database operation (query or command).
7879
79-
Routes through DqliteConnection's public API (execute/query_raw)
80+
Routes through DqliteConnection's public API (execute/query_raw_typed)
8081
which goes through _run_protocol(), providing the _in_use guard,
8182
connection invalidation on fatal errors, and leader-change detection.
8283
The _op_lock serializes operations on the same connection.
8384
"""
8485
self._check_closed()
8586

8687
conn = await self._connection._ensure_connection()
87-
params = list(parameters) if parameters is not None else None
88+
params = _convert_params(parameters)
8889

8990
# Determine if this is a query that returns rows.
9091
# Note: WITH ... INSERT/UPDATE/DELETE (without RETURNING) will be
91-
# misrouted to query_raw. This is a known limitation of the heuristic.
92+
# misrouted to query_raw_typed. This is a known limitation of the heuristic.
9293
normalized = _strip_leading_comments(operation).upper()
9394
is_query = normalized.startswith(("SELECT", "PRAGMA", "EXPLAIN", "WITH")) or (
9495
" RETURNING " in normalized or normalized.endswith(" RETURNING")
9596
)
9697

9798
async with self._connection._op_lock:
9899
if is_query:
99-
columns, rows = await conn.query_raw(operation, params)
100-
self._description = [(name, None, None, None, None, None, None) for name in columns]
101-
self._rows = [tuple(row) for row in rows]
100+
columns, column_types, rows = await conn.query_raw_typed(operation, params)
101+
self._description = [
102+
(
103+
name,
104+
column_types[i] if i < len(column_types) else None,
105+
None,
106+
None,
107+
None,
108+
None,
109+
None,
110+
)
111+
for i, name in enumerate(columns)
112+
]
113+
self._rows = [_convert_row(row, column_types) for row in rows]
102114
self._row_index = 0
103115
self._rowcount = len(rows)
104116
else:

src/dqlitedbapi/cursor.py

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,47 @@
11
"""PEP 249 Cursor implementation for dqlite."""
22

3-
from collections.abc import Sequence
3+
from collections.abc import Callable, Sequence
44
from typing import TYPE_CHECKING, Any
55

6+
from dqlitewire.constants import ValueType
7+
68
from dqlitedbapi.exceptions import InterfaceError
9+
from dqlitedbapi.types import (
10+
_convert_bind_param,
11+
_datetime_from_iso8601,
12+
_datetime_from_unixtime,
13+
)
714

815
if TYPE_CHECKING:
916
from dqlitedbapi.connection import Connection
1017

1118

19+
# Per-wire-type result converters. NULL/empty values pass through as None;
20+
# unrecognized types pass through unchanged (the wire codec already produced
21+
# an appropriate Python primitive).
22+
_RESULT_CONVERTERS: dict[int, Callable[[Any], Any]] = {
23+
int(ValueType.ISO8601): lambda v: _datetime_from_iso8601(v) if isinstance(v, str) else v,
24+
int(ValueType.UNIXTIME): lambda v: _datetime_from_unixtime(v) if isinstance(v, int) else v,
25+
}
26+
27+
28+
def _convert_row(row: Sequence[Any], column_types: Sequence[int]) -> tuple[Any, ...]:
29+
"""Apply result-side converters to a row based on its column wire types."""
30+
result = list(row)
31+
for i, tcode in enumerate(column_types):
32+
converter = _RESULT_CONVERTERS.get(tcode)
33+
if converter is not None and result[i] is not None:
34+
result[i] = converter(result[i])
35+
return tuple(result)
36+
37+
38+
def _convert_params(params: Sequence[Any] | None) -> list[Any] | None:
39+
"""Convert driver-level bind parameters (e.g. datetime) to wire primitives."""
40+
if params is None:
41+
return None
42+
return [_convert_bind_param(p) for p in params]
43+
44+
1245
def _strip_leading_comments(sql: str) -> str:
1346
"""Strip leading SQL comments (-- and /* */) and whitespace."""
1447
s = sql.strip()
@@ -33,7 +66,7 @@ class Cursor:
3366

3467
def __init__(self, connection: "Connection") -> None:
3568
self._connection = connection
36-
self._description: list[tuple[str, None, None, None, None, None, None]] | None = None
69+
self._description: list[tuple[str, int | None, None, None, None, None, None]] | None = None
3770
self._rowcount = -1
3871
self._arraysize = 1
3972
self._rows: list[tuple[Any, ...]] = []
@@ -44,13 +77,15 @@ def __init__(self, connection: "Connection") -> None:
4477
@property
4578
def description(
4679
self,
47-
) -> list[tuple[str, None, None, None, None, None, None]] | None:
80+
) -> list[tuple[str, int | None, None, None, None, None, None]] | None:
4881
"""Column descriptions for the last query.
4982
5083
Returns a list of 7-tuples:
5184
(name, type_code, display_size, internal_size, precision, scale, null_ok)
5285
53-
Only name is populated; others are None for compatibility.
86+
``type_code`` is the wire-level ``ValueType`` integer from the first
87+
result frame (e.g. 10 for ISO8601, 9 for UNIXTIME). The other fields
88+
are None — dqlite doesn't expose them.
5489
"""
5590
return self._description
5691

@@ -91,25 +126,36 @@ def execute(self, operation: str, parameters: Sequence[Any] | None = None) -> "C
91126
async def _execute_async(self, operation: str, parameters: Sequence[Any] | None = None) -> None:
92127
"""Async implementation of execute.
93128
94-
Routes through DqliteConnection's public API (execute/query_raw)
129+
Routes through DqliteConnection's public API (execute/query_raw_typed)
95130
which goes through _run_protocol(), providing the _in_use guard,
96131
connection invalidation on fatal errors, and leader-change detection.
97132
"""
98133
conn = await self._connection._get_async_connection()
99-
params = list(parameters) if parameters is not None else None
134+
params = _convert_params(parameters)
100135

101136
# Determine if this is a query that returns rows.
102137
# Note: WITH ... INSERT/UPDATE/DELETE (without RETURNING) will be
103-
# misrouted to query_raw. This is a known limitation of the heuristic.
138+
# misrouted to query_raw_typed. This is a known limitation of the heuristic.
104139
normalized = _strip_leading_comments(operation).upper()
105140
is_query = normalized.startswith(("SELECT", "PRAGMA", "EXPLAIN", "WITH")) or (
106141
" RETURNING " in normalized or normalized.endswith(" RETURNING")
107142
)
108143

109144
if is_query:
110-
columns, rows = await conn.query_raw(operation, params)
111-
self._description = [(name, None, None, None, None, None, None) for name in columns]
112-
self._rows = [tuple(row) for row in rows]
145+
columns, column_types, rows = await conn.query_raw_typed(operation, params)
146+
self._description = [
147+
(
148+
name,
149+
column_types[i] if i < len(column_types) else None,
150+
None,
151+
None,
152+
None,
153+
None,
154+
None,
155+
)
156+
for i, name in enumerate(columns)
157+
]
158+
self._rows = [_convert_row(row, column_types) for row in rows]
113159
self._row_index = 0
114160
self._rowcount = len(rows)
115161
else:

src/dqlitedbapi/types.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
"""PEP 249 type objects and constructors for dqlite."""
22

33
import datetime
4+
from typing import Any
45

56

67
# Type constructors
@@ -62,3 +63,82 @@ def __hash__(self) -> int:
6263
NUMBER = _DBAPIType("INTEGER", "INT", "SMALLINT", "BIGINT", "REAL", "FLOAT", "DOUBLE", "NUMERIC")
6364
DATETIME = _DBAPIType("DATE", "TIME", "TIMESTAMP", "DATETIME")
6465
ROWID = _DBAPIType("ROWID", "INTEGER PRIMARY KEY")
66+
67+
68+
# Internal conversion helpers.
69+
#
70+
# The wire codec deals only in primitives (ISO8601 → str, UNIXTIME → int64).
71+
# PEP 249 specifies that drivers SHOULD return datetime objects for date/time
72+
# columns — and every major Python driver (psycopg, mysqlclient, asyncpg, ...)
73+
# does. These helpers implement that conversion at the driver (DBAPI) layer,
74+
# matching Go's database/sql driver split.
75+
76+
77+
def _iso8601_from_datetime(value: datetime.datetime | datetime.date) -> str:
78+
"""Format a datetime/date as an ISO 8601 string for wire transmission.
79+
80+
Uses the space-separated layout so values are byte-for-byte comparable
81+
with what Go and the C client produce. Accepts both naive and
82+
timezone-aware datetimes — naive values round-trip as naive (matching
83+
pysqlite semantics), aware values preserve the offset.
84+
"""
85+
if isinstance(value, datetime.datetime):
86+
base = f"{value.year:04d}" + value.strftime("-%m-%d %H:%M:%S")
87+
if value.microsecond:
88+
base += f".{value.microsecond:06d}"
89+
if value.tzinfo is None:
90+
return base
91+
offset = value.utcoffset()
92+
assert offset is not None
93+
total_seconds = int(offset.total_seconds())
94+
sign = "+" if total_seconds >= 0 else "-"
95+
hours, remainder = divmod(abs(total_seconds), 3600)
96+
minutes = remainder // 60
97+
return base + f"{sign}{hours:02d}:{minutes:02d}"
98+
# datetime.date (must come after datetime check — datetime is a subclass).
99+
return value.isoformat()
100+
101+
102+
def _datetime_from_iso8601(text: str) -> datetime.datetime | None:
103+
"""Parse an ISO 8601 string into ``datetime.datetime``.
104+
105+
Returns ``None`` for the empty string — pre-null-patch dqlite servers
106+
sometimes emit empty text for NULL datetime cells, and the modern
107+
server still tolerates empty ISO8601 values. Returning None matches
108+
PEP 249 NULL semantics.
109+
110+
Naive input round-trips as naive; aware input preserves the offset.
111+
"""
112+
if not text:
113+
return None
114+
s = text[:-1] + "+00:00" if text.endswith("Z") else text
115+
try:
116+
return datetime.datetime.fromisoformat(s)
117+
except ValueError:
118+
pass
119+
try:
120+
d = datetime.date.fromisoformat(s)
121+
except ValueError as exc:
122+
raise ValueError(f"Cannot parse ISO 8601 datetime: {text!r}") from exc
123+
return datetime.datetime(d.year, d.month, d.day)
124+
125+
126+
def _datetime_from_unixtime(value: int) -> datetime.datetime:
127+
"""Decode a UNIXTIME int64 into a UTC-aware ``datetime.datetime``.
128+
129+
UNIXTIME is unambiguously seconds-since-epoch in UTC, so returning a
130+
UTC-aware value is faithful. Callers that want local time can convert.
131+
"""
132+
return datetime.datetime.fromtimestamp(value, tz=datetime.UTC)
133+
134+
135+
def _convert_bind_param(value: Any) -> Any:
136+
"""Map driver-level Python types to wire primitives.
137+
138+
The wire codec accepts only bool/int/float/str/bytes/None; datetime and
139+
date are driver-level conveniences that we stringify to ISO 8601 before
140+
handing off. Everything else passes through unchanged.
141+
"""
142+
if isinstance(value, datetime.datetime | datetime.date):
143+
return _iso8601_from_datetime(value)
144+
return value

0 commit comments

Comments
 (0)