informix-db/tests/test_decimal.py
Ryan Malloy 10863a9337 Phase 6.c: DATE / DATETIME / DECIMAL parameter encoding
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)
2026-05-04 12:09:16 -06:00

105 lines
3.9 KiB
Python

"""Phase 6.a integration tests — DECIMAL/MONEY row decoding.
Decode-only for now. Encoding (Decimal as a parameter) is Phase 6.x —
the encoder is implemented in ``converters.py`` but the server rejects
the bytes (precision packing not quite right). Workaround: cast to
float at the call site or pass via SQL literal.
"""
from __future__ import annotations
from decimal import Decimal
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_count_returns_decimal(conn_params: ConnParams) -> None:
"""COUNT(*) returns DECIMAL — must decode to a Python int-like Decimal."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM systables")
(n,) = cur.fetchone()
assert isinstance(n, Decimal)
assert n > 0 # systables has at least some rows
def test_sum_returns_decimal(conn_params: ConnParams) -> None:
"""SUM returns DECIMAL — verify exact integer arithmetic."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT SUM(tabid) FROM systables WHERE tabid <= 10")
# 1+2+...+10 = 55
assert cur.fetchone() == (Decimal("55"),)
def test_avg_returns_decimal_with_fraction(conn_params: ConnParams) -> None:
"""AVG returns DECIMAL with fractional part."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT AVG(tabid) FROM systables WHERE tabid <= 10")
# avg(1..10) = 5.5
assert cur.fetchone() == (Decimal("5.5"),)
def test_decimal_literal_positive(conn_params: ConnParams) -> None:
"""Literal DECIMAL value with explicit precision/scale."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT 1234.56::DECIMAL(10,2) FROM systables WHERE tabid = 1")
assert cur.fetchone() == (Decimal("1234.56"),)
def test_decimal_literal_negative(conn_params: ConnParams) -> None:
"""Negative DECIMAL — exercises the asymmetric base-100 complement decode."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT -1234.56::DECIMAL(10,2) FROM systables WHERE tabid = 1")
assert cur.fetchone() == (Decimal("-1234.56"),)
def test_decimal_small_fraction(conn_params: ConnParams) -> None:
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("SELECT 0.5::DECIMAL(10,2), -0.5::DECIMAL(10,2) FROM systables WHERE tabid = 1")
assert cur.fetchone() == (Decimal("0.5"), Decimal("-0.5"))
def test_decimal_null(conn_params: ConnParams) -> None:
"""NULL DECIMAL decodes as Python None."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("CREATE TEMP TABLE td (n DECIMAL(10,2))")
cur.execute("INSERT INTO td VALUES (NULL)")
cur.execute("SELECT n FROM td")
assert cur.fetchone() == (None,)
def test_decimal_param_binding_round_trip(conn_params: ConnParams) -> None:
"""Decimal as a bind parameter round-trips through INSERT + SELECT."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("CREATE TEMP TABLE td2 (id INTEGER, n DECIMAL(12,2))")
for i, d in enumerate([Decimal("1234.56"), Decimal("-99.99"), Decimal("0.5")]):
cur.execute("INSERT INTO td2 VALUES (?, ?)", (i, d))
cur.execute("SELECT n FROM td2 ORDER BY id")
rows = cur.fetchall()
assert rows == [(Decimal("1234.56"),), (Decimal("-99.99"),), (Decimal("0.5"),)]