Three Phase 4 follow-ups in one push, all with empirical wire analysis:
1. PARAMETERIZED SELECT
cur.execute('SELECT tabname FROM systables WHERE tabid = ?', (1,))
→ ('systables',)
Wire flow: PREPARE → DESCRIBE → SQ_BIND-only (no EXECUTE) →
CURNAME+NFETCH → TUPLE+DONE → drain → CLOSE+RELEASE.
The cursor open is what executes the prepared query; SQ_BIND just
binds values into scope. No need for the IDESCRIBE handshake JDBC
does for type discovery — server accepts our typed bind directly.
2. NULL ROW DECODING — per-type sentinel detection
Each IDS type has its own NULL sentinel in tuple data:
INT → 0x80000000 (INT_MIN)
BIGINT → 0x8000000000000000 (LONG_MIN)
SMALLINT→ 0x8000 (SHORT_MIN)
REAL → all 0xFF (NaN bit pattern)
FLOAT → all 0xFF
DATE → 0x80000000 (same as INT)
VARCHAR → [byte 1][byte 0] (length=1, single nul) — distinguishable
from empty '' which is [byte 0] (length=0)
Verified by wire capture against the dev container — see
docs/CAPTURES/19-py-null-vs-onechar.socat.log and
docs/CAPTURES/20-py-int-null.socat.log.
The VARCHAR null marker is the trickiest because it LOOKS like a
1-byte string of nul, but VARCHAR can't contain embedded nuls
anyway, so the byte-0 within length-1 is unambiguous.
3. executemany(sql, seq_of_params) — PEP 249 batched DML
PREPARE once, loop SQ_BIND+SQ_EXECUTE per param set, RELEASE once.
Performance: only ~1.06x faster than execute() loop for 200 INSERTs
(dominated by per-row round trips). Phase 4.x optimization opportunity:
chain BIND+EXECUTE in one PDU without intermediate flush+read for
true bulk performance (would likely give 5-10x). Documented in
DECISION_LOG.md as a follow-up.
Module changes:
src/informix_db/converters.py:
+ Per-type NULL sentinel constants and detection in each decoder
+ Decoders now return None for sentinel values
src/informix_db/cursors.py:
+ _execute_select_with_params() — SQ_BIND alone, then cursor open
+ _build_bind_only_pdu() — SQ_BIND without trailing SQ_EXECUTE
+ executemany() — loop BIND+EXECUTE, accumulate rowcount
+ execute() now dispatches to _execute_select_with_params for
parameterized SELECT (was: NotSupportedError)
Tests: 40 unit + 47 integration (was 32; added 15 new) = 87 total,
all green, ruff clean. New test files / cases:
tests/test_nulls.py (7) — NULL decoding for INT, BIGINT, FLOAT,
REAL, VARCHAR, empty-vs-null, mixed columns
tests/test_params.py — added 4 parameterized SELECT tests, 5
executemany tests
tests/test_smoke.py — updated cursor-with-params test (was Phase 1
"raises", now Phase 4 "works")
Discovered captures kept for next-session debugging:
docs/CAPTURES/18-py-null-rows.socat.log
docs/CAPTURES/19-py-null-vs-onechar.socat.log
docs/CAPTURES/20-py-int-null.socat.log
103 lines
4.0 KiB
Python
103 lines
4.0 KiB
Python
"""Phase 4.x integration tests — NULL row decoding for all supported types.
|
|
|
|
Per-type NULL sentinels (verified empirically — see DECISION_LOG.md):
|
|
INT → 0x80000000 (INT_MIN)
|
|
BIGINT → 0x8000000000000000 (LONG_MIN)
|
|
REAL → all 0xFF (NaN bit pattern)
|
|
FLOAT → all 0xFF
|
|
VARCHAR → [byte 1][byte 0] (length=1, single nul)
|
|
"""
|
|
|
|
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_null_in_int_decoded_as_none(conn_params: ConnParams) -> None:
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("CREATE TEMP TABLE t_null_int (id INTEGER, val INTEGER)")
|
|
cur.execute("INSERT INTO t_null_int VALUES (?, ?)", (1, None))
|
|
cur.execute("SELECT val FROM t_null_int")
|
|
assert cur.fetchone() == (None,)
|
|
|
|
|
|
def test_null_in_bigint_decoded_as_none(conn_params: ConnParams) -> None:
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("CREATE TEMP TABLE t_null_bi (id INTEGER, val BIGINT)")
|
|
cur.execute("INSERT INTO t_null_bi VALUES (?, ?)", (1, None))
|
|
cur.execute("SELECT val FROM t_null_bi")
|
|
assert cur.fetchone() == (None,)
|
|
|
|
|
|
def test_null_in_float_decoded_as_none(conn_params: ConnParams) -> None:
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("CREATE TEMP TABLE t_null_f (id INTEGER, val FLOAT)")
|
|
cur.execute("INSERT INTO t_null_f VALUES (?, ?)", (1, None))
|
|
cur.execute("SELECT val FROM t_null_f")
|
|
assert cur.fetchone() == (None,)
|
|
|
|
|
|
def test_null_in_real_decoded_as_none(conn_params: ConnParams) -> None:
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("CREATE TEMP TABLE t_null_r (id INTEGER, val REAL)")
|
|
cur.execute("INSERT INTO t_null_r VALUES (?, ?)", (1, None))
|
|
cur.execute("SELECT val FROM t_null_r")
|
|
assert cur.fetchone() == (None,)
|
|
|
|
|
|
def test_null_in_varchar_decoded_as_none(conn_params: ConnParams) -> None:
|
|
"""The trickiest one — VARCHAR NULL is `[byte 1][byte 0]`, distinct from empty `''`."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("CREATE TEMP TABLE t_null_vc (id INTEGER, val VARCHAR(50))")
|
|
cur.execute("INSERT INTO t_null_vc VALUES (?, ?)", (1, None))
|
|
cur.execute("SELECT val FROM t_null_vc")
|
|
assert cur.fetchone() == (None,)
|
|
|
|
|
|
def test_empty_varchar_distinct_from_null(conn_params: ConnParams) -> None:
|
|
"""Empty string `''` is encoded `[byte 0]` (length=0); must NOT be confused with NULL."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("CREATE TEMP TABLE t_empty_vc (id INTEGER, val VARCHAR(50))")
|
|
cur.execute("INSERT INTO t_empty_vc VALUES (?, ?)", (1, ""))
|
|
cur.execute("INSERT INTO t_empty_vc VALUES (?, ?)", (2, None))
|
|
cur.execute("SELECT id, val FROM t_empty_vc ORDER BY id")
|
|
rows = cur.fetchall()
|
|
assert rows == [(1, ""), (2, None)]
|
|
|
|
|
|
def test_mixed_nulls_and_values_in_one_row(conn_params: ConnParams) -> None:
|
|
"""Mixed NULL + non-NULL columns in one row — proves per-column null detection."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"CREATE TEMP TABLE t_mix (a INTEGER, b VARCHAR(20), c FLOAT, d BIGINT)"
|
|
)
|
|
cur.execute("INSERT INTO t_mix VALUES (?, ?, ?, ?)", (1, None, 3.14, None))
|
|
cur.execute("INSERT INTO t_mix VALUES (?, ?, ?, ?)", (None, "two", None, 200))
|
|
cur.execute("SELECT a, b, c, d FROM t_mix ORDER BY a NULLS LAST")
|
|
assert cur.fetchall() == [(1, None, 3.14, None), (None, "two", None, 200)]
|