informix-db/tests/test_capabilities.py
Ryan Malloy 9616ddbc0a Fix LVARCHAR tuple framing; DATETIME fractions on bind (2026.08.31)
More data corruption from the same field report as 2026.05.08.2. Anyone
with LVARCHAR columns should upgrade: 2026.08.27 is not safe for them.

Two independent errors in the LVARCHAR envelope, both shifting every
column selected after it.

1. A phantom pad byte. We appended an even-byte pad when the value
   length was odd. There is no pad. Wire capture (12.10), INT8 /
   LVARCHAR / INT8:
     00 01 00 00 07 d1 00 00 00 00   a = INT8 2001
     00                              null indicator
     00 00 00 0b                     length 11
     50 61 63 6b 61 67 65 52 6f 6f 74   "PackageRoot" (odd)
     00 01 00 00 00 0a 00 00 00 00   b = INT8 10, starts immediately

2. A missing length on NULL. We returned as soon as the indicator said
   NULL, leaving its 4-byte length unread. The length belongs to the
   envelope and is always present:
     '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.

Reported symptom: INT8 10 decoding as 2560 (the same value shifted one
byte left), strings losing their first character, and IndexError or
"INT8 payload too short" once the drift ran off the payload. The
reporter isolated it by reordering columns in the projection — right
when first, wrong when later. That's the fingerprint of positional
drift and a genuinely good diagnostic.

Missed by 247 tests because the only LVARCHAR fixture was 'lv value':
8 characters, even, never NULL. Neither faulty branch ever ran. Same
gap shape as last time — code paths covered, the data reaching them
not. New tests vary parity (0,1,2,3,11,255,256), cover NULL and empty
separately, always place a column AFTER the LVARCHAR, and rotate the
projection through every position.

Separately, DATETIME lost sub-second precision on INSERT. The encoder
emitted YEAR TO SECOND unconditionally, so binding a datetime with
microseconds into a FRACTION(n) column stored zeros, silently. Reads
were always right, so it only went missing on the way in. Now widens
to FRACTION(5) when microsecond is non-zero and keeps the original
encoding otherwise, so the well-exercised path stays byte-identical.
Binding into a narrower column still truncates server-side.

Also: server_version reported 9.56 for a 12.10 server, which reads like
a client-SDK version. The login response only carries the internal
protocol version, and documenting that didn't make the name less
misleading. server_version now returns the release via one cached
DBINFO query; server_version_internal returns the raw login string.

281/281 integration on 15, 14.10 and 12.10; 123 unit tests.
2026-08-31 14:26:17 -06:00

145 lines
5.0 KiB
Python

"""Integration tests for live SQ_PROTOCOLS negotiation.
These run against whatever server ``IFX_PORT`` points at, so `make
test-matrix` exercises them on 12.10, 14.10, and 15 in turn.
The important one is ``test_no_violated_assumptions``: this driver
hardcodes several wire-framing choices that SQLI actually negotiates, and
a mismatch corrupts rows silently. That test turns "we assume this" into
"we check this on every supported server".
"""
from __future__ import annotations
import pytest
import informix_db
from informix_db._capabilities import ENHANCED_PROTOCOL_CAP, ServerCapabilities
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_capabilities_are_decoded(conn_params: ConnParams) -> None:
with _connect(conn_params) as conn:
caps = conn.server_capabilities
assert isinstance(caps, ServerCapabilities)
assert caps.raw_mask, "server sent an empty protocols reply"
assert caps.bits
def test_no_violated_assumptions(conn_params: ConnParams) -> None:
"""Every wire-framing shape we hardcode is one the server negotiated.
If this fails, rows are being decoded against the wrong framing and
the fix is to branch on the capability rather than assume it.
"""
with _connect(conn_params) as conn:
caps = conn.server_capabilities
assert caps is not None
assert caps.violated_assumptions() == []
def test_enhanced_protocol_negotiated(conn_params: ConnParams) -> None:
"""Cap_1 is the client's declared protocol level echoed back. Every
supported server accepts 316."""
with _connect(conn_params) as conn:
caps = conn.server_capabilities
assert caps is not None
assert caps.cap_1 == ENHANCED_PROTOCOL_CAP
assert caps.enhanced_protocol
def test_framing_capabilities_present(conn_params: ConnParams) -> None:
with _connect(conn_params) as conn:
caps = conn.server_capabilities
assert caps is not None
assert caps.four_byte_offset
assert caps.varchar_var_len
assert caps.remove_64k_limit
assert caps.usver
def test_server_version_is_exposed(conn_params: ConnParams) -> None:
"""Assert the shape rather than a value so this holds across the matrix."""
with _connect(conn_params) as conn:
version = conn.server_version
assert "Informix" in version
assert "Version" in version
def test_server_version_reports_release_not_protocol_version(
conn_params: ConnParams,
) -> None:
"""A field report flagged ``server_version`` as looking like a
client-SDK version: the login response announces 12.10 servers as 9.56
and 14.10 as 9.59. ``server_version`` now answers with the release; the
raw login string moved to ``server_version_internal``."""
with _connect(conn_params) as conn:
release = conn.server_version
internal = conn.server_version_internal
assert "Informix" in internal
# On 12.10/14.10 these genuinely differ; on 15 they agree.
if "9.5" in internal:
assert release != internal
assert "9.5" not in release, (
f"server_version still reports the protocol version: {release!r}"
)
def test_server_version_is_cached(conn_params: ConnParams) -> None:
"""It costs a round-trip, so repeated access must not repeat it."""
with _connect(conn_params) as conn:
assert conn.server_version == conn.server_version
assert conn._server_version_full is not None
def test_server_version_degrades_without_a_database(
conn_params: ConnParams,
) -> None:
"""The DBINFO lookup needs an open database. With none, the property
must fall back rather than raise — a version lookup should never be
able to break a working connection."""
conn = informix_db.connect(
host=conn_params.host,
port=conn_params.port,
user=conn_params.user,
password=conn_params.password,
database=None,
server=conn_params.server,
connect_timeout=10.0,
read_timeout=10.0,
)
try:
assert isinstance(conn.server_version, str)
finally:
conn.close()
def test_capabilities_survive_multiple_connections(
conn_params: ConnParams,
) -> None:
"""Negotiation happens per-connection; two connections to the same
server must agree."""
with _connect(conn_params) as first, _connect(conn_params) as second:
assert first.server_capabilities is not None
assert second.server_capabilities is not None
assert (
first.server_capabilities.raw_mask
== second.server_capabilities.raw_mask
)
assert first.server_capabilities.bits == second.server_capabilities.bits