Skip to content

Commit 915dcd9

Browse files
Pin cursor.description for empty and all-NULL result sets
Two edge cases of cursor.description were untested: - Empty result set. A ``SELECT ... WHERE 1=0`` returns zero rows, so the wire layer never populates ``column_types``. description carries the column names but every type_code is None. Callers (including SQLAlchemy's introspection path) depend on description being truthy for any query that "could" return rows. - Row of all NULLs. The wire protocol's per-row type header flips every value's nibble to NULL when the value is None, and column_types is sourced from the first row, so the declared SQL schema type is lost. Every description[i][1] ends up as ValueType.NULL (5). Neither behaviour is a bug — both are load-bearing interpretations of the wire format — but they weren't tested and a refactor could silently alter them. Pin both contracts with integration tests against the live cluster. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ed2820a commit 915dcd9

File tree

1 file changed

+53
-0
lines changed

1 file changed

+53
-0
lines changed

tests/integration/test_misc_coverage.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,3 +132,56 @@ def test_complex_rejected_as_data_error(self, cluster_address: str) -> None:
132132
c = conn.cursor()
133133
with pytest.raises(DataError):
134134
c.execute("SELECT ?", [complex(1, 2)])
135+
136+
137+
@pytest.mark.integration
138+
class TestCursorDescriptionEdgeCases:
139+
"""``cursor.description`` invariants after queries whose result sets
140+
are either empty or contain only NULLs. PEP 249 requires description
141+
to reflect the query's column shape regardless of row count.
142+
"""
143+
144+
def test_description_populated_for_empty_resultset(self, cluster_address: str) -> None:
145+
"""A SELECT that returns zero rows still populates description with
146+
column names. ``type_code`` is None for each column because the
147+
wire layer sources it from the first row's type header and there
148+
are no rows — this is the current contract; pin it.
149+
"""
150+
with connect(cluster_address, database="test_desc_empty") as conn:
151+
c = conn.cursor()
152+
c.execute("CREATE TABLE IF NOT EXISTS desc_empty (a INTEGER, b TEXT)")
153+
c.execute("DELETE FROM desc_empty")
154+
c.execute("SELECT a, b FROM desc_empty WHERE 1=0")
155+
156+
assert c.description is not None
157+
assert len(c.description) == 2
158+
assert [col[0] for col in c.description] == ["a", "b"]
159+
# No rows → no per-row type header → type_code is None on every column.
160+
assert [col[1] for col in c.description] == [None, None]
161+
assert c.fetchall() == []
162+
163+
def test_description_typecode_when_only_row_is_all_null(self, cluster_address: str) -> None:
164+
"""A row of all-NULLs sets every column's type nibble to NULL in the
165+
wire frame, which propagates to ``description[i][1]`` as
166+
``ValueType.NULL`` (= 5). Locked in so a future refactor (e.g.
167+
falling back to declared column types when the first row is all
168+
NULLs) is a deliberate decision, not a silent drift.
169+
"""
170+
from dqlitewire.constants import ValueType
171+
172+
with connect(cluster_address, database="test_desc_nulls") as conn:
173+
c = conn.cursor()
174+
c.execute("CREATE TABLE IF NOT EXISTS desc_nulls (a INTEGER, b TEXT)")
175+
c.execute("DELETE FROM desc_nulls")
176+
c.execute("INSERT INTO desc_nulls (a, b) VALUES (NULL, NULL)")
177+
conn.commit()
178+
c.execute("SELECT a, b FROM desc_nulls")
179+
180+
assert c.description is not None
181+
assert len(c.description) == 2
182+
assert [col[0] for col in c.description] == ["a", "b"]
183+
# Current contract: per-row NULL override flips the type
184+
# nibble, so declared column types are lost when the sample
185+
# row is all-NULLs. ValueType.NULL == 5.
186+
assert [col[1] for col in c.description] == [int(ValueType.NULL), int(ValueType.NULL)]
187+
assert c.fetchall() == [(None, None)]

0 commit comments

Comments
 (0)