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.
101 lines
3.7 KiB
Python
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"),)) |