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