blob: 30b5101bb4d9a72d7f96a906c3b6cdc1b497c7e7 [file]
################################################################################
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
################################################################################
import re
from decimal import Decimal as BigDecimal
from decimal import Inexact
from decimal import ROUND_HALF_UP
from decimal import localcontext
from typing import Optional, Tuple
_DECIMAL_PATTERN = re.compile(
r"^(DECIMAL|NUMERIC|DEC)(?:\((\d+)(?:,\s*(\d+))?\))?$",
re.IGNORECASE,
)
class Decimal:
"""
An internal data structure representing data of DecimalType.
This data structure is immutable and might store decimal values in a
compact representation (as an integer value) if values are small enough.
Python implementation note:
``decimal_val`` is an instance of Python's standard library
``decimal.Decimal`` (imported as ``BigDecimal``), corresponding to
Java's ``java.math.BigDecimal``.
"""
# Maximum number of decimal digits a long integer can represent.
# (1e18 < Long.MAX_VALUE < 1e19)
MAX_LONG_DIGITS = 18
MAX_COMPACT_PRECISION = 18
# Powers of 10 used for compact decimal conversion.
POW10 = tuple(10 ** i for i in range(MAX_COMPACT_PRECISION + 1))
# The semantics of the fields are as follows:
#
# - precision and scale represent the SQL decimal type.
# - If decimal_val is set, it stores the complete decimal value.
# - Otherwise, the decimal value is represented by
# long_val / (10 ** scale).
#
# Note that (precision, scale) must always be correct.
#
# If precision > MAX_COMPACT_PRECISION:
# decimal_val stores the value and long_val is undefined.
# Otherwise:
# (long_val, scale) represents the value and decimal_val may be
# lazily initialized and cached.
def __init__(
self,
precision: int,
scale: int,
long_val: int,
decimal_val: Optional[BigDecimal],
):
self.precision = precision
self.scale = scale
self.long_val = long_val
self.decimal_val = decimal_val
# ----------------------------------------------------------------------
# Public Interfaces
# ----------------------------------------------------------------------
def to_big_decimal(self) -> BigDecimal:
"""
Converts this Decimal into a python builtin-in decimal.Decimal instance.
"""
if self.decimal_val is None:
with localcontext() as ctx:
ctx.prec = max(self.precision + abs(self.scale), 38)
self.decimal_val = BigDecimal(self.long_val).scaleb(-self.scale)
return self.decimal_val
def to_unscaled_long(self) -> int:
"""
Returns the unscaled integer value of this Decimal.
Raises:
ArithmeticError:
If this Decimal does not exactly fit into a long integer.
"""
if self.is_compact():
return self.long_val
with localcontext() as ctx:
bd = self.to_big_decimal()
ctx.prec = max(
len(bd.as_tuple().digits) + abs(self.scale),
self.precision,
38,
)
value = bd.scaleb(self.scale)
if value != value.to_integral_exact():
raise ArithmeticError("Decimal does not exactly fit in long.")
int_val = int(value)
if not (-(1 << 63) <= int_val <= (1 << 63) - 1):
raise ArithmeticError("BigInteger out of long range")
return int_val
def to_unscaled_bytes(self) -> bytes:
"""
Returns the unscaled value encoded as a signed byte array.
"""
with localcontext() as ctx:
bd = self.to_big_decimal()
ctx.prec = max(
len(bd.as_tuple().digits) + abs(self.scale),
self.precision,
38,
)
value = bd.scaleb(self.scale)
if value != value.to_integral_exact():
raise ArithmeticError("Decimal does not exactly fit in long.")
unscaled = int(value)
length = self._get_compact_byte_length(unscaled)
return unscaled.to_bytes(length, byteorder="big", signed=True)
def is_compact(self) -> bool:
"""
Returns whether the decimal value is small enough to be stored in a long integer.
"""
return self.precision <= self.MAX_COMPACT_PRECISION
def copy(self) -> "Decimal":
"""
Returns a copy of this Decimal.
"""
return Decimal(
self.precision,
self.scale,
self.long_val,
self.decimal_val,
)
# ----------------------------------------------------------------------
# Comparison
# ----------------------------------------------------------------------
def __eq__(self, other):
if not isinstance(other, Decimal):
return False
return self.compare_to(other) == 0
def __lt__(self, other):
return self.compare_to(other) < 0
def compare_to(self, other: "Decimal") -> int:
"""
Compares this Decimal with another Decimal.
"""
if (
self.is_compact()
and other.is_compact()
and self.scale == other.scale
):
if self.long_val < other.long_val:
return -1
if self.long_val > other.long_val:
return 1
return 0
a = self.to_big_decimal()
b = other.to_big_decimal()
if a < b:
return -1
if a > b:
return 1
return 0
def __hash__(self):
return hash(self.to_big_decimal())
def __str__(self):
return format(self.to_big_decimal(), "f")
# ----------------------------------------------------------------------
# Constructor Utilities
# ----------------------------------------------------------------------
@classmethod
def from_big_decimal(
cls,
value: BigDecimal,
precision: int,
scale: int,
) -> Optional["Decimal"]:
"""
Creates a ``Decimal`` from a Python's built-in ``decimal.Decimal`` with the given precision and scale.
The value is rounded to the requested scale using ROUND_HALF_UP.
If the resulting precision exceeds the specified precision, None is returned.
Note:
Java ``BigDecimal`` provides arbitrary precision. Paimon supports
DECIMAL precision up to 38 digits, so a temporary context with
precision >= 38 is used here to match Java semantics without
modifying the global decimal context.
"""
if not value.is_finite():
raise ArithmeticError("NaN and Infinity are not supported.")
with localcontext() as ctx:
t = value.as_tuple()
ctx.prec = max(
precision + scale,
len(t.digits) + abs(t.exponent) + scale,
38,
)
value = value.quantize(
BigDecimal(1).scaleb(-scale),
rounding=ROUND_HALF_UP,
)
digits = len(value.as_tuple().digits)
if digits > precision:
return None
long_val = -1
if precision <= cls.MAX_COMPACT_PRECISION:
unscaled = value.scaleb(scale)
if unscaled != unscaled.to_integral_exact():
raise ArithmeticError(
"Decimal does not exactly fit in long."
)
long_val = int(unscaled)
return cls(
precision,
scale,
long_val,
value,
)
@classmethod
def from_unscaled_long(
cls,
unscaled_long: int,
precision: int,
scale: int,
) -> "Decimal":
"""
Creates a Decimal from an unscaled integer value with the given precision and scale.
"""
if precision <= 0 or precision > cls.MAX_LONG_DIGITS:
raise ValueError(
"precision must be between 1 and {}".format(
cls.MAX_LONG_DIGITS
)
)
return cls(
precision,
scale,
unscaled_long,
None,
)
@classmethod
def from_unscaled_bytes(
cls,
unscaled_bytes: bytes,
precision: int,
scale: int,
) -> Optional["Decimal"]:
"""
Creates a Decimal from an unscaled signed byte array.
"""
value = int.from_bytes(
unscaled_bytes,
byteorder="big",
signed=True,
)
with localcontext() as ctx:
ctx.prec = max(
len(str(abs(value))) + abs(scale),
precision,
38,
)
bd = BigDecimal(value).scaleb(-scale)
return cls.from_big_decimal(
bd,
precision,
scale,
)
@classmethod
def zero(cls, precision: int, scale: int) -> Optional["Decimal"]:
"""
Creates a Decimal representing zero with the given precision and scale.
If the precision exceeds the supported range, None is returned.
"""
if precision <= cls.MAX_COMPACT_PRECISION:
return cls(
precision,
scale,
0,
None,
)
return cls.from_big_decimal(
BigDecimal(0),
precision,
scale,
)
# ----------------------------------------------------------------------
# Arithmetic Operations
# ----------------------------------------------------------------------
@staticmethod
def add(lhs: BigDecimal, rhs: BigDecimal, precision: int) -> BigDecimal:
"""
Returns the sum of two decimal values without losing precision due to
Python's default decimal context.
The operation is performed in a temporary high-precision context to
emulate Java BigDecimal arithmetic.
"""
assert isinstance(
lhs, BigDecimal
), f"'lhs' must be a decimal.Decimal, got {type(lhs).__name__}"
assert isinstance(
rhs, BigDecimal
), f"'rhs' must be a decimal.Decimal, got {type(rhs).__name__}"
with localcontext() as ctx:
ctx.prec = max(
len(lhs.as_tuple().digits),
len(rhs.as_tuple().digits),
precision,
38,
) + 1
return lhs + rhs
@staticmethod
def subtract(lhs: BigDecimal, rhs: BigDecimal, precision: int) -> BigDecimal:
"""
Returns the difference of two decimal values without losing precision due
to Python's default decimal context.
The operation is performed in a temporary high-precision context to
emulate Java BigDecimal arithmetic.
"""
assert isinstance(
lhs, BigDecimal
), f"'lhs' must be a decimal.Decimal, got {type(lhs).__name__}"
assert isinstance(
rhs, BigDecimal
), f"'rhs' must be a decimal.Decimal, got {type(rhs).__name__}"
with localcontext() as ctx:
ctx.prec = max(
len(lhs.as_tuple().digits),
len(rhs.as_tuple().digits),
precision,
38,
) + 1
return lhs - rhs
@staticmethod
def multiply(lhs: BigDecimal, rhs: BigDecimal, precision: int) -> BigDecimal:
"""
Returns the product of two decimal values without losing precision due to
Python's default decimal context.
The operation is performed in a temporary high-precision context to
emulate Java BigDecimal arithmetic.
"""
assert isinstance(
lhs, BigDecimal
), f"'lhs' must be a decimal.Decimal, got {type(lhs).__name__}"
assert isinstance(
rhs, BigDecimal
), f"'rhs' must be a decimal.Decimal, got {type(rhs).__name__}"
with localcontext() as ctx:
ctx.prec = max(
len(lhs.as_tuple().digits)
+ len(rhs.as_tuple().digits),
precision,
38,
)
return lhs * rhs
@staticmethod
def divide(lhs: BigDecimal, rhs: BigDecimal, precision: int) -> BigDecimal:
"""
Returns the exact quotient of two decimal values.
Raises:
ArithmeticError:
If the division has a non-terminating decimal expansion,
matching the behavior of Java BigDecimal.divide(BigDecimal).
"""
assert isinstance(
lhs, BigDecimal
), f"'lhs' must be a decimal.Decimal, got {type(lhs).__name__}"
assert isinstance(
rhs, BigDecimal
), f"'rhs' must be a decimal.Decimal, got {type(rhs).__name__}"
with localcontext() as ctx:
ctx.prec = max(precision, 38)
ctx.traps[Inexact] = True
try:
return lhs / rhs
except Inexact:
raise ArithmeticError(
"Non-terminating decimal expansion; "
"no exact representable decimal result."
)
# ----------------------------------------------------------------------
# Utility Methods
# ----------------------------------------------------------------------
@staticmethod
def is_compact_precision(precision: int) -> bool:
"""
Returns whether the specified precision can be stored in compact form.
"""
return precision <= Decimal.MAX_COMPACT_PRECISION
@staticmethod
def extract_decimal_precision_scale(type_str: str) -> Tuple[int, int]:
"""
Extracts the precision and scale from a DECIMAL/NUMERIC/DEC type string.
Returns: (precision, scale)
Examples:
DECIMAL -> (10, 0)
DECIMAL(10) -> (10, 0)
DECIMAL(10,2) -> (10, 2)
NUMERIC(20,5) -> (20, 5)
DEC(18,6) -> (18, 6)
"""
match = _DECIMAL_PATTERN.fullmatch(type_str.strip())
if match is None:
raise ValueError(f"Invalid decimal type: {type_str}")
precision = match.group(2)
scale = match.group(3)
if precision is None:
return 10, 0
if scale is None:
return int(precision), 0
return int(precision), int(scale)
@staticmethod
def _get_compact_byte_length(val: int) -> int:
if val == 0:
return 1
if val > 0:
bits = val.bit_length()
else:
bits = (~val).bit_length()
return bits // 8 + 1