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.
168 lines
6.3 KiB
Python
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()
|