diff --git a/pyiceberg/utils/decimal.py b/pyiceberg/utils/decimal.py index 5ef82640d9..0ed2de1d5b 100644 --- a/pyiceberg/utils/decimal.py +++ b/pyiceberg/utils/decimal.py @@ -58,11 +58,16 @@ def bytes_required(value: int | Decimal) -> int: int: the minimum number of bytes needed to serialize the value. """ if isinstance(value, int): - return (value.bit_length() + 8) // 8 + unscaled = value elif isinstance(value, Decimal): - return (decimal_to_unscaled(value).bit_length() + 8) // 8 - - raise ValueError(f"Unsupported value: {value}") + unscaled = decimal_to_unscaled(value) + else: + raise ValueError(f"Unsupported value: {value}") + + # bit_length() ignores the sign, so -128 appears to need 9 signed bits instead of 8. + # Adding 1 before counting avoids the extra byte at negative boundaries. + num_bits = unscaled.bit_length() if unscaled >= 0 else (unscaled + 1).bit_length() + return (num_bits + 8) // 8 def decimal_to_bytes(value: Decimal, byte_length: int | None = None) -> bytes: diff --git a/tests/test_transforms.py b/tests/test_transforms.py index d296fcdb21..c977fcea14 100644 --- a/tests/test_transforms.py +++ b/tests/test_transforms.py @@ -187,6 +187,7 @@ def test_bucket_hash_values(test_input: Any, test_type: PrimitiveType, expected: (BucketTransform(100).transform(TimeType()), 81068000000, 59), (BucketTransform(100).transform(TimestampType()), 1510871468000000, 7), (BucketTransform(100).transform(DecimalType(9, 2)), Decimal("14.20"), 59), + (BucketTransform(16).transform(DecimalType(5, 2)), Decimal("-1.28"), 13), (BucketTransform(100).transform(StringType()), "iceberg", 89), ( BucketTransform(100).transform(UUIDType()), diff --git a/tests/utils/test_decimal.py b/tests/utils/test_decimal.py index 3e67bf691a..d50f7c5707 100644 --- a/tests/utils/test_decimal.py +++ b/tests/utils/test_decimal.py @@ -18,7 +18,7 @@ import pytest -from pyiceberg.utils.decimal import decimal_required_bytes, decimal_to_bytes +from pyiceberg.utils.decimal import bytes_required, decimal_required_bytes, decimal_to_bytes def test_decimal_required_bytes() -> None: @@ -42,8 +42,26 @@ def test_decimal_required_bytes() -> None: assert "(0, 40]" in str(exc_info.value) +def test_bytes_required() -> None: + assert bytes_required(0) == 1 + assert bytes_required(127) == 1 + assert bytes_required(128) == 2 + # Check negative signed-byte boundaries and their neighbors. + assert bytes_required(-127) == 1 + assert bytes_required(-128) == 1 + assert bytes_required(-129) == 2 + assert bytes_required(-32768) == 2 + assert bytes_required(-8388608) == 3 + assert bytes_required(Decimal("1.27")) == 1 + assert bytes_required(Decimal("-1.28")) == 1 + assert bytes_required(Decimal("-327.68")) == 2 + + def test_decimal_to_bytes() -> None: # Check the boundary between 2 and 3 bytes. # 2 bytes has a minimum of -32,768 and a maximum value of 32,767 (inclusive). assert decimal_to_bytes(Decimal("32767.")) == b"\x7f\xff" assert decimal_to_bytes(Decimal("32768.")) == b"\x00\x80\x00" + # Unscaled values -128 and -32768 require no sign padding. + assert decimal_to_bytes(Decimal("-1.28")) == b"\x80" + assert decimal_to_bytes(Decimal("-327.68")) == b"\x80\x00"