diff --git a/redshift_connector/utils/type_utils.py b/redshift_connector/utils/type_utils.py index 6eb18f9..211486e 100644 --- a/redshift_connector/utils/type_utils.py +++ b/redshift_connector/utils/type_utils.py @@ -8,7 +8,7 @@ from datetime import time from datetime import timedelta as Timedelta from datetime import timezone as Timezone -from decimal import Decimal +from decimal import Context, Decimal from enum import Enum from json import loads from struct import Struct @@ -225,6 +225,9 @@ def intervald2s_send_integer(v: IntervalDayToSecond) -> bytes: return typing.cast(bytes, q_pack(microseconds)) +_NUMERIC_BINARY_CONTEXT: Context = Context(prec=39) + + def numeric_in_binary(data: bytes, offset: int, length: int, scale: int) -> Decimal: raw_value: int @@ -233,7 +236,10 @@ def numeric_in_binary(data: bytes, offset: int, length: int, scale: int) -> Deci else: raise Exception("Malformed column value of type numeric received") - return Decimal(raw_value).scaleb(-1 * scale) + # scaleb() rounds to the current decimal context (28 digits by default), + # which silently truncated NUMERIC(38, s) values. A 16-byte value has at + # most 39 digits, so this context keeps every digit. + return Decimal(raw_value).scaleb(-1 * scale, context=_NUMERIC_BINARY_CONTEXT) def numeric_to_float_binary(data: bytes, offset: int, length: int, scale: int) -> float: diff --git a/test/unit/datatype/test_data_in.py b/test/unit/datatype/test_data_in.py index 4f07a28..f9c36f6 100644 --- a/test/unit/datatype/test_data_in.py +++ b/test/unit/datatype/test_data_in.py @@ -1,6 +1,6 @@ import typing from datetime import date, datetime, time, timedelta, timezone -from decimal import Decimal +from decimal import Context, Decimal from enum import Enum, auto from math import isclose @@ -492,3 +492,21 @@ def test_numeric_to_float_binary_raises_for_invalid_length(length): length, # invalid length 8, ) + + +@pytest.mark.parametrize( + "value, scale, length", + [ + (Decimal("12345678901234567890.123456789012345678"), 18, 16), + (Decimal("-99999999999999999999.999999999999999999"), 18, 16), + (Decimal("0.000000000000000001"), 18, 16), + (Decimal("170141183460469231731687303715884105727"), 0, 16), + (Decimal("-12345.67"), 2, 8), + ], +) +def test_numeric_in_binary_is_exact_beyond_28_digits(value, scale, length): + raw_value = int(value.scaleb(scale, context=Context(prec=60))) + data = raw_value.to_bytes(length, byteorder="big", signed=True) + result = Datatypes.numeric_binary(data, 0, length, scale) + assert result == value + assert result.as_tuple().exponent == -scale