Phase 6.e: INTERVAL parameter encoding

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.
This commit is contained in:
Ryan Malloy 2026-05-04 12:30:48 -06:00
parent 4dafbf8ce9
commit 888b8079d3
3 changed files with 349 additions and 1 deletions

View File

@ -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)
```

View File

@ -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)"
)

View File

@ -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,)]