informix-db/tests/test_select.py
Ryan Malloy 34ad04a872 Phase 2.x: VARCHAR row decoding works — three byte-level fixes
Three findings, each caught by a different debugging technique,
documented in DECISION_LOG.md:

1. CURNAME+NFETCH PDU: trailing reserved field is SHORT not INT.
   Caught by byte-diffing our 44-byte PDU against JDBC's 42-byte
   reference under socat. The server tolerated the longer version
   for INT-only SELECTs (silently consuming extra zeros) but
   rejected it for VARCHAR queries. Lesson: server tolerance varies
   by query type — always match JDBC byte-for-byte.

2. SQ_TUPLE payload pads to even byte alignment. An 11-byte
   "syscolumns" VARCHAR payload had a trailing 0x00 between it and
   the next SQ_TUPLE tag. JDBC's IfxRowColumn.readTuple consumes
   this pad silently; we weren't, so any odd-length variable-width
   row desynced the parser.

3. VARCHAR/NCHAR/NVCHAR in tuple data use a SINGLE-byte length
   prefix (max 255 chars — IDS VARCHAR's hard limit). NOT a 2-byte
   short as I'd initially assumed. CHAR is fixed-width per
   encoded_length. LVARCHAR uses a 4-byte int prefix for >255 byte
   values.

Module changes:
  src/informix_db/_resultset.py — _LENGTH_PREFIXED_SHORT_TYPES set,
    branched VARCHAR/NCHAR/NVCHAR (1-byte prefix) vs CHAR (fixed)
    vs LVARCHAR (4-byte prefix); even-byte alignment pad consumed
    after each SQ_TUPLE payload.
  src/informix_db/cursors.py — CURNAME+NFETCH and standalone NFETCH
    PDUs now write_short(0) for the reserved trailing field.

Tests: 40 unit + 18 integration (3 new VARCHAR tests) = 58 total,
all green, ruff clean. New tests cover:
  - VARCHAR single-column SELECT
  - Odd-length VARCHAR row (regression for the pad-byte bug)
  - Mixed INT + VARCHAR + FLOAT three-column SELECT

Sample output:
  SELECT FIRST 5 tabname FROM systables → ('systables',),
    ('syscolumns',), ('sysindices',), ('systabauth',), ('syscolauth',)
  SELECT FIRST 3 tabname, tabid, nrows → ('systables', 1, 276.0), ...

VARCHAR was the last known gap from the Phase 2 commit. Phase 2
now reads INT, BIGINT, REAL, FLOAT, CHAR, VARCHAR end-to-end. Phase
6+ types (DATETIME, INTERVAL, DECIMAL, BLOBs) remain.
2026-05-04 07:55:13 -06:00

168 lines
6.3 KiB
Python

"""Phase 2 integration tests — SELECT execution end-to-end.
Marked ``integration`` so the default ``pytest`` invocation skips them.
Run with ``pytest -m integration`` after ``docker compose up``.
"""
from __future__ import annotations
import pytest
import informix_db
from tests.conftest import ConnParams
pytestmark = pytest.mark.integration
def _connect(conn_params: ConnParams) -> informix_db.Connection:
return informix_db.connect(
host=conn_params.host,
port=conn_params.port,
user=conn_params.user,
password=conn_params.password,
database=conn_params.database,
server=conn_params.server,
connect_timeout=10.0,
read_timeout=10.0,
)
def test_select_1_returns_one_tuple(conn_params: ConnParams) -> None:
"""The canonical Phase 2 milestone: ``SELECT 1`` → ``(1,)``."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT 1 FROM systables WHERE tabid = 1")
assert cur.fetchone() == (1,)
assert cur.fetchone() is None # no more rows
def test_select_1_description_shape(conn_params: ConnParams) -> None:
"""``cursor.description`` is a 7-tuple per PEP 249."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT 1 FROM systables WHERE tabid = 1")
assert cur.description is not None
assert len(cur.description) == 1
col = cur.description[0]
assert len(col) == 7
# (name, type_code, display_size, internal_size, precision, scale, null_ok)
name, type_code, display_size, internal_size, _precision, _scale, _null_ok = col
assert name == "(constant)"
assert type_code == 2 # IfxType.INT
assert display_size == internal_size == 4
def test_select_multi_row_int(conn_params: ConnParams) -> None:
"""Multi-row INT SELECT — fetchall returns a list of tuples."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 5 tabid FROM systables ORDER BY tabid")
rows = cur.fetchall()
assert len(rows) == 5
assert rows == [(1,), (2,), (3,), (4,), (5,)]
assert cur.rowcount == 5
def test_select_multi_column_mixed_types(conn_params: ConnParams) -> None:
"""Multi-column with mixed types (INT + FLOAT)."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 3 tabid, nrows FROM systables ORDER BY tabid")
rows = cur.fetchall()
assert len(rows) == 3
for tabid, nrows in rows:
assert isinstance(tabid, int)
assert isinstance(nrows, float)
names = [c[0] for c in cur.description]
assert names == ["tabid", "nrows"]
def test_iterator_protocol(conn_params: ConnParams) -> None:
"""Cursor supports the iterator protocol — ``for row in cursor``."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 3 tabid FROM systables ORDER BY tabid")
rows = list(cur)
assert rows == [(1,), (2,), (3,)]
def test_fetchmany(conn_params: ConnParams) -> None:
"""``fetchmany(n)`` returns up to n rows."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 5 tabid FROM systables ORDER BY tabid")
first_two = cur.fetchmany(2)
assert first_two == [(1,), (2,)]
rest = cur.fetchall()
assert rest == [(3,), (4,), (5,)]
def test_two_executes_on_same_cursor(conn_params: ConnParams) -> None:
"""Re-executing on the same cursor resets state cleanly."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT 1 FROM systables WHERE tabid = 1")
assert cur.fetchone() == (1,)
cur.execute("SELECT 2 FROM systables WHERE tabid = 1")
assert cur.fetchone() == (2,)
def test_varchar_single_column(conn_params: ConnParams) -> None:
"""VARCHAR column decoding — single-byte length prefix in tuple payload."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 5 tabname FROM systables ORDER BY tabid")
rows = cur.fetchall()
assert len(rows) == 5
# All rows should have a single VARCHAR string element
for (name,) in rows:
assert isinstance(name, str)
assert len(name) > 0
# Specifically the system-catalog tables we expect
names = [r[0] for r in rows]
assert names[0] == "systables"
assert "syscolumns" in names
def test_varchar_with_odd_length_padding(conn_params: ConnParams) -> None:
"""Odd-length VARCHAR row — payload padding to even alignment must be consumed.
'syscolumns' is 10 chars but the payload is 11 bytes (1-byte length + 10 bytes).
11 is odd, so the SQ_TUPLE format inserts a pad byte before the next message.
Regression for the bug where parse_tuple_payload didn't consume that pad.
"""
with _connect(conn_params) as conn:
cur = conn.cursor()
# systables row 2 is "syscolumns" (10 chars → 11-byte payload → odd → pad)
cur.execute("SELECT FIRST 2 tabname FROM systables ORDER BY tabid")
rows = cur.fetchall()
assert rows == [("systables",), ("syscolumns",)]
def test_mixed_types_int_varchar_float(conn_params: ConnParams) -> None:
"""Three-column SELECT mixing INT + VARCHAR + FLOAT."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT FIRST 3 tabname, tabid, nrows FROM systables ORDER BY tabid")
rows = cur.fetchall()
assert len(rows) == 3
for name, tabid, nrows in rows:
assert isinstance(name, str)
assert isinstance(tabid, int)
assert isinstance(nrows, float)
def test_two_cursors_on_same_connection(conn_params: ConnParams) -> None:
"""Two cursors on one connection — used sequentially (Phase 4 may parallel-ize)."""
with _connect(conn_params) as conn:
cur1 = conn.cursor()
cur1.execute("SELECT 1 FROM systables WHERE tabid = 1")
assert cur1.fetchone() == (1,)
cur1.close()
cur2 = conn.cursor()
cur2.execute("SELECT FIRST 2 tabid FROM systables ORDER BY tabid")
assert cur2.fetchall() == [(1,), (2,)]
cur2.close()