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:
Ryan Malloy 2026-05-04 11:17:59 -06:00
parent d508a489fd
commit 2bacbc4e53
5 changed files with 355 additions and 1 deletions

View 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

View File

@ -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) ## (template — copy below this line for new entries)
``` ```

View File

@ -243,10 +243,24 @@ def parse_tuple_payload(
values.append(decode(col.type_code, raw)) values.append(decode(col.type_code, raw))
continue 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 # Fixed-width types
width = FIXED_WIDTHS.get(base) width = FIXED_WIDTHS.get(base)
if width is None: 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. # to encoded_length and surface raw bytes.
width = col.encoded_length width = col.encoded_length
raw = payload[offset:offset + width] raw = payload[offset:offset + width]

View File

@ -16,6 +16,7 @@ For DATE we use the Informix epoch (1899-12-31). The raw bytes are a
from __future__ import annotations from __future__ import annotations
import datetime import datetime
import decimal
import struct import struct
from collections.abc import Callable 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) 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 # 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. # slice column values out of an SQ_TUPLE payload for fixed-width types.
# Variable-width types (CHAR, VARCHAR, DECIMAL, etc.) are length-prefixed # Variable-width types (CHAR, VARCHAR, DECIMAL, etc.) are length-prefixed
@ -129,6 +205,8 @@ DECODERS: dict[int, DecoderFn] = {
IfxType.LVARCHAR: _decode_varchar, IfxType.LVARCHAR: _decode_varchar,
IfxType.BOOL: _decode_bool, IfxType.BOOL: _decode_bool,
IfxType.DATE: _decode_date, 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") 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: def encode_param(value: object) -> EncodedParam:
"""Pick an encoder based on the Python value's type. """Pick an encoder based on the Python value's type.
@ -218,6 +358,15 @@ def encode_param(value: object) -> EncodedParam:
return _encode_float(value) return _encode_float(value)
if isinstance(value, str): if isinstance(value, str):
return _encode_str(value) 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( raise NotImplementedError(
f"parameter binding for {type(value).__name__} not yet supported " f"parameter binding for {type(value).__name__} not yet supported "
f"(Phase 4 MVP: int, float, str, bool, None)" f"(Phase 4 MVP: int, float, str, bool, None)"

101
tests/test_decimal.py Normal file
View 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"),))