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.
This commit is contained in:
parent
d508a489fd
commit
2bacbc4e53
50
docs/CAPTURES/21-py-decimal-neg.socat.log
Normal file
50
docs/CAPTURES/21-py-decimal-neg.socat.log
Normal file
@ -0,0 +1,50 @@
|
||||
2026/05/04 11:14:20 socat[852378] N listening on AF=2 0.0.0.0:9090
|
||||
2026/05/04 11:14:20 socat[852378] N accepting connection from AF=2 127.0.0.1:52034 on AF=2 127.0.0.1:9090
|
||||
2026/05/04 11:14:20 socat[852378] N opening connection to 127.0.0.1:9088
|
||||
2026/05/04 11:14:20 socat[852378] N opening connection to AF=2 127.0.0.1:9088
|
||||
2026/05/04 11:14:20 socat[852378] N successfully connected from local address AF=2 127.0.0.1:60064
|
||||
2026/05/04 11:14:20 socat[852378] N successfully connected to 127.0.0.1:9088
|
||||
2026/05/04 11:14:20 socat[852378] N starting data transfer loop with FDs [6,6] and [5,5]
|
||||
> 2026/05/04 11:14:20.597948 length=384 from=0 to=383
|
||||
01 80 01 3c 00 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 4d 00 00 6c 73 71 6c 65 78 65 63 00 00 00 00 00 00 06 39 2e 32 38 30 00 00 0c 52 44 53 23 52 30 30 30 30 30 30 00 00 05 73 71 6c 69 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 01 00 09 69 6e 66 6f 72 6d 69 78 00 00 07 69 6e 34 6d 69 78 00 6f 6c 00 00 00 00 00 00 00 00 00 3d 74 6c 69 74 63 70 00 00 00 00 00 01 00 68 00 0b 00 00 00 03 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 00 00 00 00 00 00 00 00 00 6a 00 06 00 07 44 42 50 41 54 48 00 00 02 2e 00 00 0e 43 4c 49 45 4e 54 5f 4c 4f 43 41 4c 45 00 00 0d 65 6e 5f 55 53 2e 38 38 35 39 2d 31 00 00 11 43 4c 4e 54 5f 50 41 4d 5f 43 41 50 41 42 4c 45 00 00 02 31 00 00 07 44 42 44 41 54 45 00 00 06 59 34 4d 44 2d 00 00 0c 49 46 58 5f 55 50 44 44 45 53 43 00 00 02 31 00 00 09 4e 4f 44 45 46 44 41 43 00 00 03 6e 6f 00 00 6b 00 00 00 00 00 0d 01 bc 00 00 00 00 00 0b 72 70 6d 2d 62 75 6c 6c 65 74 00 00 00 00 29 2f 68 6f 6d 65 2f 72 70 6d 2f 63 6c 61 75 64 65 2f 69 6e 66 6f 72 6d 69 78 2f 70 79 74 68 6f 6e 2d 6c 69 62 72 61 72 79 00 00 74 00 20 00 00 00 00 00 00 00 00 00 16 69 6e 66 6f 72 6d 69 78 2d 64 62 40 70 69 64 38 35 32 34 31 32 00 00 7f
|
||||
< 2026/05/04 11:14:20.609620 length=276 from=0 to=275
|
||||
01 14 02 3c 10 00 00 64 00 65 00 00 00 3d 00 06 49 45 45 45 49 00 00 6c 73 72 76 69 6e 66 78 00 00 00 00 00 00 2f 49 42 4d 20 49 6e 66 6f 72 6d 69 78 20 44 79 6e 61 6d 69 63 20 53 65 72 76 65 72 20 56 65 72 73 69 6f 6e 20 31 35 2e 30 2e 31 2e 30 2e 33 00 00 07 73 65 72 69 61 6c 00 00 09 69 6e 66 6f 72 6d 69 78 00 00 00 01 3c 00 00 00 00 00 00 00 00 00 00 00 00 00 00 6f 6e 00 00 00 00 00 00 00 00 00 3d 73 6f 63 74 63 70 00 00 00 00 00 00 00 66 00 00 00 00 00 00 00 00 00 00 00 14 00 00 00 6b 00 00 00 00 00 00 03 1a 00 00 00 00 00 0d 32 33 32 37 63 34 33 35 34 65 61 38 00 00 00 00 0f 2f 68 6f 6d 65 2f 69 6e 66 6f 72 6d 69 78 00 00 6e 00 04 00 00 00 00 00 74 00 33 00 00 00 c8 00 00 00 c8 00 29 2f 6f 70 74 2f 69 62 6d 2f 69 6e 66 6f 72 6d 69 78 2f 76 31 35 2e 30 2e 31 2e 30 2e 33 2f 62 69 6e 2f 6f 6e 69 6e 69 74 00 00 7f
|
||||
> 2026/05/04 11:14:20.609849 length=14 from=384 to=397
|
||||
00 7e 00 08 ff fc 7f fc 3c 8c aa 97 00 0c
|
||||
< 2026/05/04 11:14:20.609915 length=16 from=276 to=291
|
||||
00 7e 00 09 bd be 9f fe 7f b7 ff ef ff 00 00 0c
|
||||
> 2026/05/04 11:14:20.609941 length=48 from=398 to=445
|
||||
00 51 00 06 00 26 00 0c 00 04 00 06 44 42 54 45 4d 50 00 04 2f 74 6d 70 00 0b 53 55 42 51 43 41 43 48 45 53 5a 00 00 02 31 30 00 00 00 00 00 0c
|
||||
< 2026/05/04 11:14:20.610015 length=2 from=292 to=293
|
||||
00 0c
|
||||
> 2026/05/04 11:14:20.610030 length=18 from=446 to=463
|
||||
00 24 00 09 73 79 73 6d 61 73 74 65 72 00 00 00 00 0c
|
||||
< 2026/05/04 11:14:20.610194 length=28 from=294 to=321
|
||||
00 0f 00 15 00 00 00 00 00 00 00 00 00 00 00 00 00 37 00 00 00 01 00 00 00 01 00 0c
|
||||
> 2026/05/04 11:14:20.610232 length=140 from=464 to=603
|
||||
00 02 00 00 00 00 00 7e 53 45 4c 45 43 54 20 31 32 33 34 2e 35 36 3a 3a 44 45 43 49 4d 41 4c 28 31 30 2c 32 29 2c 20 2d 31 32 33 34 2e 35 36 3a 3a 44 45 43 49 4d 41 4c 28 31 30 2c 32 29 2c 20 30 2e 35 3a 3a 44 45 43 49 4d 41 4c 28 31 30 2c 32 29 2c 20 2d 30 2e 35 3a 3a 44 45 43 49 4d 41 4c 28 31 30 2c 32 29 20 46 52 4f 4d 20 73 79 73 74 61 62 6c 65 73 20 57 48 45 52 45 20 74 61 62 69 64 20 3d 20 31 00 16 00 31 00 0c
|
||||
< 2026/05/04 11:14:20.610502 length=210 from=322 to=531
|
||||
00 08 00 02 00 00 00 00 00 00 00 18 00 04 00 00 00 2c 00 00 00 00 00 00 00 00 00 05 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0a 02 00 00 00 0b 00 00 00 06 00 05 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0a 02 00 00 00 16 00 00 00 0c 00 05 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0a 02 00 00 00 21 00 00 00 12 00 05 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 0a 02 28 63 6f 6e 73 74 61 6e 74 29 00 28 63 6f 6e 73 74 61 6e 74 29 00 28 63 6f 6e 73 74 61 6e 74 29 00 28 63 6f 6e 73 74 61 6e 74 29 00 00 0f 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 37 00 00 00 01 00 00 00 01 00 0c
|
||||
> 2026/05/04 11:14:20.610703 length=42 from=604 to=645
|
||||
00 04 00 00 00 03 00 12 5f 69 66 78 63 30 30 30 30 30 30 30 30 30 30 30 30 31 00 06 00 04 00 00 00 09 00 00 10 00 00 00 00 0c
|
||||
< 2026/05/04 11:14:20.610790 length=60 from=532 to=591
|
||||
00 0e 00 00 00 00 00 18 c2 0c 22 38 00 00 3d 57 41 2c 00 00 c0 32 00 00 00 00 3f 32 00 00 00 00 00 0f 00 00 00 00 00 01 00 00 03 01 00 00 00 00 00 37 00 00 00 01 00 00 00 01 00 0c
|
||||
> 2026/05/04 11:14:20.610864 length=14 from=646 to=659
|
||||
00 04 00 00 00 09 00 00 10 00 00 00 00 0c
|
||||
< 2026/05/04 11:14:20.610900 length=28 from=592 to=619
|
||||
00 0f 00 00 00 00 00 01 00 00 03 01 00 00 00 00 00 37 00 00 00 01 00 00 00 01 00 0c
|
||||
> 2026/05/04 11:14:20.610934 length=8 from=660 to=667
|
||||
00 04 00 00 00 0a 00 0c
|
||||
< 2026/05/04 11:14:20.610971 length=2 from=620 to=621
|
||||
00 0c
|
||||
> 2026/05/04 11:14:20.610985 length=8 from=668 to=675
|
||||
00 04 00 00 00 0b 00 0c
|
||||
< 2026/05/04 11:14:20.611019 length=2 from=622 to=623
|
||||
00 0c
|
||||
> 2026/05/04 11:14:20.611042 length=2 from=676 to=677
|
||||
00 38
|
||||
< 2026/05/04 11:14:20.611086 length=2 from=624 to=625
|
||||
00 38
|
||||
2026/05/04 11:14:20 socat[852378] N socket 1 (fd 6) is at EOF
|
||||
2026/05/04 11:14:20 socat[852378] N socket 2 (fd 5) is at EOF
|
||||
2026/05/04 11:14:20 socat[852378] N exiting with status 0
|
||||
@ -315,6 +315,46 @@ For now, executemany still gives PEP 249 conformance and slight perf improvement
|
||||
|
||||
---
|
||||
|
||||
## 2026-05-04 — DECIMAL/MONEY decoding: base-100 BCD with asymmetric complement
|
||||
|
||||
**Status**: active (decoder); encoder is Phase 6.x
|
||||
**Decision**: ``_decode_decimal`` handles IDS DECIMAL/MONEY wire bytes per ``com.informix.lang.Decimal.init`` (line 374) format:
|
||||
|
||||
```
|
||||
byte[0] = (sign << 7) | biased_exponent_base100
|
||||
- bit 7 = sign (1=positive, 0=negative)
|
||||
- bits 0-6 = (exponent + 64) for positive
|
||||
- bits 0-6 = (exponent + 64) ^ 0x7F for negative ← XOR'd
|
||||
byte[1..] = digit-pair bytes (each 0..99 = two BCD digits)
|
||||
- for negative: asymmetric base-100 complement applied
|
||||
```
|
||||
|
||||
Asymmetric base-100 complement (per ``Decimal.decComplement`` line 447):
|
||||
- Walk digits RIGHT to LEFT
|
||||
- Trailing zeros stay zero
|
||||
- First non-zero digit: subtract from 100
|
||||
- Subsequent digits: subtract from 99
|
||||
|
||||
This was the trickiest decode of the project so far — initial naive
|
||||
``99 - d`` for all digits gave artifacts like ``-1234.55999`` instead of
|
||||
``-1234.56``. The trailing-zeros and "first non-zero from 100" rules
|
||||
are what make the round trip exact.
|
||||
|
||||
NULL marker: byte[0] == 0 AND byte[1] == 0.
|
||||
|
||||
**Width on the wire**: per-column ``encoded_length`` field is packed as
|
||||
``(precision << 8) | scale``. Byte width = ``ceil(precision/2) + 1``.
|
||||
The row decoder uses this to slice DECIMAL columns out of the tuple
|
||||
payload (``parse_tuple_payload`` in ``_resultset.py``).
|
||||
|
||||
**Encoder (``_encode_decimal``)**: implemented but disabled — server
|
||||
rejects the bytes (precision packing wrong somewhere). Workaround for
|
||||
Phase 6.x users: cast Decimal to float at the call site or pass via
|
||||
SQL literal. Decode side is fully working — handles COUNT, SUM, AVG,
|
||||
literal DECIMAL values, negatives, fractions, NULLs.
|
||||
|
||||
---
|
||||
|
||||
## (template — copy below this line for new entries)
|
||||
|
||||
```
|
||||
|
||||
@ -243,10 +243,24 @@ def parse_tuple_payload(
|
||||
values.append(decode(col.type_code, raw))
|
||||
continue
|
||||
|
||||
# DECIMAL/MONEY: width = ceil(precision/2) + 1, where precision is
|
||||
# the high byte of encoded_length (packed as (precision << 8) | scale).
|
||||
# Per IfxRowColumn.loadColumnData and IfxToJavaDecimal byte sizing.
|
||||
if base in (int(IfxType.DECIMAL), int(IfxType.MONEY)):
|
||||
precision = (col.encoded_length >> 8) & 0xFF
|
||||
width = (precision + 1) // 2 + 1
|
||||
raw = payload[offset:offset + width]
|
||||
offset += width
|
||||
try:
|
||||
values.append(decode(col.type_code, raw))
|
||||
except NotImplementedError:
|
||||
values.append(raw)
|
||||
continue
|
||||
|
||||
# Fixed-width types
|
||||
width = FIXED_WIDTHS.get(base)
|
||||
if width is None:
|
||||
# Phase 6+ types (DECIMAL, DATETIME, INTERVAL, BLOBs) — fall back
|
||||
# Phase 6+ types (DATETIME, INTERVAL, BLOBs) — fall back
|
||||
# to encoded_length and surface raw bytes.
|
||||
width = col.encoded_length
|
||||
raw = payload[offset:offset + width]
|
||||
|
||||
@ -16,6 +16,7 @@ For DATE we use the Informix epoch (1899-12-31). The raw bytes are a
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import decimal
|
||||
import struct
|
||||
from collections.abc import Callable
|
||||
|
||||
@ -95,6 +96,81 @@ def _decode_date(raw: bytes) -> datetime.date | None:
|
||||
return _INFORMIX_DATE_EPOCH + datetime.timedelta(days=days)
|
||||
|
||||
|
||||
def _decode_decimal(raw: bytes) -> decimal.Decimal | None:
|
||||
"""Decode IDS DECIMAL/MONEY: base-100 packed BCD with sign/exponent header.
|
||||
|
||||
Wire format (per ``com.informix.lang.Decimal.init``, line 374):
|
||||
byte[0]: ``(sign << 7) | (biased_exponent & 0x7F)``
|
||||
- sign bit (bit 7): 1 = positive, 0 = negative
|
||||
- biased_exponent (bits 0-6): actual exponent = biased - 64,
|
||||
measured in BASE-100 digits before the decimal point
|
||||
byte[1..]: digit-pair bytes; each byte holds two decimal digits
|
||||
as a single base-100 number (0..99). If the value is NEGATIVE,
|
||||
each digit-pair is stored as 99-d (i.e., 9's complement in base 100).
|
||||
|
||||
NULL marker: byte[0] == 0 AND byte[1] == 0.
|
||||
"""
|
||||
if len(raw) < 2 or (raw[0] == 0 and raw[1] == 0):
|
||||
return None
|
||||
|
||||
expbyte = raw[0]
|
||||
is_positive = (expbyte & 0x80) != 0
|
||||
# For negative: exponent byte is XOR'd with 0x7F to recover real
|
||||
# exponent (per IfxToJavaDecimal.init line 386).
|
||||
biased_exp = (expbyte & 0x7F) if is_positive else ((expbyte ^ 0x7F) & 0x7F)
|
||||
exponent_base100 = biased_exp - 64 # in base-100 digits
|
||||
|
||||
digits = list(raw[1:])
|
||||
if not is_positive:
|
||||
# Asymmetric base-100 complement (per Decimal.decComplement, line 447):
|
||||
# walk from RIGHT to LEFT; trailing zeros stay zero; the first
|
||||
# non-zero is subtracted from 100; subsequent from 99.
|
||||
# Without this, trailing 99s appear in the decoded value (a
|
||||
# 1234.559999 / 0.4999... rounding-style artifact).
|
||||
sub_from = 100
|
||||
for i in range(len(digits) - 1, -1, -1):
|
||||
if digits[i] == 0 and sub_from == 100:
|
||||
continue
|
||||
digits[i] = sub_from - digits[i]
|
||||
sub_from = 99
|
||||
|
||||
# Build the decimal-string representation.
|
||||
# exponent_base100 is the count of BASE-100 digits before the decimal
|
||||
# point; multiplying by 2 gives BASE-10 digits before the decimal.
|
||||
base10_exp = exponent_base100 * 2
|
||||
|
||||
# Concatenate all digit-pairs as a string, dropping trailing zeros
|
||||
# for normalization.
|
||||
digit_str = "".join(f"{d:02d}" for d in digits)
|
||||
if not digit_str:
|
||||
return decimal.Decimal(0)
|
||||
|
||||
sign_str = "" if is_positive else "-"
|
||||
# Build "<sign><digits>E<exp>" — Decimal will normalize.
|
||||
# Each digit-pair represents 2 base-10 digits; the value is
|
||||
# digit_str interpreted as an integer * 10^(base10_exp - len(digit_str))
|
||||
if base10_exp >= 0:
|
||||
# The decimal point is to the RIGHT of digit_str's start by
|
||||
# base10_exp positions.
|
||||
if base10_exp >= len(digit_str):
|
||||
# All digits are integer; pad with zeros to reach the exp.
|
||||
int_part = digit_str + "0" * (base10_exp - len(digit_str))
|
||||
return decimal.Decimal(f"{sign_str}{int_part}")
|
||||
else:
|
||||
int_part = digit_str[:base10_exp] or "0"
|
||||
frac_part = digit_str[base10_exp:].rstrip("0")
|
||||
if frac_part:
|
||||
return decimal.Decimal(f"{sign_str}{int_part}.{frac_part}")
|
||||
return decimal.Decimal(f"{sign_str}{int_part}")
|
||||
else:
|
||||
# base10_exp < 0: leading zeros in the fraction
|
||||
frac_zeros = "0" * (-base10_exp)
|
||||
frac_part = (frac_zeros + digit_str).rstrip("0")
|
||||
if frac_part:
|
||||
return decimal.Decimal(f"{sign_str}0.{frac_part}")
|
||||
return decimal.Decimal(0)
|
||||
|
||||
|
||||
# Wire byte length per Phase-2-MVP type. Used by the row decoder to
|
||||
# slice column values out of an SQ_TUPLE payload for fixed-width types.
|
||||
# Variable-width types (CHAR, VARCHAR, DECIMAL, etc.) are length-prefixed
|
||||
@ -129,6 +205,8 @@ DECODERS: dict[int, DecoderFn] = {
|
||||
IfxType.LVARCHAR: _decode_varchar,
|
||||
IfxType.BOOL: _decode_bool,
|
||||
IfxType.DATE: _decode_date,
|
||||
IfxType.DECIMAL: _decode_decimal,
|
||||
IfxType.MONEY: _decode_decimal, # MONEY is DECIMAL with implied scale
|
||||
}
|
||||
|
||||
|
||||
@ -199,6 +277,68 @@ def _encode_bool(value: bool) -> EncodedParam:
|
||||
return (45, 0, b"\x01" if value else b"\x00")
|
||||
|
||||
|
||||
def _encode_decimal(value: decimal.Decimal) -> EncodedParam:
|
||||
"""Encode a Python ``decimal.Decimal`` as IDS DECIMAL (type=5).
|
||||
|
||||
Inverse of ``_decode_decimal``: produce a base-100 BCD encoding with
|
||||
the ``[sign+exponent][digit-pairs]`` header byte. Mirrors
|
||||
``Decimal.javaToIfx`` (line 457).
|
||||
"""
|
||||
sign, digits, exp = value.as_tuple()
|
||||
# Total decimal digits in mantissa
|
||||
n_digits = len(digits)
|
||||
# Compute base-10 exponent of the most significant digit
|
||||
# (the "exp" returned by as_tuple is the position of the LSD;
|
||||
# we want the position of the MSD relative to the decimal point.)
|
||||
base10_exp = n_digits + exp # number of digits BEFORE the decimal
|
||||
|
||||
# Pad digits to even length on both sides so we can pack into base-100.
|
||||
# Compute how many leading-zero-pairs to add (to align base100_exp on
|
||||
# a base-100 boundary).
|
||||
if base10_exp % 2 != 0:
|
||||
# If odd, add a leading 0 to align — base10_exp becomes even.
|
||||
digits = (0, *digits)
|
||||
base10_exp += 1
|
||||
n_digits += 1
|
||||
if n_digits % 2 != 0:
|
||||
# Pad trailing zero to make digit count even (so we can pair).
|
||||
digits = (*digits, 0)
|
||||
n_digits += 1
|
||||
|
||||
base100_exp = base10_exp // 2 # exponent in base-100 digits
|
||||
|
||||
# Pack pairs of decimal digits into bytes.
|
||||
digit_pairs = bytes(
|
||||
digits[i] * 10 + digits[i + 1] for i in range(0, n_digits, 2)
|
||||
)
|
||||
|
||||
is_positive = sign == 0
|
||||
biased_exp = base100_exp + 64
|
||||
if is_positive:
|
||||
head_byte = (biased_exp & 0x7F) | 0x80
|
||||
out_digits = digit_pairs
|
||||
else:
|
||||
# Apply asymmetric base-100 complement (mirror of decode).
|
||||
complemented = bytearray(digit_pairs)
|
||||
sub_from = 100
|
||||
for i in range(len(complemented) - 1, -1, -1):
|
||||
if complemented[i] == 0 and sub_from == 100:
|
||||
continue
|
||||
complemented[i] = sub_from - complemented[i]
|
||||
sub_from = 99
|
||||
# Negative: head byte is biased_exp ^ 0x7F (high bit stays 0)
|
||||
head_byte = (biased_exp & 0x7F) ^ 0x7F
|
||||
out_digits = bytes(complemented)
|
||||
|
||||
raw = bytes([head_byte]) + out_digits
|
||||
# Precision short for DECIMAL: packed (precision << 8) | scale
|
||||
# Precision = total significant digits, scale = digits after point.
|
||||
precision = max(n_digits, 1)
|
||||
scale = max(0, -exp)
|
||||
prec_short = (precision << 8) | (scale & 0xFF)
|
||||
return (5, prec_short, raw)
|
||||
|
||||
|
||||
def encode_param(value: object) -> EncodedParam:
|
||||
"""Pick an encoder based on the Python value's type.
|
||||
|
||||
@ -218,6 +358,15 @@ def encode_param(value: object) -> EncodedParam:
|
||||
return _encode_float(value)
|
||||
if isinstance(value, str):
|
||||
return _encode_str(value)
|
||||
if isinstance(value, decimal.Decimal):
|
||||
# _encode_decimal is implemented but the server rejects the
|
||||
# bytes (precision packing wrong somewhere) — kept as a
|
||||
# Phase 6.x starting point but disabled for now. Workaround:
|
||||
# cast Decimal to float at the call site if you need to bind.
|
||||
raise NotImplementedError(
|
||||
"Decimal parameter binding is Phase 6.x; convert to float "
|
||||
"or pass DECIMAL via SQL literal for now"
|
||||
)
|
||||
raise NotImplementedError(
|
||||
f"parameter binding for {type(value).__name__} not yet supported "
|
||||
f"(Phase 4 MVP: int, float, str, bool, None)"
|
||||
|
||||
101
tests/test_decimal.py
Normal file
101
tests/test_decimal.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""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"),))
|
||||
Loading…
x
Reference in New Issue
Block a user