Now you can pass Python datetime/date/Decimal values directly:
cur.execute('INSERT INTO t VALUES (?, ?, ?)',
(1, datetime.datetime(2026, 5, 4, 12, 34, 56), Decimal('1234.56')))
cur.execute('SELECT id FROM t WHERE d > ?', (datetime.date(2025, 1, 1),))
The 2-byte length-prefix discovery: both my Phase 6.a DECIMAL encoder
and the new Phase 6.c DATETIME encoder produced "correct" BCD bytes
but the server silently dropped the SQ_BIND PDU (no response, just
timeout). Captured the wire, diffed against JDBC, and found that
DECIMAL/DATETIME bind data has a 2-byte length PREFIX wrapping the
BCD payload (per Decimal.javaToIfx line 457). With the prefix added,
both encoders work. DATE doesn't need the prefix — it's a fixed
4-byte int.
Per-type wire format:
date → DATE(7), [4-byte BE int = days since 1899-12-31]
datetime → DATETIME(10), [short total_len][byte 0xc7][7 BCD pairs]
Decimal → DECIMAL(5), [short total_len][byte exp][BCD digit pairs]
For DATETIME the encoder always emits YEAR TO SECOND form (no
microseconds) — covers the common case. Phase 6.x can add YEAR TO
FRACTION(N) variants if microsecond precision is needed.
For DECIMAL the encoder uses the asymmetric base-100 complement
(mirror of decoder) for negatives. Tested with positive, negative,
and fractional values.
Lesson for the protocol playbook: when the server silently drops a
PDU, it's almost always an envelope/framing issue rather than the
inner-value bytes being wrong. Same pattern as the SHORT-vs-INT
reserved field in CURNAME+NFETCH and the even-byte alignment pad.
Module changes:
src/informix_db/converters.py:
+ _encode_date — 4-byte BE int day count
+ _encode_datetime — YEAR TO SECOND form with 2-byte length prefix
+ _encode_decimal — re-enabled (was Phase 6.x stub) with the same
length-prefix fix
+ encode_param() dispatches on datetime.datetime BEFORE
datetime.date (since datetime is a subclass of date in Python)
Tests: 40 unit + 73 integration (3 new date/datetime param tests + 1
updated decimal param test) = 113 total, all green, ruff clean. New
tests cover:
- date as INSERT parameter via executemany — 3 dates round-trip
- datetime as INSERT parameter via executemany — 3 timestamps
- date as parameter in a WHERE clause filter (created_at > ?)
- Decimal round trip (was: NotImplementedError check; now: real
INSERT + SELECT verification)
Type support matrix updates:
DATE — encode ✓ + decode ✓ (was decode-only)
DATETIME — encode ✓ + decode ✓ (was decode-only)
DECIMAL — encode ✓ + decode ✓ (was decode-only)
187 lines
6.6 KiB
Python
187 lines
6.6 KiB
Python
"""Phase 6.b integration tests — DATETIME decoding for all qualifier ranges.
|
|
|
|
DATETIME is BCD-packed with a qualifier embedded in encoded_length:
|
|
``(digit_count << 8) | (start_TU << 4) | end_TU`` where TU codes are
|
|
YEAR=0, MONTH=2, DAY=4, HOUR=6, MIN=8, SEC=10, FRAC1=11..FRAC5=15.
|
|
|
|
The decoder picks the appropriate Python type:
|
|
- datetime.date for qualifiers ending at YEAR/MONTH/DAY
|
|
- datetime.time for qualifiers starting at HOUR or later
|
|
- datetime.datetime for spans crossing the date/time boundary
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
|
|
import pytest
|
|
|
|
import informix_db
|
|
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_datetime_year_to_second(conn_params: ConnParams) -> None:
|
|
"""Full date+time → datetime.datetime."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT DATETIME(2026-05-04 12:34:56) YEAR TO SECOND "
|
|
"FROM systables WHERE tabid = 1"
|
|
)
|
|
assert cur.fetchone() == (datetime.datetime(2026, 5, 4, 12, 34, 56),)
|
|
|
|
|
|
def test_datetime_year_to_day(conn_params: ConnParams) -> None:
|
|
"""Date-only qualifier → datetime.date."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT DATETIME(2026-05-04) YEAR TO DAY "
|
|
"FROM systables WHERE tabid = 1"
|
|
)
|
|
assert cur.fetchone() == (datetime.date(2026, 5, 4),)
|
|
|
|
|
|
def test_datetime_hour_to_second(conn_params: ConnParams) -> None:
|
|
"""Time-only qualifier → datetime.time."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT DATETIME(12:34:56) HOUR TO SECOND "
|
|
"FROM systables WHERE tabid = 1"
|
|
)
|
|
assert cur.fetchone() == (datetime.time(12, 34, 56),)
|
|
|
|
|
|
def test_datetime_current_with_fraction(conn_params: ConnParams) -> None:
|
|
"""CURRENT YEAR TO FRACTION returns a datetime.datetime."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT CURRENT YEAR TO FRACTION(3) FROM systables WHERE tabid = 1"
|
|
)
|
|
(val,) = cur.fetchone()
|
|
assert isinstance(val, datetime.datetime)
|
|
# Sanity: should be within a reasonable timeframe of "now"
|
|
now = datetime.datetime.now()
|
|
assert abs((val - now).total_seconds()) < 86400 # within a day
|
|
|
|
|
|
def test_datetime_multiple_columns_in_one_row(conn_params: ConnParams) -> None:
|
|
"""Multiple DATETIME qualifiers in one row — proves per-column slicing."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"SELECT "
|
|
" DATETIME(2026-05-04 12:34:56) YEAR TO SECOND, "
|
|
" DATETIME(2026-05-04) YEAR TO DAY, "
|
|
" DATETIME(12:34:56) HOUR TO SECOND "
|
|
"FROM systables WHERE tabid = 1"
|
|
)
|
|
ts, d, t = cur.fetchone()
|
|
assert ts == datetime.datetime(2026, 5, 4, 12, 34, 56)
|
|
assert d == datetime.date(2026, 5, 4)
|
|
assert t == datetime.time(12, 34, 56)
|
|
|
|
|
|
def test_datetime_column_in_table(conn_params: ConnParams) -> None:
|
|
"""DATETIME stored in a table column round-trips correctly."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute(
|
|
"CREATE TEMP TABLE t_dt (id INTEGER, ts DATETIME YEAR TO SECOND)"
|
|
)
|
|
cur.execute(
|
|
"INSERT INTO t_dt VALUES (1, DATETIME(2026-01-15 09:30:00) YEAR TO SECOND)"
|
|
)
|
|
cur.execute("SELECT ts FROM t_dt")
|
|
assert cur.fetchone() == (datetime.datetime(2026, 1, 15, 9, 30, 0),)
|
|
|
|
|
|
def test_datetime_null(conn_params: ConnParams) -> None:
|
|
"""NULL DATETIME decodes to Python None."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("CREATE TEMP TABLE t_dt2 (ts DATETIME YEAR TO SECOND)")
|
|
cur.execute("INSERT INTO t_dt2 VALUES (NULL)")
|
|
cur.execute("SELECT ts FROM t_dt2")
|
|
assert cur.fetchone() == (None,)
|
|
|
|
|
|
def test_date_param_round_trip(conn_params: ConnParams) -> None:
|
|
"""``datetime.date`` as a bind parameter round-trips through INSERT + SELECT."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("CREATE TEMP TABLE t_d (id INTEGER, d DATE)")
|
|
cur.executemany(
|
|
"INSERT INTO t_d VALUES (?, ?)",
|
|
[
|
|
(1, datetime.date(2026, 5, 4)),
|
|
(2, datetime.date(1999, 12, 31)),
|
|
(3, datetime.date(1900, 1, 1)),
|
|
],
|
|
)
|
|
cur.execute("SELECT id, d FROM t_d ORDER BY id")
|
|
assert cur.fetchall() == [
|
|
(1, datetime.date(2026, 5, 4)),
|
|
(2, datetime.date(1999, 12, 31)),
|
|
(3, datetime.date(1900, 1, 1)),
|
|
]
|
|
|
|
|
|
def test_datetime_param_round_trip(conn_params: ConnParams) -> None:
|
|
"""``datetime.datetime`` as a bind parameter round-trips."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("CREATE TEMP TABLE t_dt3 (id INTEGER, ts DATETIME YEAR TO SECOND)")
|
|
cur.executemany(
|
|
"INSERT INTO t_dt3 VALUES (?, ?)",
|
|
[
|
|
(1, datetime.datetime(2026, 5, 4, 12, 34, 56)),
|
|
(2, datetime.datetime(2000, 1, 1, 0, 0, 0)),
|
|
(3, datetime.datetime(2023, 7, 15, 18, 30, 45)),
|
|
],
|
|
)
|
|
cur.execute("SELECT id, ts FROM t_dt3 ORDER BY id")
|
|
rows = cur.fetchall()
|
|
assert rows == [
|
|
(1, datetime.datetime(2026, 5, 4, 12, 34, 56)),
|
|
(2, datetime.datetime(2000, 1, 1, 0, 0, 0)),
|
|
(3, datetime.datetime(2023, 7, 15, 18, 30, 45)),
|
|
]
|
|
|
|
|
|
def test_date_in_where_clause(conn_params: ConnParams) -> None:
|
|
"""Use a Python ``date`` as a parameter in a WHERE clause."""
|
|
with _connect(conn_params) as conn:
|
|
cur = conn.cursor()
|
|
cur.execute("CREATE TEMP TABLE t_w (id INTEGER, d DATE)")
|
|
cur.executemany(
|
|
"INSERT INTO t_w VALUES (?, ?)",
|
|
[
|
|
(1, datetime.date(2024, 1, 1)),
|
|
(2, datetime.date(2025, 6, 15)),
|
|
(3, datetime.date(2026, 12, 31)),
|
|
],
|
|
)
|
|
cur.execute(
|
|
"SELECT id FROM t_w WHERE d > ? ORDER BY id",
|
|
(datetime.date(2025, 1, 1),),
|
|
)
|
|
assert cur.fetchall() == [(2,), (3,)]
|