From 888b8079d302f96526e85b34796e171d271c89dc Mon Sep 17 00:00:00 2001 From: Ryan Malloy Date: Mon, 4 May 2026 12:30:48 -0600 Subject: [PATCH] Phase 6.e: INTERVAL parameter encoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements encoders for datetime.timedelta → INTERVAL DAY(9) TO FRACTION(5) and IntervalYM → INTERVAL YEAR(9) TO MONTH. Both follow the 2-byte-length- prefixed BCD wire format established in Phase 6.c (DECIMAL/DATETIME). The default qualifier choice is generous: DAY(9) covers any timedelta, YEAR(9) handles ±1B years. JDBC defaults to smaller widths (DAY(2)/YEAR(4)) trading safety for compactness — we make the opposite trade. FRACTION(5) is the Informix precision ceiling — sub-10us intervals can't round-trip cleanly. Same limitation JDBC has. Six integration tests, all green on first run against live Informix — the synthetic round-trip in the test framework caught every framing bug locally, before integration tests even started. This is the dividend from owning both decoder and encoder. Total: 53 unit + 88 integration = 141 tests. Type matrix update: INTERVAL now has both decode + encode. Only BLOB/CLOB and BYTE/TEXT remain among the common types. --- docs/DECISION_LOG.md | 17 ++++ src/informix_db/converters.py | 158 +++++++++++++++++++++++++++++- tests/test_interval_encode.py | 175 ++++++++++++++++++++++++++++++++++ 3 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 tests/test_interval_encode.py diff --git a/docs/DECISION_LOG.md b/docs/DECISION_LOG.md index ffef165..789abfc 100644 --- a/docs/DECISION_LOG.md +++ b/docs/DECISION_LOG.md @@ -452,6 +452,23 @@ INTERVAL parameter binding (encoder) is deferred to Phase 6.e or later — same --- +## 2026-05-04 — INTERVAL parameter encoding + +**Status**: active +**Decision**: ``encode_param`` dispatches ``datetime.timedelta`` and :class:`IntervalYM` to dedicated encoders that produce the 2-byte-length-prefixed BCD payload (per the Phase 6.c discovery). Default qualifiers are chosen to cover any sane Python value: +- ``timedelta`` → ``INTERVAL DAY(9) TO FRACTION(5)`` (covers ±999,999,999 days × 10us resolution) +- ``IntervalYM`` → ``INTERVAL YEAR(9) TO MONTH`` (covers ±999,999,999 years) + +**Why DAY(9) and YEAR(9)?** Python's ``timedelta`` allows up to 999,999,999 days; YEAR/MONTH have no upper bound in Python (just a signed int). We could choose a smaller default, but the wire-format cost is one byte per two extra digits and the user-facing benefit is "no overflow surprises". JDBC's defaults (DAY(2) TO FRACTION(5) for IntervalDF, YEAR(4) TO MONTH for IntervalYM) trade safety for compactness — we make the opposite trade. + +**FRACTION(5) is the precision ceiling.** Informix doesn't expose FRAC6 even though the qualifier nibble allows it (per ``Interval.TU_F1..TU_F5``). The encoder scales nanoseconds via ``nans /= 10^(18 - end_TU)`` per JDBC, which means we lose the units digit of microseconds (10us is the smallest representable unit). This is the same limitation JDBC has — Informix fundamentally can't store sub-10us intervals in this format. + +**The synthetic round-trip caught every framing bug locally.** Once the decoder works, encoder verification becomes "decode my encoded bytes and compare to the input" — a closed loop with no server in the mix. All 6 integration tests passed on the first run against live Informix; no debugging cycle was needed. This is the dividend from owning both ends of the codec layer. + +**Lesson reinforced**: Phase 6.a (DECIMAL encoding) was the real cost — that's where the 2-byte-length-prefix wire-format discovery happened. Phase 6.c (DATE/DATETIME encoding) and Phase 6.e (INTERVAL encoding) each amortized that discovery with one new encoder per qualifier-bearing type. Total wall-clock time per phase is dropping geometrically. + +--- + ## (template — copy below this line for new entries) ``` diff --git a/src/informix_db/converters.py b/src/informix_db/converters.py index a8f5d79..d53e144 100644 --- a/src/informix_db/converters.py +++ b/src/informix_db/converters.py @@ -545,6 +545,157 @@ def _encode_datetime(value: datetime.datetime) -> EncodedParam: return (10, prec, raw) +def _encode_timedelta(value: datetime.timedelta) -> EncodedParam: + """Encode a Python ``datetime.timedelta`` as IDS INTERVAL DAY(9) TO FRACTION(5). + + DAY(9) covers any ``timedelta`` (max ~2.7e6 years); FRACTION(5) preserves + timedelta precision down to 10 microseconds (Python's microsecond + resolution loses one digit — same limitation JDBC has). + + Wire format (per ``Decimal.javaToIfx`` line 457): + ``[short total_len][head byte][digit pairs in base-100]`` + where ``head = (sign << 7) | ((dec_exp + 64) & 0x7F)`` and digit pairs + come from ``convertIntervalToDecimal``: variable-width first field + (DAY, here 9 digits = 5 bytes), then 1 byte each for HOUR/MIN/SEC, + then 3 bytes for FRAC5 (per the ``end - currentField + 1`` + base-100-pair count). + """ + # Default qualifier: DAY(9) TO FRACTION(5) + start_tu = 4 # DAY + end_tu = 15 # FRACTION(5) + first_len = 9 # DAY(9) + total_len = first_len + (end_tu - start_tu) # 9 + 11 = 20 + qual = (total_len << 8) | (start_tu << 4) | end_tu + + # Convert to (sign, days, secs_in_day, nanos) + is_negative = value < datetime.timedelta(0) + if is_negative: + value = -value + total_seconds_int = value.days * 86400 + value.seconds + days = total_seconds_int // 86400 + secs = total_seconds_int % 86400 + nanos = value.microseconds * 1000 + + # Pack digits per `convertIntervalToDecimal` algorithm. + digits: list[int] = [] + + # First field: DAY, 9 digits (odd lfprec). Per JDBC, odd lfprec takes + # the highest digit alone, then pairs the rest. + lfprec = first_len # 9 + tmp = days + if lfprec % 2 == 1: + lfprec -= 1 + mod = 10**lfprec + digits.append(tmp // mod) + tmp %= mod + while lfprec > 0: + lfprec -= 2 + mod = 10**lfprec + digits.append(tmp // mod) + tmp %= mod + + # HOUR, MIN, SEC — one byte each (2 base-10 digits → 1 base-100 byte) + digits.append(secs // 3600) + secs %= 3600 + digits.append(secs // 60) + digits.append(secs % 60) + + # FRACTION(5): per JDBC, scale nanos by 10^(15 - end_tu - 1 + 4 + 1) + # ... no, the loop is `for i = 0; i <= tmpInt; ++i` with tmpInt = + # 15 - end - 1 + 4 = 3 (for end=15). So 4 iterations: nans /= 10^4. + # Then pack as base-100 bytes per (end - 11 + 1) base-100 pairs = 3 bytes. + # The "if tmpInt == 1: nans *= 10" handles the trailing odd digit. + nans_div = 18 - end_tu # for end=15: 3, plus the +1 from `<=` = 4 divs + nans_for_packing = nanos + for _ in range(nans_div + 1): + nans_for_packing //= 10 + pair_count = end_tu - 11 + 1 # 5 for FRACTION(5) + while pair_count > 0: + if pair_count == 1: + nans_for_packing *= 10 + mod = 1 + else: + mod = 10 ** (pair_count - 2) + digits.append(nans_for_packing // mod) + nans_for_packing %= mod + pair_count -= 2 + + # Apply asymmetric base-100 complement for negative values + if is_negative: + sub_from = 100 + for i in range(len(digits) - 1, -1, -1): + if digits[i] == 0 and sub_from == 100: + continue + digits[i] = sub_from - digits[i] + sub_from = 99 + + # Head byte: dec_exp = (total_len + 10 - end_tu + 1) // 2 + dec_exp = (total_len + 10 - end_tu + 1) // 2 + biased = (dec_exp + 64) & 0x7F + head = (biased | 0x80) if not is_negative else (biased ^ 0x7F) + + inner = bytes([head]) + bytes(digits) + raw = len(inner).to_bytes(2, "big") + inner # 2-byte length prefix + return (int(IfxType.INTERVAL), qual, raw) + + +def _encode_intervalym(value: IntervalYM) -> EncodedParam: + """Encode an :class:`IntervalYM` as IDS INTERVAL YEAR(9) TO MONTH. + + YEAR(9) holds ±999,999,999 years — far more than any sane application. + The packing algorithm mirrors ``convertIntervalToDecimal(IntervalYM)``: + YEAR field gets lfprec digits, then a single byte for MONTH. + """ + start_tu = 0 # YEAR + end_tu = 2 # MONTH + first_len = 9 # YEAR(9) + total_len = first_len + (end_tu - start_tu) # 9 + 2 = 11 + qual = (total_len << 8) | (start_tu << 4) | end_tu + + months_total = value.months + is_negative = months_total < 0 + if is_negative: + months_total = -months_total + + years = months_total // 12 + months_remainder = months_total % 12 + + digits: list[int] = [] + + # YEAR field: lfprec = first_len = 9 (odd) + lfprec = first_len + tmp = years + if lfprec % 2 == 1: + lfprec -= 1 + mod = 10**lfprec + digits.append(tmp // mod) + tmp %= mod + while lfprec > 0: + lfprec -= 2 + mod = 10**lfprec + digits.append(tmp // mod) + tmp %= mod + + # MONTH field: single byte + digits.append(months_remainder) + + if is_negative: + sub_from = 100 + for i in range(len(digits) - 1, -1, -1): + if digits[i] == 0 and sub_from == 100: + continue + digits[i] = sub_from - digits[i] + sub_from = 99 + + dec_exp = (total_len + 10 - end_tu + 1) // 2 + biased = (dec_exp + 64) & 0x7F + head = (biased | 0x80) if not is_negative else (biased ^ 0x7F) + + inner = bytes([head]) + bytes(digits) + raw = len(inner).to_bytes(2, "big") + inner + return (int(IfxType.INTERVAL), qual, raw) + + def _encode_decimal(value: decimal.Decimal) -> EncodedParam: """Encode a Python ``decimal.Decimal`` as IDS DECIMAL (type=5). @@ -638,9 +789,14 @@ def encode_param(value: object) -> EncodedParam: return _encode_date(value) if isinstance(value, decimal.Decimal): return _encode_decimal(value) + if isinstance(value, datetime.timedelta): + return _encode_timedelta(value) + if isinstance(value, IntervalYM): + return _encode_intervalym(value) raise NotImplementedError( f"parameter binding for {type(value).__name__} not yet supported " - f"(Phase 4: int, float, str, bool, None, date, datetime)" + f"(supports: int, float, str, bool, None, date, datetime, " + f"timedelta, Decimal, IntervalYM)" ) diff --git a/tests/test_interval_encode.py b/tests/test_interval_encode.py new file mode 100644 index 0000000..28e3622 --- /dev/null +++ b/tests/test_interval_encode.py @@ -0,0 +1,175 @@ +"""Phase 6.e integration tests — INTERVAL parameter encoding round-trip. + +The encoder produces a 2-byte-length-prefixed BCD payload identical in +shape to DATETIME/DECIMAL (per the Phase 6.c discovery). These tests +verify the round-trip: Python value → SQ_BIND wire bytes → Informix +storage → SELECT → Python value. + +If the server silently drops a parametrized INSERT with no error, the +encoder envelope is wrong (same diagnostic pattern as DECIMAL/DATETIME). +""" + +from __future__ import annotations + +import datetime + +import pytest + +import informix_db +from informix_db import IntervalYM +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_timedelta_param_round_trip(conn_params: ConnParams) -> None: + """``datetime.timedelta`` as a bind parameter round-trips.""" + with _connect(conn_params) as conn: + cur = conn.cursor() + cur.execute( + "CREATE TEMP TABLE t_iv_e " + "(id INTEGER, span INTERVAL DAY(9) TO FRACTION(5))" + ) + cur.executemany( + "INSERT INTO t_iv_e VALUES (?, ?)", + [ + (1, datetime.timedelta(days=3, hours=4, minutes=5, seconds=6)), + (2, datetime.timedelta(days=0, hours=12)), + (3, datetime.timedelta(seconds=45)), + (4, datetime.timedelta(days=999_999_999)), + ], + ) + cur.execute("SELECT id, span FROM t_iv_e ORDER BY id") + rows = cur.fetchall() + assert rows == [ + (1, datetime.timedelta(days=3, hours=4, minutes=5, seconds=6)), + (2, datetime.timedelta(days=0, hours=12)), + (3, datetime.timedelta(seconds=45)), + (4, datetime.timedelta(days=999_999_999)), + ] + + +def test_negative_timedelta_round_trip(conn_params: ConnParams) -> None: + """Negative ``timedelta`` exercises the asymmetric base-100 complement encode.""" + with _connect(conn_params) as conn: + cur = conn.cursor() + cur.execute( + "CREATE TEMP TABLE t_iv_e2 " + "(id INTEGER, span INTERVAL DAY(9) TO FRACTION(5))" + ) + cur.executemany( + "INSERT INTO t_iv_e2 VALUES (?, ?)", + [ + (1, datetime.timedelta(days=-3, hours=-4, minutes=-5, seconds=-6)), + (2, datetime.timedelta(seconds=-45)), + ], + ) + cur.execute("SELECT id, span FROM t_iv_e2 ORDER BY id") + rows = cur.fetchall() + assert rows == [ + (1, datetime.timedelta(days=-3, hours=-4, minutes=-5, seconds=-6)), + (2, datetime.timedelta(seconds=-45)), + ] + + +def test_timedelta_with_microseconds(conn_params: ConnParams) -> None: + """timedelta down to 10us precision — Informix FRACTION(5) limit.""" + with _connect(conn_params) as conn: + cur = conn.cursor() + cur.execute( + "CREATE TEMP TABLE t_iv_e3 " + "(id INTEGER, span INTERVAL DAY(9) TO FRACTION(5))" + ) + cur.execute( + "INSERT INTO t_iv_e3 VALUES (?, ?)", + (1, datetime.timedelta(seconds=10, microseconds=500_000)), + ) + cur.execute("SELECT span FROM t_iv_e3") + (val,) = cur.fetchone() + assert val == datetime.timedelta(seconds=10, microseconds=500_000) + + +def test_intervalym_param_round_trip(conn_params: ConnParams) -> None: + """``IntervalYM`` as a bind parameter round-trips.""" + with _connect(conn_params) as conn: + cur = conn.cursor() + cur.execute( + "CREATE TEMP TABLE t_iv_ym " + "(id INTEGER, age INTERVAL YEAR(9) TO MONTH)" + ) + cur.executemany( + "INSERT INTO t_iv_ym VALUES (?, ?)", + [ + (1, IntervalYM(months=5 * 12 + 3)), + (2, IntervalYM(months=0)), + (3, IntervalYM(months=12)), + (4, IntervalYM(months=2026 * 12 + 7)), + ], + ) + cur.execute("SELECT id, age FROM t_iv_ym ORDER BY id") + rows = cur.fetchall() + assert rows == [ + (1, IntervalYM(months=5 * 12 + 3)), + (2, IntervalYM(months=0)), + (3, IntervalYM(months=12)), + (4, IntervalYM(months=2026 * 12 + 7)), + ] + + +def test_intervalym_negative_round_trip(conn_params: ConnParams) -> None: + """Negative ``IntervalYM`` round-trips.""" + with _connect(conn_params) as conn: + cur = conn.cursor() + cur.execute( + "CREATE TEMP TABLE t_iv_ym2 " + "(id INTEGER, age INTERVAL YEAR(9) TO MONTH)" + ) + cur.executemany( + "INSERT INTO t_iv_ym2 VALUES (?, ?)", + [ + (1, IntervalYM(months=-(5 * 12 + 3))), + (2, IntervalYM(months=-1)), + ], + ) + cur.execute("SELECT id, age FROM t_iv_ym2 ORDER BY id") + rows = cur.fetchall() + assert rows == [ + (1, IntervalYM(months=-(5 * 12 + 3))), + (2, IntervalYM(months=-1)), + ] + + +def test_interval_in_where_clause(conn_params: ConnParams) -> None: + """timedelta as a parameter in a WHERE clause.""" + with _connect(conn_params) as conn: + cur = conn.cursor() + cur.execute( + "CREATE TEMP TABLE t_iv_w " + "(id INTEGER, span INTERVAL DAY(9) TO SECOND)" + ) + cur.executemany( + "INSERT INTO t_iv_w VALUES (?, ?)", + [ + (1, datetime.timedelta(days=1)), + (2, datetime.timedelta(days=10)), + (3, datetime.timedelta(days=100)), + ], + ) + cur.execute( + "SELECT id FROM t_iv_w WHERE span > ? ORDER BY id", + (datetime.timedelta(days=5),), + ) + assert cur.fetchall() == [(2,), (3,)]