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:
parent
4dafbf8ce9
commit
888b8079d3
@ -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)
|
## (template — copy below this line for new entries)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@ -545,6 +545,157 @@ def _encode_datetime(value: datetime.datetime) -> EncodedParam:
|
|||||||
return (10, prec, raw)
|
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:
|
def _encode_decimal(value: decimal.Decimal) -> EncodedParam:
|
||||||
"""Encode a Python ``decimal.Decimal`` as IDS DECIMAL (type=5).
|
"""Encode a Python ``decimal.Decimal`` as IDS DECIMAL (type=5).
|
||||||
|
|
||||||
@ -638,9 +789,14 @@ def encode_param(value: object) -> EncodedParam:
|
|||||||
return _encode_date(value)
|
return _encode_date(value)
|
||||||
if isinstance(value, decimal.Decimal):
|
if isinstance(value, decimal.Decimal):
|
||||||
return _encode_decimal(value)
|
return _encode_decimal(value)
|
||||||
|
if isinstance(value, datetime.timedelta):
|
||||||
|
return _encode_timedelta(value)
|
||||||
|
if isinstance(value, IntervalYM):
|
||||||
|
return _encode_intervalym(value)
|
||||||
raise NotImplementedError(
|
raise NotImplementedError(
|
||||||
f"parameter binding for {type(value).__name__} not yet supported "
|
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)"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
175
tests/test_interval_encode.py
Normal file
175
tests/test_interval_encode.py
Normal 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,)]
|
||||||
Loading…
x
Reference in New Issue
Block a user