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.
145 lines
5.0 KiB
Python
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
|