informix-db/tests/test_interval_encode.py
Ryan Malloy 888b8079d3 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.
2026-05-04 12:30:48 -06:00

176 lines
6.0 KiB
Python

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