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.
152 lines
5.3 KiB
Python
152 lines
5.3 KiB
Python
"""Regression tests for DATETIME sub-second precision on bind.
|
|
|
|
``_encode_datetime`` used to emit YEAR TO SECOND unconditionally, so
|
|
binding a ``datetime`` carrying microseconds into a
|
|
``DATETIME YEAR TO FRACTION(n)`` column stored zeros — silently, with no
|
|
error and no warning. Reads were always fine, which is what made it hard
|
|
to notice: the value only went missing on the way in.
|
|
|
|
It now emits YEAR TO FRACTION(5) when ``microsecond`` is non-zero and
|
|
keeps the original YEAR TO SECOND encoding otherwise.
|
|
|
|
FRACTION(5) is Informix's widest and resolves to 10 µs, so Python's
|
|
sixth microsecond digit is dropped. That's a real limit of the type, not
|
|
a driver choice, and the tests below pin the truncation so it can't drift
|
|
into something worse.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
|
|
import pytest
|
|
|
|
import informix_db
|
|
from informix_db.converters import _encode_datetime
|
|
from tests.conftest import ConnParams
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Encoder unit tests — no server needed
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def test_encoder_uses_year_to_second_without_microseconds() -> None:
|
|
"""The long-exercised path must stay byte-identical."""
|
|
type_code, prec, raw = _encode_datetime(
|
|
datetime.datetime(2026, 8, 31, 12, 30, 15)
|
|
)
|
|
assert type_code == 10
|
|
assert prec == (14 << 8) | 10 # digit_count 14, YEAR..SECOND
|
|
assert raw == b"\x00\x08\xc7\x14\x1a\x08\x1f\x0c\x1e\x0f"
|
|
assert len(raw) == 10 # 2 len + 1 exp + 7 BCD pairs
|
|
|
|
|
|
def test_encoder_widens_to_fraction_when_microseconds_present() -> None:
|
|
type_code, prec, raw = _encode_datetime(
|
|
datetime.datetime(2026, 8, 31, 12, 30, 15, 120000)
|
|
)
|
|
assert type_code == 10
|
|
assert prec == (19 << 8) | 15 # digit_count 19, YEAR..FRACTION(5)
|
|
assert len(raw) == 13 # 2 len + 1 exp + 10 BCD pairs
|
|
# Exponent byte is unchanged: the integer part is still 7 base-100
|
|
# pairs, the fraction just adds three more after the point.
|
|
assert raw[2] == 0xC7
|
|
# Trailing pairs carry 120000 as BCD 12/00/00.
|
|
assert raw[-3:] == b"\x0c\x00\x00"
|
|
|
|
|
|
def test_encoder_pads_fraction_to_six_digits() -> None:
|
|
"""One microsecond must not shift the BCD pairs."""
|
|
_, prec, raw = _encode_datetime(
|
|
datetime.datetime(2026, 8, 31, 12, 30, 15, 1)
|
|
)
|
|
assert prec == (19 << 8) | 15
|
|
assert len(raw) == 13
|
|
assert raw[-3:] == b"\x00\x00\x01" # 000001
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Round-trip against a real server
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.integration
|
|
@pytest.mark.parametrize(
|
|
("microsecond", "expected"),
|
|
[
|
|
(0, 0),
|
|
(120000, 120000),
|
|
(500000, 500000),
|
|
(1, 0), # below FRACTION(5) resolution
|
|
(999999, 999990), # truncated to 5 significant digits
|
|
],
|
|
)
|
|
def test_fraction_round_trip(
|
|
conn_params: ConnParams, microsecond: int, expected: int
|
|
) -> None:
|
|
value = datetime.datetime(2026, 8, 31, 12, 30, 15, microsecond)
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"CREATE TEMP TABLE t_dt_frac "
|
|
"(k INT, t DATETIME YEAR TO FRACTION(5))"
|
|
)
|
|
cur.execute("INSERT INTO t_dt_frac VALUES (?, ?)", (1, value))
|
|
cur.execute("SELECT t FROM t_dt_frac")
|
|
(got,) = cur.fetchone()
|
|
assert got == value.replace(microsecond=expected)
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_fraction_bind_into_year_to_second_column(
|
|
conn_params: ConnParams,
|
|
) -> None:
|
|
"""Widening the bind must not break narrower columns — Informix
|
|
converts between qualifiers on assignment, truncating server-side."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"CREATE TEMP TABLE t_dt_sec (k INT, t DATETIME YEAR TO SECOND)"
|
|
)
|
|
cur.execute(
|
|
"INSERT INTO t_dt_sec VALUES (?, ?)",
|
|
(1, datetime.datetime(2026, 8, 31, 12, 30, 15, 987654)),
|
|
)
|
|
cur.execute("SELECT t FROM t_dt_sec")
|
|
assert cur.fetchone() == (
|
|
datetime.datetime(2026, 8, 31, 12, 30, 15),
|
|
)
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_fraction_survives_alongside_lvarchar(
|
|
conn_params: ConnParams,
|
|
) -> None:
|
|
"""The reported schema pairs FRACTION(5) columns with LVARCHARs."""
|
|
ts = datetime.datetime(2026, 8, 31, 12, 30, 15, 120000)
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"CREATE TEMP TABLE t_dt_lv "
|
|
"(s LVARCHAR(512), t DATETIME YEAR TO FRACTION(5), n INT8 NOT NULL)"
|
|
)
|
|
cur.execute(
|
|
"INSERT INTO t_dt_lv VALUES (?, ?, ?)", ("PackageRoot", ts, 10)
|
|
)
|
|
cur.execute("SELECT s, t, n FROM t_dt_lv")
|
|
assert cur.fetchone() == ("PackageRoot", ts, 10)
|