informix-db/tests/test_decimal.py
Ryan Malloy 2bacbc4e53 Phase 6.a: DECIMAL/MONEY row decoding works (COUNT/SUM/AVG return Decimal)
Before:
  cur.execute('SELECT COUNT(*) FROM systables')
  cur.fetchone()  # → (b'\xc2\x02\x00\x00\x00\x00\x00\x00\x00',) raw bytes

After:
  cur.execute('SELECT COUNT(*) FROM systables')
  cur.fetchone()  # → (Decimal('276'),)

The trickiest decode of the project so far. IDS DECIMAL/MONEY wire format:

  byte[0] = (sign << 7) | biased_exponent_base100
    bit 7 = sign (1=positive, 0=negative)
    bits 0-6 = (exponent + 64), XOR'd with 0x7F if negative
  byte[1..] = digit-pair bytes (each 0..99 = two BCD digits)
    if negative: asymmetric base-100 complement applied:
      walk digits right→left, trailing zeros stay zero,
      first non-zero subtracts from 100, rest from 99

Initial naive "99 - d for all digits" decoder gave artifacts like
-1234.559999 instead of -1234.56. The asymmetric complement rule
(from Decimal.decComplement line 447) is what makes negatives
round-trip exactly.

Width on the wire: per-column encoded_length packed as
(precision << 8) | scale; byte width = ceil(precision/2) + 1.
parse_tuple_payload uses this to slice DECIMAL columns correctly.

Tested cases all decode correctly:
  COUNT(*)             → Decimal('276')
  SUM(tabid)           → Decimal('55')
  AVG(tabid)           → Decimal('5.5')
  1234.56::DECIMAL     → Decimal('1234.56')
  -1234.56::DECIMAL    → Decimal('-1234.56')
  -0.5::DECIMAL        → Decimal('-0.5')
  -99.99::DECIMAL      → Decimal('-99.99')
  -12345678.9::DECIMAL → Decimal('-12345678.9')
  NULL                 → None

Encoder (_encode_decimal) is implemented but disabled — server rejects
the produced bytes (precision packing not quite right). Phase 6.x will
fix. Workaround: cast Decimal to float, or pass via SQL literal.

Module changes:
  src/informix_db/converters.py:
    + decimal module import
    + _decode_decimal — full BCD decoder with asymmetric complement
    + _encode_decimal (Phase 6.x stub — present but unreached)
    + DECIMAL/MONEY added to DECODERS dispatch
  src/informix_db/_resultset.py:
    + DECIMAL/MONEY width computation from encoded_length

Tests: 40 unit + 55 integration (8 new DECIMAL) = 95 total, all
green, ruff clean.
2026-05-04 11:17:59 -06:00

101 lines
3.7 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_raises_for_now(conn_params: ConnParams) -> None:
"""Decimal as a bind parameter is Phase 6.x — currently raises."""
with _connect(conn_params) as conn:
cur = conn.cursor()
cur.execute("CREATE TEMP TABLE td2 (n DECIMAL(10,2))")
with pytest.raises(NotImplementedError, match="Decimal"):
cur.execute("INSERT INTO td2 VALUES (?)", (Decimal("3.14"),))