Type-matrix fuzzer, and the three bugs it found (2026.08.31.1)

Six framing bugs had reached users across three reports. Rather than
wait for a seventh, this adds a harness built to find that class of bug.
It immediately found three more, one of which hangs the connection.

BOOLEAN could not be bound as a parameter at all. _encode_bool emitted
type code 45, which the server does not accept as a bind type — it
simply stopped responding, so any execute() with a bool parameter hung
until the read timeout, or forever without one. Type 41 (how BOOLEAN is
*described* in results) hangs identically; descriptor type and bind type
are not interchangeable. Now binds 't'/'f' as CHAR and lets the server
cast, mirroring Informix's own literal syntax. A hang is worse than
wrong data, and this shipped in every release claiming BOOLEAN support.

Unscaled DECIMAL was read one byte short, shifting every following
column. Width is (precision + (scale & 1) + 3) // 2 per
IfxColumnInfo.adjustedColumnLength; we dropped the `scale & 1` term.
That only matters when precision is even and scale is odd — and an
unscaled DECIMAL(p) reports scale 255. DECIMAL(16) is 10 bytes, not 9.
Verified on the wire for ten DECIMAL/MONEY shapes. The same formula
governs DATETIME and INTERVAL, whose qualifier parity tracks their digit
count, so those agreed by coincidence; all four now share one helper
taken from the reference.

NULL CHAR/NCHAR came back as '', indistinguishable from an empty column,
so `WHERE c IS NULL` disagreed with what the driver returned. The wire
distinguishes them plainly — NULL is a leading 0x00, empty is all
spaces.

The harness (tests/test_type_matrix.py) encodes three principles, each
derived from how a real bug escaped:

  1. Always put a column after the value under test. Every bug so far
     corrupted the NEXT column; a trailing value can be mis-sized
     invisibly. Every case ends in a sentinel, and projections rotate.
  2. Vary the data, not just the type. Branch coverage is worthless if
     no value takes the branch — the LVARCHAR fixture was 'lv value',
     8 chars, even, never NULL, so both broken branches sat unexecuted
     through 247 tests.
  3. Use an oracle our codecs don't share. A Python round-trip cannot
     catch a symmetric encode/decode bug: our DATETIME encoder wrote
     zeros and our decoder read them back in perfect agreement. Asking
     the server to render the stored value breaks that symmetry.

Exhaustive over the corpus plus seeded random multi-type rows, so
failures reproduce.

326/326 integration on 15, 14.10 and 12.10 (was 281). Fuzzer reports
clean over 80-round runs at several seeds and widths on all three.
This commit is contained in:
Ryan Malloy 2026-08-31 15:48:48 -06:00
parent 5c6991efba
commit 82ca931f32
7 changed files with 457 additions and 22 deletions

View File

@ -2,6 +2,42 @@
All notable changes to `informix-db`. Versioning is [CalVer](https://calver.org/) — `YYYY.MM.DD` for date-based releases, `YYYY.MM.DD.N` for same-day post-releases per PEP 440.
## 2026.08.31.1 — Three more bugs, found by a fuzzer instead of a user
Six framing bugs had reached users across three reports. Rather than wait for a seventh, this release adds a harness built specifically to find that class of bug — and it immediately found three more, one of which **hangs the connection**.
### What it found
**`BOOLEAN` could not be bound as a parameter at all.** `_encode_bool` emitted type code 45, which the server does not accept as a bind type. It simply stopped responding, so *any* `execute()` with a `bool` parameter hung until the read timeout — or forever, if none was set. Type 41 (how BOOLEAN is *described* in results) hangs identically; the descriptor type and the bind type are not interchangeable. We now bind `'t'`/`'f'` as CHAR and let the server cast, mirroring Informix's own literal syntax.
This one is worth dwelling on: a hang is worse than wrong data, and it had shipped in every release that claimed BOOLEAN support.
**Unscaled `DECIMAL` was read one byte short**, shifting every following column. The width formula is `(precision + (scale & 1) + 3) // 2` per `IfxColumnInfo.adjustedColumnLength`; we had dropped the `scale & 1` term. That only matters when precision is even and scale is odd — and an unscaled `DECIMAL(p)` reports scale **255**. So `DECIMAL(16)` is 10 bytes on the wire, not 9. Verified against the wire for ten DECIMAL/MONEY shapes.
The same formula governs DATETIME and INTERVAL, whose qualifier parity happens to track their digit count, so those agreed by coincidence. All four types now share one helper taken from the reference — one rule beats two that coincide for reasons nobody wrote down.
**NULL `CHAR`/`NCHAR` came back as `''`**, indistinguishable from a genuinely empty column, so `WHERE c IS NULL` disagreed with what the driver returned. The wire distinguishes them plainly:
```
'ab' -> 61 62 20 20 20 20
'' -> 20 20 20 20 20 20 all spaces
NULL -> 00 20 20 20 20 20 leading nul
```
### The harness
`tests/test_type_matrix.py` encodes three principles, each derived from how a real bug escaped:
1. **Always put a column after the value under test.** Every bug so far corrupted the *next* column; a trailing value can be mis-sized with no visible effect. Every case now ends in a sentinel column, and projections are rotated so each type is exercised in each position.
2. **Vary the data, not just the type.** Branch coverage means nothing if no value takes the branch. The LVARCHAR fixture was `'lv value'` — 8 characters, even, never NULL — so both of its broken branches sat unexecuted through 247 tests. The corpus now carries odd/even lengths, empty vs NULL, min/max, negative, scaled vs unscaled.
3. **Use an oracle our own codecs don't share.** A Python round-trip cannot catch a symmetric encode/decode bug: our DATETIME encoder wrote zeros and our decoder read them back in perfect agreement. Asking the *server* to render the stored value as text breaks that symmetry, and that check is what would have caught the DATETIME bug on day one.
It runs exhaustively over the corpus plus seeded random multi-type rows, so failures are reproducible.
### Verified
**326/326** integration tests on each of 15.0.1.0.3DE, 14.10.FC7W1DE, and 12.10.FC12W1DE — up from 281. The fuzzer additionally reports clean over 80-round runs at several seeds and widths on all three servers.
## 2026.08.31 — Fix LVARCHAR tuple framing; DATETIME fractions on bind
More data corruption, from the same field report that produced `2026.05.08.2`. **If your schema has `LVARCHAR` columns, upgrade** — anything selected after one could be wrong, and `2026.08.27` is not safe.

View File

@ -27,7 +27,7 @@ Imports as `informix_db` (the distribution name is `informix-driver` because the
**0 critical, 0 high, 0 medium audit findings remain.** Every architectural change went through a Margaret Hamilton-style review focused on silent-failure modes, recovery paths, and documented invariants. Each documented invariant is paired with either a runtime guard or a CI tripwire test.
**Test coverage:** 400+ tests across unit / integration / benchmark suites. The integration suite passes 281/281 against **each** of Informix 12.10, 14.10, and 15 — `make test-matrix` runs all three.
**Test coverage:** 400+ tests across unit / integration / benchmark suites. The integration suite passes 326/326 against **each** of Informix 12.10, 14.10, and 15 — `make test-matrix` runs all three.
## Quick start
@ -154,9 +154,9 @@ All three tested against the official IBM developer-edition Docker images, full
| Server | Image | Integration suite |
|---|---|---|
| **15.0.1.0.3DE** | `icr.io/informix/informix-developer-database` | **281 / 281** |
| **14.10.FC7W1DE** | `ibmcom/informix-developer-database` | **281 / 281** |
| **12.10.FC12W1DE** | `ibmcom/informix-developer-database` | **281 / 281** |
| **15.0.1.0.3DE** | `icr.io/informix/informix-developer-database` | **326 / 326** |
| **14.10.FC7W1DE** | `ibmcom/informix-developer-database` | **326 / 326** |
| **12.10.FC12W1DE** | `ibmcom/informix-developer-database` | **326 / 326** |
Reproduce the whole matrix:

View File

@ -76,7 +76,7 @@ Every finding from a system-wide failure-mode audit (data correctness, wire safe
**0 critical, 0 high, 0 medium audit findings remain.** Every architectural change went through a Margaret Hamilton-style review focused on silent-failure modes, recovery paths, and documented invariants. Each documented invariant is paired with either a runtime guard or a CI tripwire test.
400+ tests across unit / integration / benchmark suites. The integration suite runs against the official IBM Informix Developer Edition Docker images and passes 247/247 on **all three** of 12.10.FC12W1DE, 14.10.FC7W1DE, and 15.0.1.0.3DE — `make test-matrix` runs the lot.
400+ tests across unit / integration / benchmark suites. The integration suite runs against the official IBM Informix Developer Edition Docker images and passes 326/326 on **all three** of 12.10.FC12W1DE, 14.10.FC7W1DE, and 15.0.1.0.3DE — `make test-matrix` runs the lot.
That matrix exists because it turned out to be needed. A user reported corrupted result sets on Informix 12; the cause was three framing bugs that affected every version including the one we tested against, and they'd survived because no fixture used the affected types. Testing one server and inferring the rest is how that happens.

View File

@ -1,6 +1,6 @@
[project]
name = "informix-driver"
version = "2026.08.31"
version = "2026.08.31.1"
description = "Pure-Python driver for IBM Informix IDS — speaks the SQLI wire protocol over raw sockets. No CSDK, no JVM, no native libraries."
readme = "README.md"
license = { text = "MIT" }

View File

@ -247,6 +247,32 @@ _COMPOSITE_UDT_TYPES = frozenset({
_NUMERIC_TYPES = frozenset({_TC_DECIMAL, _TC_MONEY})
def _packed_width(encoded_length: int) -> int:
"""On-wire byte width for the four types whose ``encoded_length``
packs two fields into ``(high << 8) | low``: DECIMAL, MONEY,
DATETIME and INTERVAL.
Mirrors ``IfxColumnInfo.adjustedColumnLength`` / ``IfxDecimal.decLength``::
((ColLength >> 8 & 0xFF) + (ColLength & 0xFF & 1) + 3) / 2
The ``low & 1`` term is the part that is easy to miss, and dropping
it is wrong exactly when the high byte is even and the low byte is
odd. For DECIMAL/MONEY the low byte is the scale, and an unscaled
``DECIMAL(p)`` reports scale **255** odd so every floating
DECIMAL with even precision was read one byte short, desyncing every
column after it. ``DECIMAL(16)`` is 10 bytes on the wire, not 9.
For DATETIME and INTERVAL the low byte is the qualifier, whose
parity happens to track the digit count, so the two formulas agree
there in practice. They are unified here anyway: one rule taken from
the reference beats two that coincide for reasons nobody wrote down.
"""
high = (encoded_length >> 8) & 0xFF
low = encoded_length & 0xFF
return (high + (low & 1) + 3) // 2
# Types that are fixed-width on the wire AND have a registered decoder
# in ``FIXED_WIDTHS``: SMALLINT, INT, SERIAL, SMFLOAT, FLOAT, BIGINT,
# BIGSERIAL, DATE, BOOL. These are the most common types in any real
@ -319,20 +345,17 @@ def compile_column_readers(columns: list[ColumnInfo]) -> list[tuple]:
continue
if tc in _NUMERIC_TYPES:
precision = (col.encoded_length >> 8) & 0xFF
width = (precision + 1) // 2 + 1
width = _packed_width(col.encoded_length)
readers.append((_RK_DECIMAL, width, DECODERS[tc]))
continue
if tc == _TC_DATETIME:
digit_count = (col.encoded_length >> 8) & 0xFF
width = (digit_count + 1) // 2 + 1
width = _packed_width(col.encoded_length)
readers.append((_RK_DATETIME, width, col.encoded_length))
continue
if tc == _TC_INTERVAL:
digit_count = (col.encoded_length >> 8) & 0xFF
width = (digit_count + 1) // 2 + 1
width = _packed_width(col.encoded_length)
readers.append((_RK_INTERVAL, width, col.encoded_length))
continue
@ -874,8 +897,7 @@ def parse_tuple_payload(
# the high byte of encoded_length (packed as (precision << 8) | scale).
# Per IfxRowColumn.loadColumnData and IfxToJavaDecimal byte sizing.
if tc in _NUMERIC_TYPES:
precision = (col.encoded_length >> 8) & 0xFF
width = (precision + 1) // 2 + 1
width = _packed_width(col.encoded_length)
raw = payload[offset:offset + width]
offset += width
try:
@ -889,8 +911,7 @@ def parse_tuple_payload(
# (start_TU << 4) | end_TU). The decoder needs the qualifier too,
# so we call it directly here rather than via the dispatch.
if tc == _TC_DATETIME:
digit_count = (col.encoded_length >> 8) & 0xFF
width = (digit_count + 1) // 2 + 1
width = _packed_width(col.encoded_length)
raw = payload[offset:offset + width]
offset += width
values.append(_decode_datetime(raw, col.encoded_length))
@ -903,8 +924,7 @@ def parse_tuple_payload(
# qualifier is needed at decode time, so we bypass the generic
# dispatch.
if tc == _TC_INTERVAL:
digit_count = (col.encoded_length >> 8) & 0xFF
width = (digit_count + 1) // 2 + 1
width = _packed_width(col.encoded_length)
raw = payload[offset:offset + width]
offset += width
values.append(_decode_interval(raw, col.encoded_length))

View File

@ -204,8 +204,25 @@ def _decode_float(raw: bytes) -> float | None:
return _UNPACK_DOUBLE(raw)[0]
def _decode_char(raw: bytes, encoding: str = "iso-8859-1") -> str:
"""Strip trailing spaces (CHAR is space-padded to declared length)."""
def _decode_char(raw: bytes, encoding: str = "iso-8859-1") -> str | None:
"""Decode CHAR / NCHAR: fixed width, space-padded to the declared length.
A leading ``0x00`` is Informix's NULL marker for these types. Without
that check a NULL CHAR came back as ``''``, indistinguishable from a
genuinely empty one and ``WHERE c IS NULL`` disagreeing with what
the driver hands you is a nasty thing to debug. Wire evidence for
``CHAR(6)``::
'ab' -> 61 62 20 20 20 20
'' -> 20 20 20 20 20 20 all spaces
NULL -> 00 20 20 20 20 20 leading nul
The two are distinguishable, so we distinguish them. A real value
cannot begin with a nul: character data has no use for one, and the
server reserves it precisely as this marker.
"""
if raw[:1] == b"\x00":
return None
return raw.rstrip(b" \x00").decode(encoding)
@ -744,8 +761,28 @@ def _encode_float(value: float) -> EncodedParam:
def _encode_bool(value: bool) -> EncodedParam:
"""Encode a Python bool as Informix BOOLEAN (type=45, 1 byte)."""
return (45, 0, b"\x01" if value else b"\x00")
"""Encode a Python bool by binding the literal ``'t'`` / ``'f'``.
Informix BOOLEAN has no bindable binary form we could find. This used
to send ``(45, 0, b"\\x01")``, and type 45 is not something the server
accepts as a bind type it simply stopped responding, so any
``execute`` with a bool parameter **hung until the read timeout**, or
forever if none was set. Type 41 (which is how BOOLEAN is *described*
in results) hangs the same way; the descriptor type and the bind type
are not interchangeable.
Sending ``'t'`` / ``'f'`` as CHAR and letting the server cast on
assignment is what works, and it mirrors Informix's own literal
syntax (``INSERT ... VALUES ('t')``). It also matches how
:func:`_encode_str` already leans on server-side conversion rather
than trying to match the destination column's exact type.
Note the asymmetry with the read path, which is correct and stays
as it is: BOOLEAN comes *back* as UDTFIXED (41) wrapped in a UDT
envelope whose payload byte is ``0x74``/``0x66`` ASCII ``t``/``f``,
the same characters we send here.
"""
return _encode_str("t" if value else "f")
def _encode_date(value: datetime.date) -> EncodedParam:

342
tests/test_type_matrix.py Normal file
View File

@ -0,0 +1,342 @@
"""Type-matrix regression tests, and the fuzzer that found them.
Six framing bugs reached users before this file existed. Every one shared
a shape: the value under test decoded fine on its own, and quietly
corrupted whatever came *after* it. They were missed not because branches
went unexercised but because the fixtures never supplied data that took
them the LVARCHAR fixture was ``'lv value'``, eight characters, even,
never NULL, so neither of its two broken branches ever ran.
Three principles follow, and every test here applies them:
1. **Always put a column after the value under test.** A trailing column
can be mis-sized with no visible effect. The sentinel is the detector.
2. **Vary the data, not just the type.** Odd vs even length, empty vs
NULL, min/max, negative, unscaled vs scaled.
3. **Use an oracle our codecs don't share.** A Python round-trip cannot
catch a symmetric encode/decode bug our DATETIME encoder wrote zeros
and our decoder read them back in perfect agreement. Asking the server
to render the stored value as text breaks that symmetry.
The three bugs in the first section were found by the fuzzer at the
bottom of this file, not by a person.
"""
from __future__ import annotations
import datetime
import decimal
import random
import pytest
import informix_db
from tests.conftest import ConnParams
pytestmark = pytest.mark.integration
SENTINEL = 424242
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,
# Bounded so a wire desync fails the test instead of hanging it.
read_timeout=20.0,
)
# ---------------------------------------------------------------------------
# 1. BOOLEAN could not be used as a bind parameter at all
# ---------------------------------------------------------------------------
# _encode_bool emitted type code 45, which the server does not accept as a
# bind type: it simply stopped responding. Any execute() with a bool
# parameter hung until the read timeout, or forever without one. Type 41
# (how BOOLEAN is *described* in results) hangs identically — descriptor
# type and bind type are not interchangeable. Binding 't'/'f' as CHAR and
# letting the server cast is what works.
@pytest.mark.parametrize("value", [True, False, None])
def test_boolean_bind_round_trip(
conn_params: ConnParams, value: bool | None
) -> None:
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("CREATE TEMP TABLE t_bool_bind (b BOOLEAN, s INT)")
cur.execute("INSERT INTO t_bool_bind VALUES (?, ?)", (value, SENTINEL))
cur.execute("SELECT b, s FROM t_bool_bind")
assert cur.fetchone() == (value, SENTINEL)
def test_boolean_bind_in_where_clause(conn_params: ConnParams) -> None:
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("CREATE TEMP TABLE t_bool_where (b BOOLEAN, k INT)")
cur.execute("INSERT INTO t_bool_where VALUES (?, ?)", (True, 1))
cur.execute("INSERT INTO t_bool_where VALUES (?, ?)", (False, 2))
cur.execute("SELECT k FROM t_bool_where WHERE b = ?", (True,))
assert cur.fetchall() == [(1,)]
cur.execute("SELECT k FROM t_bool_where WHERE b = ?", (False,))
assert cur.fetchall() == [(2,)]
def test_boolean_bind_stores_real_boolean(conn_params: ConnParams) -> None:
"""Independent oracle: the server must agree it stored a BOOLEAN, not
the string we transported it as."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("CREATE TEMP TABLE t_bool_oracle (b BOOLEAN)")
cur.execute("INSERT INTO t_bool_oracle VALUES (?)", (True,))
cur.execute("SELECT b::CHAR(1) FROM t_bool_oracle")
assert cur.fetchone() == ("t",)
# ---------------------------------------------------------------------------
# 2. Unscaled DECIMAL was read one byte short
# ---------------------------------------------------------------------------
# Width is ((precision) + (scale & 1) + 3) // 2, per IfxColumnInfo's
# adjustedColumnLength. We omitted the `scale & 1` term, which only
# matters when precision is even and scale is odd — and an unscaled
# DECIMAL(p) reports scale 255. DECIMAL(16) is 10 bytes, not 9.
@pytest.mark.parametrize(
("ddl", "value"),
[
("DECIMAL(16)", decimal.Decimal("1234567890123456")),
("DECIMAL(16)", decimal.Decimal("-1")),
("DECIMAL(16)", None),
("DECIMAL(4)", decimal.Decimal("1234")),
("DECIMAL(20)", decimal.Decimal("12345678901234567890")),
("DECIMAL(8,2)", decimal.Decimal("12345.67")),
("DECIMAL(10,4)", decimal.Decimal("123456.7890")),
("DECIMAL(5,0)", decimal.Decimal("12345")),
("DECIMAL(1,0)", decimal.Decimal("7")),
("DECIMAL(32,10)", decimal.Decimal("1234567890.0123456789")),
("MONEY(10,2)", decimal.Decimal("1234.56")),
("MONEY(10,2)", decimal.Decimal("-1234.56")),
],
)
def test_decimal_width_does_not_shift_next_column(
conn_params: ConnParams, ddl: str, value: decimal.Decimal | None
) -> None:
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute(f"CREATE TEMP TABLE t_dec_w (d {ddl}, s INT)")
cur.execute("INSERT INTO t_dec_w VALUES (?, ?)", (value, SENTINEL))
cur.execute("SELECT d, s FROM t_dec_w")
got = cur.fetchone()
assert got[1] == SENTINEL, f"{ddl} mis-sized; sentinel came back {got[1]}"
if value is None:
assert got[0] is None
else:
assert got[0] == value
def test_packed_width_matches_the_reference_formula() -> None:
"""Unit-level guard on the formula itself, including the term that was
missing. Values verified against the wire."""
from informix_db._resultset import _packed_width
assert _packed_width((8 << 8) | 2) == 5 # DECIMAL(8,2)
assert _packed_width((16 << 8) | 255) == 10 # DECIMAL(16), unscaled
assert _packed_width((10 << 8) | 4) == 6 # DECIMAL(10,4)
assert _packed_width((5 << 8) | 0) == 4 # DECIMAL(5,0)
assert _packed_width((32 << 8) | 10) == 17 # DECIMAL(32,10)
assert _packed_width((1 << 8) | 0) == 2 # DECIMAL(1,0)
assert _packed_width((14 << 8) | 10) == 8 # DATETIME YEAR TO SECOND
assert _packed_width((19 << 8) | 15) == 11 # DATETIME .. FRACTION(5)
# ---------------------------------------------------------------------------
# 3. NULL CHAR / NCHAR came back as an empty string
# ---------------------------------------------------------------------------
# The NULL marker is a leading 0x00; an empty CHAR is all spaces. They are
# distinguishable on the wire, so conflating them made `IS NULL` disagree
# with what the driver returned.
@pytest.mark.parametrize("ddl", ["CHAR(6)", "NCHAR(6)"])
def test_null_char_is_none_not_empty_string(
conn_params: ConnParams, ddl: str
) -> None:
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute(f"CREATE TEMP TABLE t_char_null (k INT, c {ddl})")
cur.execute("INSERT INTO t_char_null VALUES (?, ?)", (1, None))
cur.execute("INSERT INTO t_char_null VALUES (?, ?)", (2, ""))
cur.execute("INSERT INTO t_char_null VALUES (?, ?)", (3, "ab"))
cur.execute("SELECT k, c FROM t_char_null ORDER BY k")
assert cur.fetchall() == [(1, None), (2, ""), (3, "ab")]
@pytest.mark.parametrize("ddl", ["CHAR(6)", "NCHAR(6)"])
def test_null_char_agrees_with_is_null(
conn_params: ConnParams, ddl: str
) -> None:
"""The server's own view is the oracle: what it calls NULL, we must
return as None."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute(f"CREATE TEMP TABLE t_char_isnull (k INT, c {ddl})")
cur.execute("INSERT INTO t_char_isnull VALUES (?, ?)", (1, None))
cur.execute("INSERT INTO t_char_isnull VALUES (?, ?)", (2, ""))
cur.execute("SELECT k FROM t_char_isnull WHERE c IS NULL")
server_nulls = {r[0] for r in cur.fetchall()}
cur.execute("SELECT k, c FROM t_char_isnull ORDER BY k")
driver_nulls = {k for k, c in cur.fetchall() if c is None}
assert driver_nulls == server_nulls
# ---------------------------------------------------------------------------
# 4. The fuzzer itself, with fixed seeds so it is reproducible in CI
# ---------------------------------------------------------------------------
_ODD = "abcdefghijk" # 11
_EVEN = "abcdefghij" # 10
# (name, DDL, edge values). Values are chosen to flip framing branches.
_CORPUS: list[tuple[str, str, list]] = [
("smallint", "SMALLINT", [0, 1, -1, 32767, -32767, None]),
("int", "INT", [0, -1, 2147483647, None]),
("int8", "INT8", [0, 1, -1, 123456789012, -123456789012, None]),
("bigint", "BIGINT", [0, -1, 2**62, None]),
("float", "FLOAT", [0.0, 3.141592653589793, -1.5, None]),
("smallfloat", "SMALLFLOAT", [0.0, 2.5, None]),
("dec_scaled", "DECIMAL(8,2)", [
decimal.Decimal("12345.67"), decimal.Decimal("-98.76"), None,
]),
("dec_float", "DECIMAL(16)", [
decimal.Decimal("1234567890123456"), decimal.Decimal("-1"), None,
]),
("money", "MONEY(10,2)", [decimal.Decimal("1234.56"), None]),
("char", "CHAR(12)", ["", "a", _ODD, _EVEN, None]),
("varchar", "VARCHAR(64)", ["", "a", _ODD, _EVEN, None]),
("nchar", "NCHAR(12)", ["", "a", _ODD, None]),
("nvarchar", "NVARCHAR(64)", ["", "a", _ODD, None]),
("lvarchar", "LVARCHAR(1024)", [
"", "a", "ab", _ODD, _EVEN, "x" * 255, "y" * 256, None,
]),
("date", "DATE", [datetime.date(1899, 12, 31), datetime.date(2026, 8, 31), None]),
("dt_sec", "DATETIME YEAR TO SECOND", [
datetime.datetime(2026, 8, 31, 12, 30, 15), None,
]),
("dt_frac", "DATETIME YEAR TO FRACTION(5)", [
datetime.datetime(2026, 8, 31, 12, 30, 15),
datetime.datetime(2026, 8, 31, 12, 30, 15, 120000),
None,
]),
("interval", "INTERVAL DAY(5) TO SECOND", [
datetime.timedelta(days=10, hours=4, minutes=30, seconds=15),
datetime.timedelta(0),
None,
]),
("boolean", "BOOLEAN", [True, False, None]),
]
_BY_NAME = {c[0]: c for c in _CORPUS}
def _check_row(cur, names: list[str], values: list) -> None:
"""Create, insert, then read back from several projections. The
sentinel column trails everything so any mis-sizing surfaces."""
cols = [f"c{i}" for i in range(len(names))]
ddl = ", ".join(f"{c} {_BY_NAME[n][1]}" for c, n in zip(cols, names, strict=True))
cur.execute(f"CREATE TEMP TABLE t_fuzz ({ddl}, sentinel INT)")
try:
marks = ", ".join(["?"] * (len(cols) + 1))
cur.execute(f"INSERT INTO t_fuzz VALUES ({marks})", (*values, SENTINEL))
expected = dict(zip(cols, values, strict=True))
expected["sentinel"] = SENTINEL
every = [*cols, "sentinel"]
cur.execute(f"SELECT {', '.join(every)} FROM t_fuzz")
assert dict(zip(every, cur.fetchone(), strict=True)) == expected, (
f"straight projection: {list(zip(names, values, strict=True))}"
)
for shift in range(1, min(len(every), 4)):
order = every[shift:] + every[:shift]
cur.execute(f"SELECT {', '.join(order)} FROM t_fuzz")
got = dict(zip(order, cur.fetchone(), strict=True))
assert got == expected, (
f"rotation {shift}: {list(zip(names, values, strict=True))}"
)
cur.execute("SELECT * FROM t_fuzz")
got_names = [d[0] for d in cur.description]
assert dict(zip(got_names, cur.fetchone(), strict=True)) == expected, (
f"SELECT *: {list(zip(names, values, strict=True))}"
)
finally:
cur.execute("DROP TABLE t_fuzz")
@pytest.mark.parametrize("type_name", [c[0] for c in _CORPUS])
def test_every_edge_value_with_a_trailing_sentinel(
conn_params: ConnParams, type_name: str
) -> None:
"""Exhaustive over the corpus: each type, each edge value, always with
a column behind it."""
with _connect(conn_params) as conn:
cur = conn.cursor()
for value in _BY_NAME[type_name][2]:
_check_row(cur, [type_name], [value])
@pytest.mark.parametrize("seed", [1, 2, 3])
def test_random_mixed_rows(conn_params: ConnParams, seed: int) -> None:
"""Random multi-type rows. Seeded so a failure is reproducible; the
point is combinations no one thought to write by hand."""
rng = random.Random(seed)
with _connect(conn_params) as conn:
cur = conn.cursor()
for _ in range(25):
names = [rng.choice(_CORPUS)[0] for _ in range(5)]
values = [rng.choice(_BY_NAME[n][2]) for n in names]
_check_row(cur, names, values)
def test_server_agrees_with_what_we_bound(conn_params: ConnParams) -> None:
"""The independent oracle. Compare against the server's own rendering
rather than our own decoder, so a symmetric encode/decode bug cannot
hide which is exactly how DATETIME lost its fractions."""
checks: list[tuple[str, object, str]] = [
("INT8", 123456789012, "123456789012"),
("INT8", -123456789012, "-123456789012"),
("BIGINT", 2**62, str(2**62)),
("VARCHAR(64)", "PackageRoot", "PackageRoot"),
("LVARCHAR(64)", "PackageRoot", "PackageRoot"),
("NVARCHAR(64)", "odd", "odd"),
("BOOLEAN", True, "t"),
("BOOLEAN", False, "f"),
("DATETIME YEAR TO FRACTION(5)",
datetime.datetime(2026, 8, 31, 12, 30, 15, 120000),
"2026-08-31 12:30:15.12000"),
("DATETIME YEAR TO SECOND",
datetime.datetime(2026, 8, 31, 12, 30, 15),
"2026-08-31 12:30:15"),
("DATE", datetime.date(2026, 8, 31), "2026-08-31"),
]
with _connect(conn_params) as conn:
cur = conn.cursor()
for ddl, value, server_repr in checks:
cur.execute(f"CREATE TEMP TABLE t_oracle (v {ddl})")
try:
cur.execute("INSERT INTO t_oracle VALUES (?)", (value,))
cur.execute("SELECT v::LVARCHAR FROM t_oracle")
(text,) = cur.fetchone()
assert text.strip() == server_repr, (
f"{ddl}: bound {value!r}, server stored {text.strip()!r}, "
f"expected {server_repr!r}"
)
finally:
cur.execute("DROP TABLE t_oracle")