Skip to content

Commit f148abd

Browse files
Pin Cursor.description NULL-type-code → None mapping at unit level
The cycle 22 mapping replaces ``ValueType.NULL`` (5) with ``None`` in ``description[i].type_code`` so the surface satisfies PEP 249 §6.1.2 ("type_code must compare equal to one of the Type Objects" — NULL is not one of the five). Existing coverage: - ``test_types_dbapi_compare`` pins the Type-Object surface contract (NULL is intentionally exempt from ``_DBAPIType``). - ``test_description_strict_type_codes`` covers the mismatched-column-count guard. - The integration test in ``test_misc_coverage.py`` exercises the mapping end-to-end. What was missing: a unit-level pin for the mixed-row case (some columns NULL-typed, others with real type codes — the load-bearing path for the comprehension). Add three pins: mixed-row sync, mixed-row async, all-NULL columns. A refactor that drops the comprehension surfaces immediately. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 0b84808 commit f148abd

1 file changed

Lines changed: 106 additions & 0 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Pin: ``Cursor.description`` / ``AsyncCursor.description`` map
2+
``ValueType.NULL`` to ``None`` (cycle 22 mapping).
3+
4+
Wire-layer returns ``column_types`` derived from row 0; if a
5+
column was tagged ``ValueType.NULL`` (e.g. a ``LEFT JOIN``
6+
unmatched row, or a literal ``SELECT NULL``), the cycle 22
7+
mapping replaces ``ValueType.NULL`` (5) with ``None`` in the
8+
description's ``type_code`` slot. PEP 249 §6.1.2 says the
9+
type_code "must compare equal to one of the Type Objects" —
10+
NULL is not one of the five Type Objects, and surfacing
11+
``None`` matches the documented empty-result-set deviation
12+
already in this module.
13+
14+
The mixed-row case is the load-bearing surface for the test
15+
gap: a column tagged NULL alongside columns with real type
16+
codes. A refactor that drops the comprehension would silently
17+
re-introduce ``ValueType.NULL`` into description and break
18+
every cross-driver type-branch.
19+
"""
20+
21+
from __future__ import annotations
22+
23+
from typing import Any
24+
from unittest.mock import MagicMock
25+
26+
import pytest
27+
28+
from dqlitedbapi.aio.cursor import AsyncCursor
29+
from dqlitedbapi.cursor import Cursor
30+
from dqlitewire.constants import ValueType
31+
32+
33+
class _MixedNullTypesClient:
34+
"""Returns ``column_types=[TEXT, NULL, INTEGER]`` — the
35+
mixed-row case the cycle 22 mapping must handle."""
36+
37+
async def query_raw_typed(
38+
self, sql: str, params: Any
39+
) -> tuple[list[str], list[int], list[list[int]], list[list[Any]]]:
40+
return (
41+
["name", "n", "age"],
42+
[int(ValueType.TEXT), int(ValueType.NULL), int(ValueType.INTEGER)],
43+
[[3, 5, 1], [3, 5, 1]],
44+
[["alice", None, 30], ["bob", None, 31]],
45+
)
46+
47+
48+
class _AllNullClient:
49+
async def query_raw_typed(
50+
self, sql: str, params: Any
51+
) -> tuple[list[str], list[int], list[list[int]], list[list[Any]]]:
52+
return (
53+
["a", "b"],
54+
[int(ValueType.NULL), int(ValueType.NULL)],
55+
[[5, 5]],
56+
[[None, None]],
57+
)
58+
59+
60+
@pytest.mark.asyncio
61+
async def test_sync_description_maps_null_to_none_in_mixed_row() -> None:
62+
conn = MagicMock()
63+
64+
async def _get() -> object:
65+
return _MixedNullTypesClient()
66+
67+
conn._get_async_connection = _get
68+
cur = Cursor(conn)
69+
await cur._execute_async("SELECT name, NULL AS n, age FROM users")
70+
71+
assert cur.description is not None
72+
assert cur.description[0][1] == ValueType.TEXT
73+
assert cur.description[1][1] is None
74+
assert cur.description[2][1] == ValueType.INTEGER
75+
76+
77+
@pytest.mark.asyncio
78+
async def test_async_description_maps_null_to_none_in_mixed_row() -> None:
79+
conn = MagicMock()
80+
81+
async def _ensure() -> object:
82+
return _MixedNullTypesClient()
83+
84+
conn._ensure_connection = _ensure
85+
cur = AsyncCursor(conn)
86+
await cur._execute_unlocked("SELECT name, NULL AS n, age FROM users", ())
87+
88+
assert cur.description is not None
89+
assert cur.description[0][1] == ValueType.TEXT
90+
assert cur.description[1][1] is None
91+
assert cur.description[2][1] == ValueType.INTEGER
92+
93+
94+
@pytest.mark.asyncio
95+
async def test_sync_description_all_null_columns_all_none() -> None:
96+
conn = MagicMock()
97+
98+
async def _get() -> object:
99+
return _AllNullClient()
100+
101+
conn._get_async_connection = _get
102+
cur = Cursor(conn)
103+
await cur._execute_async("SELECT NULL AS a, NULL AS b")
104+
105+
assert cur.description is not None
106+
assert all(d[1] is None for d in cur.description)

0 commit comments

Comments
 (0)