"""Regression tests for LVARCHAR tuple framing, reported 2026-08-31. Two independent framing errors, both of which shifted every column that followed an LVARCHAR: 1. **A phantom pad byte.** We appended an even-byte pad when the value length was odd. There is no pad — the next column begins immediately after the last content byte. 2. **A missing length on NULL.** We returned as soon as the null indicator said NULL, leaving the 4-byte length field unread. The length is part of the envelope and is always present. Both survived a 247-test suite because the only LVARCHAR fixture used ``'lv value'`` — 8 characters, even, and never NULL. Neither faulty branch ever executed. The tests below therefore vary length parity and nullness deliberately, and always place a column *after* the LVARCHAR, because the damage lands downstream: a trailing LVARCHAR can be mis-sized with no visible effect. Wire evidence (Informix 12.10) for INT8 / LVARCHAR / INT8: 'odd' -> 00 | 00 00 00 03 | 6f 64 64 8 bytes NULL -> 01 | 00 00 00 00 5 bytes '' -> 00 | 00 00 00 00 5 bytes NULL and empty string differ only in the indicator byte. """ from __future__ import annotations import datetime 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, ) # -------------------------------------------------------------------------- # Length parity — the phantom pad byte # -------------------------------------------------------------------------- @pytest.mark.parametrize( "text", [ "", # 0 — even, empty "a", # 1 — ODD "ab", # 2 "abc", # 3 — ODD "PackageRoot", # 11 — ODD, the reported value "lv value", # 8 — the old fixture that hid the bug "x" * 255, # 255 — ODD, spans a length byte boundary "y" * 256, # 256 ], ) def test_lvarchar_length_parity_does_not_shift_next_column( conn_params: ConnParams, text: str ) -> None: """A trailing sentinel column catches any over- or under-read.""" with _connect(conn_params) as conn: cur = conn.cursor() cur.execute( "CREATE TEMP TABLE t_lv_parity " "(a INT8 NOT NULL, s LVARCHAR(512), b INT8 NOT NULL, c VARCHAR(8))" ) cur.execute( "INSERT INTO t_lv_parity VALUES (?, ?, ?, ?)", (2001, text, 10, "tail"), ) cur.execute("SELECT a, s, b, c FROM t_lv_parity") assert cur.fetchone() == (2001, text, 10, "tail") def test_odd_length_lvarchar_reproduces_the_report( conn_params: ConnParams, ) -> None: """The exact failure shape: INT8 decoded as its own value shifted one byte left (10 -> 2560), and the following string losing a character.""" with _connect(conn_params) as conn: cur = conn.cursor() cur.execute( "CREATE TEMP TABLE t_lv_report " "(a INT8 NOT NULL, k LVARCHAR(512) NOT NULL," " b INT8 NOT NULL, v LVARCHAR(1024))" ) cur.execute( "INSERT INTO t_lv_report VALUES (?, ?, ?, ?)", (2001, "PackageRoot", 10, "/content/package"), ) cur.execute("SELECT a, k, b, v FROM t_lv_report") row = cur.fetchone() assert row == (2001, "PackageRoot", 10, "/content/package") assert row[2] != 2560, "INT8 shifted one byte left" assert row[3].startswith("/"), "leading character lost" # -------------------------------------------------------------------------- # NULL — the missing length field # -------------------------------------------------------------------------- def test_null_lvarchar_does_not_shift_next_column( conn_params: ConnParams, ) -> None: with _connect(conn_params) as conn: cur = conn.cursor() cur.execute( "CREATE TEMP TABLE t_lv_null " "(a INT8 NOT NULL, s LVARCHAR(512), b INT8 NOT NULL)" ) cur.execute("INSERT INTO t_lv_null VALUES (?, ?, ?)", (3001, None, 20)) cur.execute("SELECT a, s, b FROM t_lv_null") assert cur.fetchone() == (3001, None, 20) def test_null_and_empty_lvarchar_are_distinguished( conn_params: ConnParams, ) -> None: """They differ only in the indicator byte, so it's easy to conflate.""" with _connect(conn_params) as conn: cur = conn.cursor() cur.execute( "CREATE TEMP TABLE t_lv_ne (k INT, s LVARCHAR(64), tail INT)" ) cur.execute("INSERT INTO t_lv_ne VALUES (?, ?, ?)", (1, None, 111)) cur.execute("INSERT INTO t_lv_ne VALUES (?, ?, ?)", (2, "", 222)) cur.execute("SELECT k, s, tail FROM t_lv_ne ORDER BY k") assert cur.fetchall() == [(1, None, 111), (2, "", 222)] def test_consecutive_null_lvarchars(conn_params: ConnParams) -> None: """Each NULL under-read by 4 bytes, so several in a row compound.""" with _connect(conn_params) as conn: cur = conn.cursor() cur.execute( "CREATE TEMP TABLE t_lv_many_null " "(a INT8 NOT NULL, s1 LVARCHAR(256), s2 LVARCHAR(256)," " s3 LVARCHAR(256), b INT8 NOT NULL)" ) cur.execute( "INSERT INTO t_lv_many_null VALUES (?, ?, ?, ?, ?)", (4001, None, None, None, 40), ) cur.execute("SELECT a, s1, s2, s3, b FROM t_lv_many_null") assert cur.fetchone() == (4001, None, None, None, 40) # -------------------------------------------------------------------------- # Wide, mixed shape — several LVARCHARs interleaved with other types # -------------------------------------------------------------------------- _WIDE_COLS = [ "tag", "key_txt", "def_txt", "cur_txt", "note_txt", "ver", "made_by", "ident", "made_on", ] _WIDE_ROW = ( "Gadget", "PackageRoot", # 11 — ODD None, # NULL "/content/package", # 16 "z", # 1 — ODD 77, "maker", 3001, datetime.datetime(2026, 8, 31, 12, 30, 15, 120000), ) def _make_wide(cur) -> None: cur.execute( "CREATE TEMP TABLE t_lv_wide (" " tag VARCHAR(32) NOT NULL," " key_txt LVARCHAR(512) NOT NULL," " def_txt LVARCHAR(1024)," " cur_txt LVARCHAR(1024)," " note_txt LVARCHAR(1024)," " ver INT8 DEFAULT 0 NOT NULL," " made_by VARCHAR(100)," " ident INT8 NOT NULL," " made_on DATETIME YEAR TO FRACTION(5))" ) cur.execute( f"INSERT INTO t_lv_wide VALUES ({', '.join(['?'] * len(_WIDE_COLS))})", _WIDE_ROW, ) def test_wide_mixed_row(conn_params: ConnParams) -> None: with _connect(conn_params) as conn: cur = conn.cursor() _make_wide(cur) cur.execute(f"SELECT {', '.join(_WIDE_COLS)} FROM t_lv_wide") assert cur.fetchone() == _WIDE_ROW @pytest.mark.parametrize("shift", range(len(_WIDE_COLS))) def test_wide_row_survives_column_reordering( conn_params: ConnParams, shift: int ) -> None: """The reporter isolated this by reordering columns — values were correct first in the list and wrong later on. Rotating the projection exercises every position for every type.""" with _connect(conn_params) as conn: cur = conn.cursor() _make_wide(cur) order = _WIDE_COLS[shift:] + _WIDE_COLS[:shift] cur.execute(f"SELECT {', '.join(order)} FROM t_lv_wide") expected = tuple(_WIDE_ROW[_WIDE_COLS.index(c)] for c in order) assert cur.fetchone() == expected def test_select_star_wide_row(conn_params: ConnParams) -> None: """``SELECT *`` raised IndexError once enough LVARCHARs accumulated.""" with _connect(conn_params) as conn: cur = conn.cursor() _make_wide(cur) cur.execute("SELECT * FROM t_lv_wide") row = cur.fetchone() names = [d[0] for d in cur.description] assert row == tuple(_WIDE_ROW[_WIDE_COLS.index(n)] for n in names) def test_repeated_lvarchar_columns(conn_params: ConnParams) -> None: """Selecting the same LVARCHAR twice doubles any per-column drift.""" with _connect(conn_params) as conn: cur = conn.cursor() _make_wide(cur) cur.execute( "SELECT ident, key_txt, ident, key_txt, ver FROM t_lv_wide" ) assert cur.fetchone() == (3001, "PackageRoot", 3001, "PackageRoot", 77)