This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git
The following commit(s) were added to refs/heads/master by this push:
new bce7744d93 [python] Align FieldSumAgg with Java implementation (#8719)
bce7744d93 is described below
commit bce7744d93a2a6002bfd49b97c3aff1f8786b279
Author: AuroraVoyage <[email protected]>
AuthorDate: Sun Jul 26 19:47:45 2026 +0800
[python] Align FieldSumAgg with Java implementation (#8719)
---
paimon-python/pypaimon/data/__init__.py | 3 +-
paimon-python/pypaimon/data/decimal.py | 507 +++++++++++++++++++++
.../pypaimon/read/merge_engine_support.py | 1 +
.../pypaimon/read/reader/aggregate/aggregators.py | 320 ++++++++++++-
paimon-python/pypaimon/tests/test_decimal.py | 255 +++++++++++
.../pypaimon/tests/test_field_aggregators.py | 359 ++++++++++++++-
6 files changed, 1426 insertions(+), 19 deletions(-)
diff --git a/paimon-python/pypaimon/data/__init__.py
b/paimon-python/pypaimon/data/__init__.py
index 6308bf139b..97f36f3d52 100644
--- a/paimon-python/pypaimon/data/__init__.py
+++ b/paimon-python/pypaimon/data/__init__.py
@@ -16,5 +16,6 @@
# under the License.
from pypaimon.data.timestamp import Timestamp
+from pypaimon.data.decimal import Decimal
-__all__ = ['Timestamp']
+__all__ = ['Timestamp', 'Decimal']
diff --git a/paimon-python/pypaimon/data/decimal.py
b/paimon-python/pypaimon/data/decimal.py
new file mode 100644
index 0000000000..30b5101bb4
--- /dev/null
+++ b/paimon-python/pypaimon/data/decimal.py
@@ -0,0 +1,507 @@
+################################################################################
+# 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
diff --git a/paimon-python/pypaimon/read/merge_engine_support.py
b/paimon-python/pypaimon/read/merge_engine_support.py
index d6b38c20c6..0624bf1e65 100644
--- a/paimon-python/pypaimon/read/merge_engine_support.py
+++ b/paimon-python/pypaimon/read/merge_engine_support.py
@@ -65,6 +65,7 @@ _AGGREGATION_SUPPORTED_AGG_FUNCS = frozenset([
"listagg",
"nested_update", "nested_partial_update",
"collect",
+ "product",
"merge_map_with_keytime",
"merge_map",
"theta_sketch",
diff --git a/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
b/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
index 4d06e303f9..0f7c4d4c31 100644
--- a/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
+++ b/paimon-python/pypaimon/read/reader/aggregate/aggregators.py
@@ -32,13 +32,13 @@ the 9 most commonly-used value aggregators: ``primary_key``
/
the registry will report them as unsupported so users see a clear
error rather than a silent fallback.
"""
-
from typing import Any, List, Dict, Optional, Tuple, Union, Set
from _datasketches import compact_theta_sketch, theta_union
from pypaimon.common.options import CoreOptions
from pypaimon.common.options.core_options import NestedKeyNullStrategy
+from pypaimon.data.decimal import Decimal
from pypaimon.read.reader.aggregate import register_aggregator
from pypaimon.read.reader.aggregate.field_aggregator import FieldAggregator
from pypaimon.schema.data_types import AtomicType, DataType, ArrayType,
RowType, MapType
@@ -57,6 +57,7 @@ NAME_LAST_NON_NULL_VALUE = "last_non_null_value"
NAME_FIRST_VALUE = "first_value"
NAME_FIRST_NON_NULL_VALUE = "first_non_null_value"
NAME_SUM = "sum"
+NAME_PRODUCT = "product"
NAME_MAX = "max"
NAME_MIN = "min"
NAME_BOOL_OR = "bool_or"
@@ -70,6 +71,16 @@ NAME_MERGE_MAP = "merge_map"
NAME_THETA_SKETCH = "theta_sketch"
+# Integer range limits used for overflow checking.
+_BYTE_MIN = -128
+_BYTE_MAX = 127
+_SHORT_MIN = -32768
+_SHORT_MAX = 32767
+_INT_MIN = -(1 << 31)
+_INT_MAX = (1 << 31) - 1
+_LONG_MIN = -(1 << 63)
+_LONG_MAX = (1 << 63) - 1
+
# Base SQL type names treated as numeric for sum/product-style
# aggregators. NUMERIC / DEC are SQL synonyms accepted by the parser;
# treat them the same as DECIMAL.
@@ -78,6 +89,16 @@ _NUMERIC_BASE_TYPES = frozenset([
"FLOAT", "DOUBLE", "DECIMAL", "NUMERIC", "DEC",
])
+# SQL type names treated as decimal. NUMERIC / DEC are SQL
+# synonyms accepted by the parser; treat them the same as DECIMAL.
+_DECIMAL_TYPES = frozenset({"DECIMAL", "NUMERIC", "DEC"})
+
+# SQL type names treated as integer.
+_INT_TYPES = frozenset({"INT", "INTEGER"})
+
+# SQL type names treated as floating-point.
+_FLOAT_TYPES = frozenset({"FLOAT", "DOUBLE"})
+
def _atomic_base_name(field_type: DataType):
"""Extract the bare SQL type name from an :class:`AtomicType`,
@@ -346,17 +367,301 @@ class FieldFirstNonNullValueAgg(FieldAggregator):
class FieldSumAgg(FieldAggregator):
- """Numeric sum. ``None`` on either side returns the non-null
- operand. Python's native ``+`` works uniformly for int / float /
- Decimal — the values produced by the pyarrow read path already
- arrive as the right Python primitive for the column's SQL type, so
- no per-type branching is needed.
"""
+ Numeric sum aggregator.
+
+ Returns the non-null operand if either side is ``None``. Performs
+ overflow checking for integral types and preserves decimal
+ precision and scale for DECIMAL values.
+ """
+ def __init__(self, name: str, field_type: DataType):
+ super().__init__(name, field_type)
+ self._base_type = _atomic_base_name(field_type)
+ if self._base_type in _DECIMAL_TYPES:
+ self._precision, self._scale =
Decimal.extract_decimal_precision_scale(field_type.type)
+ else:
+ self._precision = None
+ self._scale = None
def agg(self, accumulator: Any, input_field: Any) -> Any:
if accumulator is None or input_field is None:
return accumulator if input_field is None else input_field
- return accumulator + input_field
+
+ if self._base_type in _DECIMAL_TYPES:
+ result = Decimal.add(accumulator, input_field, self._precision)
+
+ value = Decimal.from_big_decimal(
+ result,
+ self._precision,
+ self._scale
+ )
+ return None if value is None else value.to_big_decimal()
+
+ elif self._base_type == "TINYINT":
+ value = accumulator + input_field
+ if value < _BYTE_MIN or value > _BYTE_MAX:
+ raise ArithmeticError(
+ "byte overflow: {} + {} = {}".format(accumulator,
input_field, value)
+ )
+ return value
+
+ elif self._base_type == "SMALLINT":
+ value = accumulator + input_field
+ if value < _SHORT_MIN or value > _SHORT_MAX:
+ raise ArithmeticError(
+ "short overflow: {} + {} = {}".format(accumulator,
input_field, value)
+ )
+ return value
+
+ elif self._base_type in _INT_TYPES:
+ value = accumulator + input_field
+ if value < _INT_MIN or value > _INT_MAX:
+ raise ArithmeticError(
+ "int overflow: {} + {}".format(accumulator, input_field)
+ )
+ return value
+
+ elif self._base_type == "BIGINT":
+ value = accumulator + input_field
+ if value < _LONG_MIN or value > _LONG_MAX:
+ raise ArithmeticError(
+ "long overflow: {} + {}".format(accumulator, input_field)
+ )
+ return value
+
+ elif self._base_type in _FLOAT_TYPES:
+ return accumulator + input_field
+
+ raise ValueError(
+ "type {} not support in {}".format(self._base_type,
self.__class__.__name__)
+ )
+
+ def retract(self, accumulator: Any, retract_field: Any) -> Any:
+ if accumulator is None or retract_field is None:
+ return self._negative(retract_field) if accumulator is None else
accumulator
+
+ if self._base_type in _DECIMAL_TYPES:
+ result = Decimal.subtract(accumulator, retract_field,
self._precision)
+
+ value = Decimal.from_big_decimal(
+ result,
+ self._precision,
+ self._scale,
+ )
+ return None if value is None else value.to_big_decimal()
+
+ elif self._base_type == "TINYINT":
+ value = accumulator - retract_field
+ if value < _BYTE_MIN or value > _BYTE_MAX:
+ raise ArithmeticError(
+ "byte overflow: {} - {} = {}".format(accumulator,
retract_field, value)
+ )
+ return value
+
+ elif self._base_type == "SMALLINT":
+ value = accumulator - retract_field
+ if value < _SHORT_MIN or value > _SHORT_MAX:
+ raise ArithmeticError(
+ "short overflow: {} - {} = {}".format(accumulator,
retract_field, value)
+ )
+ return value
+
+ elif self._base_type in _INT_TYPES:
+ value = accumulator - retract_field
+ if value < _INT_MIN or value > _INT_MAX:
+ raise ArithmeticError(
+ "int overflow: {} - {}".format(accumulator, retract_field)
+ )
+ return value
+
+ elif self._base_type == "BIGINT":
+ value = accumulator - retract_field
+ if value < _LONG_MIN or value > _LONG_MAX:
+ raise ArithmeticError(
+ "long overflow: {} - {}".format(accumulator,
retract_field)
+ )
+ return value
+
+ elif self._base_type in _FLOAT_TYPES:
+ return accumulator - retract_field
+
+ raise ValueError(
+ "type {} not support in {}".format(self._base_type,
self.__class__.__name__)
+ )
+
+ def _negative(self, value: Any) -> Any:
+ if value is None:
+ return None
+
+ if self._base_type in _DECIMAL_TYPES:
+ return -value
+
+ elif self._base_type == "TINYINT":
+ result = -value
+ if result < _BYTE_MIN or result > _BYTE_MAX:
+ raise ArithmeticError("byte overflow: -{} = {}".format(value,
result))
+ return result
+
+ elif self._base_type == "SMALLINT":
+ result = -value
+ if result < _SHORT_MIN or result > _SHORT_MAX:
+ raise ArithmeticError("short overflow: -{} = {}".format(value,
result))
+ return result
+
+ elif self._base_type in _INT_TYPES:
+ result = -value
+ if result < _INT_MIN or result > _INT_MAX:
+ raise ArithmeticError("int overflow: -{}".format(value))
+ return result
+
+ elif self._base_type == "BIGINT":
+ result = -value
+ if result < _LONG_MIN or result > _LONG_MAX:
+ raise ArithmeticError("long overflow: -{}".format(value))
+ return result
+
+ elif self._base_type in _FLOAT_TYPES:
+ return -value
+
+ raise ValueError(
+ "type {} not support in {}".format(self._base_type,
self.__class__.__name__)
+ )
+
+
+class FieldProductAgg(FieldAggregator):
+ """
+ Numeric product aggregator.
+
+ Null values are ignored and the non-null operand is returned.
+ Otherwise, returns the product of accumulator and input value.
+ """
+ def __init__(self, name: str, field_type: DataType):
+ super().__init__(name, field_type)
+ self._base_type = _atomic_base_name(field_type)
+ if self._base_type in _DECIMAL_TYPES:
+ self._precision, self._scale =
Decimal.extract_decimal_precision_scale(field_type.type)
+ else:
+ self._precision = None
+ self._scale = None
+
+ def agg(self, accumulator: Any, input_field: Any) -> Any:
+ if accumulator is None or input_field is None:
+ return accumulator if input_field is None else input_field
+
+ if self._base_type in _DECIMAL_TYPES:
+ mul = Decimal.multiply(accumulator, input_field, self._precision)
+
+ value = Decimal.from_big_decimal(
+ mul,
+ self._precision,
+ self._scale,
+ )
+ return None if value is None else value.to_big_decimal()
+
+ elif self._base_type == "TINYINT":
+ value = accumulator * input_field
+ if value < _BYTE_MIN or value > _BYTE_MAX:
+ raise ArithmeticError(
+ "byte overflow: {} * {} = {}".format(accumulator,
input_field, value)
+ )
+ return value
+
+ elif self._base_type == "SMALLINT":
+ value = accumulator * input_field
+ if value < _SHORT_MIN or value > _SHORT_MAX:
+ raise ArithmeticError(
+ "short overflow: {} * {} = {}".format(accumulator,
input_field, value)
+ )
+ return value
+
+ elif self._base_type in _INT_TYPES:
+ value = accumulator * input_field
+ if value < _INT_MIN or value > _INT_MAX:
+ raise ArithmeticError(
+ "int overflow: {} * {}".format(accumulator, input_field)
+ )
+ return value
+
+ elif self._base_type == "BIGINT":
+ value = accumulator * input_field
+ if value < _LONG_MIN or value > _LONG_MAX:
+ raise ArithmeticError(
+ "long overflow: {} * {}".format(accumulator, input_field)
+ )
+ return value
+
+ elif self._base_type in _FLOAT_TYPES:
+ return accumulator * input_field
+
+ raise ValueError(
+ "type {} not support in {}".format(self._base_type,
self.__class__.__name__)
+ )
+
+ def retract(self, accumulator: Any, retract_field: Any) -> Any:
+ if accumulator is None or retract_field is None:
+ return accumulator
+
+ if self._base_type in _DECIMAL_TYPES:
+ div = Decimal.divide(accumulator, retract_field, self._precision)
+
+ value = Decimal.from_big_decimal(
+ div,
+ self._precision,
+ self._scale,
+ )
+ return None if value is None else value.to_big_decimal()
+
+ elif self._base_type == "TINYINT":
+ value = int(accumulator / retract_field)
+ if value > _BYTE_MAX or value < _BYTE_MIN:
+ raise ArithmeticError(
+ "byte overflow: {} / {} = {}".format(accumulator,
retract_field, value)
+ )
+ return value
+
+ elif self._base_type == "SMALLINT":
+ value = int(accumulator / retract_field)
+ if value > _SHORT_MAX or value < _SHORT_MIN:
+ raise ArithmeticError(
+ "short overflow: {} / {} = {}".format(accumulator,
retract_field, value)
+ )
+ return value
+
+ elif self._base_type in _INT_TYPES:
+ if accumulator == _INT_MIN and retract_field == -1:
+ raise ArithmeticError(
+ "int overflow: {} / {}".format(accumulator, retract_field)
+ )
+ return int(accumulator / retract_field)
+
+ elif self._base_type == "BIGINT":
+ if accumulator == _LONG_MIN and retract_field == -1:
+ raise ArithmeticError(
+ "long overflow: {} / {}".format(accumulator, retract_field)
+ )
+
+ # Java integer division truncates toward zero, while Python's "//"
+ # floors toward negative infinity. Divide absolute values first,
then
+ # restore the sign to match Java semantics without converting
through
+ # float (which would lose precision for BIGINT values).
+ if (accumulator >= 0) == (retract_field >= 0):
+ return abs(accumulator) // abs(retract_field)
+ else:
+ return -(abs(accumulator) // abs(retract_field))
+
+ elif self._base_type in _FLOAT_TYPES:
+ if retract_field == 0.0:
+ if accumulator == 0.0:
+ return float("nan")
+ elif accumulator > 0:
+ return float("inf")
+ else:
+ return float("-inf")
+ return accumulator / retract_field
+
+ raise ValueError(
+ "type {} not support in {}".format(self._base_type,
self.__class__.__name__)
+ )
class FieldMaxAgg(FieldAggregator):
@@ -1167,6 +1472,7 @@ register_aggregator(
_build_no_type_check(FieldFirstNonNullValueAgg, NAME_FIRST_NON_NULL_VALUE),
)
register_aggregator(NAME_SUM, _build_numeric(FieldSumAgg, NAME_SUM))
+register_aggregator(NAME_PRODUCT, _build_numeric(FieldProductAgg,
NAME_PRODUCT))
register_aggregator(NAME_MAX, _build_no_type_check(FieldMaxAgg, NAME_MAX))
register_aggregator(NAME_MIN, _build_no_type_check(FieldMinAgg, NAME_MIN))
register_aggregator(
diff --git a/paimon-python/pypaimon/tests/test_decimal.py
b/paimon-python/pypaimon/tests/test_decimal.py
new file mode 100644
index 0000000000..7853aecc0e
--- /dev/null
+++ b/paimon-python/pypaimon/tests/test_decimal.py
@@ -0,0 +1,255 @@
+# 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 unittest
+from decimal import Decimal as BigDecimal
+
+from pypaimon.data.decimal import Decimal
+
+
+class DecimalTest(unittest.TestCase):
+
+ def test_from_big_decimal(self):
+ d = Decimal.from_big_decimal(BigDecimal("12.34"), precision=10,
scale=2)
+
+ self.assertIsNotNone(d)
+ self.assertEqual(d.precision, 10)
+ self.assertEqual(d.scale, 2)
+ self.assertEqual(d.to_big_decimal(), BigDecimal("12.34"))
+ self.assertEqual(d.to_unscaled_long(), 1234)
+
+ def test_from_big_decimal_overflow(self):
+ d = Decimal.from_big_decimal(BigDecimal("12345678901.23"),
precision=10, scale=2)
+ self.assertIsNone(d)
+
+ def test_from_unscaled_long(self):
+ d = Decimal.from_unscaled_long(1234, precision=10, scale=2)
+
+ self.assertEqual(d.precision, 10)
+ self.assertEqual(d.scale, 2)
+ self.assertEqual(d.to_big_decimal(), BigDecimal("12.34"))
+
+ def test_from_unscaled_bytes(self):
+ d = Decimal.from_big_decimal(BigDecimal("12.34"), 10, 2)
+
+ d2 = Decimal.from_unscaled_bytes(
+ d.to_unscaled_bytes(),
+ precision=10,
+ scale=2,
+ )
+
+ self.assertEqual(d2, d)
+
+ def test_zero(self):
+ d = Decimal.zero(precision=10, scale=2)
+ self.assertEqual(d.to_big_decimal(), BigDecimal("0.00"))
+
+ def test_copy(self):
+ d = Decimal.from_big_decimal(BigDecimal("12.34"), 10, 2)
+ copy = d.copy()
+
+ self.assertEqual(copy, d)
+ self.assertIsNot(copy, d)
+
+ def test_compare(self):
+ d1 = Decimal.from_big_decimal(BigDecimal("1.23"), 10, 2)
+ d2 = Decimal.from_big_decimal(BigDecimal("2.34"), 10, 2)
+
+ self.assertLess(d1.compare_to(d2), 0)
+ self.assertGreater(d2.compare_to(d1), 0)
+ self.assertEqual(d1.compare_to(d1.copy()), 0)
+
+ def test_hash(self):
+ d1 = Decimal.from_big_decimal(BigDecimal("12.34"), 10, 2)
+ d2 = Decimal.from_big_decimal(BigDecimal("12.34"), 10, 2)
+
+ self.assertEqual(hash(d1), hash(d2))
+
+ def test_to_big_decimal(self):
+ d = Decimal.from_unscaled_long(1234, 10, 2)
+ self.assertEqual(d.to_big_decimal(), BigDecimal("12.34"))
+
+ def test_to_unscaled_long(self):
+ d = Decimal.from_big_decimal(BigDecimal("12.34"), 10, 2)
+ self.assertEqual(d.to_unscaled_long(), 1234)
+
+ def test_to_unscaled_bytes(self):
+ d = Decimal.from_big_decimal(BigDecimal("12.34"), 10, 2)
+
+ self.assertEqual(
+ int.from_bytes(d.to_unscaled_bytes(), "big", signed=True),
+ 1234,
+ )
+
+ def test_to_string(self):
+ self.assertEqual(
+ str(Decimal.from_big_decimal(BigDecimal("12.34"), 10, 2)),
+ "12.34",
+ )
+
+ self.assertEqual(
+ str(Decimal.from_big_decimal(BigDecimal("0.0000000000000000001"),
39, 19)),
+ "0.0000000000000000001",
+ )
+
+ def test_is_compact(self):
+ self.assertTrue(Decimal.is_compact_precision(18))
+ self.assertFalse(Decimal.is_compact_precision(19))
+
+ self.assertTrue(Decimal.from_big_decimal(BigDecimal("1"), 18,
0).is_compact())
+ self.assertFalse(Decimal.from_big_decimal(BigDecimal("1"), 19,
0).is_compact())
+
+ def test_extract_decimal_precision_scale(self):
+ self.assertEqual(Decimal.extract_decimal_precision_scale("DECIMAL"),
(10, 0))
+
self.assertEqual(Decimal.extract_decimal_precision_scale("DECIMAL(20)"), (20,
0))
+
self.assertEqual(Decimal.extract_decimal_precision_scale("DECIMAL(20,5)"), (20,
5))
+
self.assertEqual(Decimal.extract_decimal_precision_scale("NUMERIC(18,2)"), (18,
2))
+ self.assertEqual(Decimal.extract_decimal_precision_scale("DEC(8,3)"),
(8, 3))
+
+ def test_extract_decimal_precision_scale_invalid(self):
+ with self.assertRaises(ValueError):
+ Decimal.extract_decimal_precision_scale("INT")
+
+ def test_high_precision_unscaled_bytes_round_trip(self):
+ value = BigDecimal("12345678901234567890123456789012345678")
+
+ d = Decimal.from_big_decimal(value, precision=38, scale=0)
+
+ self.assertIsNotNone(d)
+
+ encoded = d.to_unscaled_bytes()
+
+ d2 = Decimal.from_unscaled_bytes(
+ encoded,
+ precision=38,
+ scale=0,
+ )
+
+ self.assertEqual(d2, d)
+ self.assertEqual(d2.to_big_decimal(), value)
+
+ def test_high_precision_scaled_round_trip(self):
+ value = BigDecimal("12345678901234567890.123456789012345678")
+
+ d = Decimal.from_big_decimal(
+ value,
+ precision=38,
+ scale=18,
+ )
+
+ self.assertIsNotNone(d)
+
+ d2 = Decimal.from_unscaled_bytes(
+ d.to_unscaled_bytes(),
+ precision=38,
+ scale=18,
+ )
+
+ self.assertEqual(d2.to_big_decimal(), value)
+
+ def test_to_unscaled_bytes_java_compatible(self):
+ cases = [
+ (-32769, "ff7fff"),
+ (-32768, "8000"),
+ (-129, "ff7f"),
+ (-128, "80"),
+ (-127, "81"),
+ (-2, "fe"),
+ (-1, "ff"),
+ (0, "00"),
+ (1, "01"),
+ (127, "7f"),
+ (128, "0080"),
+ (255, "00ff"),
+ (256, "0100"),
+ (32767, "7fff"),
+ (32768, "008000"),
+ ]
+
+ for unscaled, expected in cases:
+ with self.subTest(unscaled=unscaled):
+ d = Decimal.from_unscaled_long(
+ unscaled,
+ precision=18,
+ scale=0,
+ )
+
+ self.assertEqual(
+ d.to_unscaled_bytes().hex(),
+ expected,
+ )
+
+ def test_from_unscaled_bytes_java_compatible(self):
+ cases = [
+ ("80", -128),
+ ("ff7f", -129),
+ ("ff", -1),
+ ("00", 0),
+ ("7f", 127),
+ ("0080", 128),
+ ("00ff", 255),
+ ]
+
+ for encoded, expected in cases:
+ with self.subTest(encoded=encoded):
+ d = Decimal.from_unscaled_bytes(
+ bytes.fromhex(encoded),
+ precision=18,
+ scale=0,
+ )
+
+ self.assertEqual(
+ d.to_unscaled_long(),
+ expected,
+ )
+
+ def test_unscaled_bytes_round_trip(self):
+ values = [
+ -123456789012345678,
+ -1000,
+ -129,
+ -128,
+ -127,
+ -1,
+ 0,
+ 1,
+ 127,
+ 128,
+ 255,
+ 256,
+ 123456789012345678,
+ ]
+
+ for value in values:
+ with self.subTest(value=value):
+ d = Decimal.from_unscaled_long(
+ value,
+ precision=18,
+ scale=0,
+ )
+
+ d2 = Decimal.from_unscaled_bytes(
+ d.to_unscaled_bytes(),
+ precision=18,
+ scale=0,
+ )
+
+ self.assertEqual(d2, d)
+
+
+if __name__ == '__main__':
+ unittest.main()
diff --git a/paimon-python/pypaimon/tests/test_field_aggregators.py
b/paimon-python/pypaimon/tests/test_field_aggregators.py
index f04da32a11..6d548499ca 100644
--- a/paimon-python/pypaimon/tests/test_field_aggregators.py
+++ b/paimon-python/pypaimon/tests/test_field_aggregators.py
@@ -25,15 +25,16 @@ on real PK tables lives in ``test_aggregation_e2e.py``.
"""
import datetime
+import math
import unittest
-from decimal import Decimal
+from decimal import Decimal as BigDecimal
from functools import reduce
from typing import List
from _datasketches import update_theta_sketch
from pypaimon.common.options import CoreOptions, Options
-from pypaimon.data import Timestamp
+from pypaimon.data import Timestamp, Decimal
from pypaimon.read.reader.aggregate import create_field_aggregator
from pypaimon.read.reader.aggregate.aggregators import (
FieldBoolAndAgg,
@@ -46,6 +47,7 @@ from pypaimon.read.reader.aggregate.aggregators import (
FieldMinAgg,
FieldPrimaryKeyAgg,
FieldSumAgg,
+ FieldProductAgg,
FieldListaggAgg,
FieldNestedUpdateAgg,
FieldNestedPartialUpdateAgg,
@@ -146,32 +148,367 @@ class FieldFirstNonNullValueAggTest(unittest.TestCase):
class FieldSumAggTest(unittest.TestCase):
+ def test_null_inputs_return_non_null_operand(self):
+ agg = _make("sum", "INT")
+ self.assertEqual(agg.agg(None, 5), 5)
+ self.assertEqual(agg.agg(5, None), 5)
+ self.assertIsNone(agg.agg(None, None))
+
+ def test_non_numeric_type_rejected_at_construction(self):
+ with self.assertRaises(ValueError) as ctx:
+ _make("sum", "VARCHAR")
+ self.assertIn("numeric", str(ctx.exception))
+
def test_int_sum(self):
- agg = _make("sum", "BIGINT")
+ agg = _make("sum", "INT")
self.assertIsInstance(agg, FieldSumAgg)
- self.assertEqual(agg.agg(None, 5), 5)
- self.assertEqual(agg.agg(5, 7), 12)
+
+ self.assertEqual(agg.agg(None, 10), 10)
+ self.assertEqual(agg.agg(1, 10), 11)
+ self.assertEqual(agg.retract(10, 5), 5)
+ self.assertEqual(agg.retract(None, 5), -5)
+
+ def test_byte_sum(self):
+ agg = _make("sum", "TINYINT")
+ self.assertEqual(agg.agg(None, 10), 10)
+ self.assertEqual(agg.agg(1, 10), 11)
+ self.assertEqual(agg.retract(10, 5), 5)
+ self.assertEqual(agg.retract(None, 5), -5)
+
+ def test_short_sum(self):
+ agg = _make("sum", "SMALLINT")
+ self.assertEqual(agg.agg(None, 10), 10)
+ self.assertEqual(agg.agg(1, 10), 11)
+ self.assertEqual(agg.retract(10, 5), 5)
+ self.assertEqual(agg.retract(None, 5), -5)
+
+ def test_long_sum(self):
+ agg = _make("sum", "BIGINT")
+ self.assertEqual(agg.agg(None, 10), 10)
+ self.assertEqual(agg.agg(1, 10), 11)
+ self.assertEqual(agg.retract(10, 5), 5)
+ self.assertEqual(agg.retract(None, 5), -5)
+
+ def test_byte_overflow(self):
+ agg = _make("sum", "TINYINT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(127, 1)
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(-128, -1)
+
+ def test_short_overflow(self):
+ agg = _make("sum", "SMALLINT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(32767, 1)
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(-32768, -1)
+
+ def test_int_overflow(self):
+ agg = _make("sum", "INT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(2147483647, 1)
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(-2147483648, -1)
+
+ def test_long_overflow(self):
+ agg = _make("sum", "BIGINT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(9223372036854775807, 1)
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(-9223372036854775808, -1)
+
+ def test_byte_retract_overflow(self):
+ agg = _make("sum", "TINYINT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(-128, 1)
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(127, -1)
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(None, -128)
+
+ def test_short_retract_overflow(self):
+ agg = _make("sum", "SMALLINT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(-32768, 1)
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(32767, -1)
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(None, -32768)
+
+ def test_int_retract_overflow(self):
+ agg = _make("sum", "INT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(-2147483648, 1)
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(2147483647, -1)
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(None, -2147483648)
+
+ def test_long_retract_overflow(self):
+ agg = _make("sum", "BIGINT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(-9223372036854775808, 1)
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(9223372036854775807, -1)
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(None, -9223372036854775808)
def test_float_sum(self):
+ agg = _make("sum", "FLOAT")
+
+ self.assertEqual(agg.agg(None, 10.0), 10.0)
+ self.assertEqual(agg.agg(1.0, 10.0), 11.0)
+ self.assertEqual(agg.retract(10.0, 5.0), 5.0)
+ self.assertEqual(agg.retract(None, 5.0), -5.0)
+
+ def test_double_sum(self):
agg = _make("sum", "DOUBLE")
- self.assertAlmostEqual(agg.agg(1.5, 2.25), 3.75)
+
+ self.assertEqual(agg.agg(1.5, 2.25), 3.75)
+ self.assertEqual(agg.agg(None, 10.0), 10.0)
+ self.assertEqual(agg.agg(1.0, 10.0), 11.0)
+ self.assertEqual(agg.retract(10.0, 5.0), 5.0)
+ self.assertEqual(agg.retract(None, 5.0), -5.0)
def test_decimal_sum(self):
agg = _make("sum", "DECIMAL(10,2)")
- result = agg.agg(Decimal("1.23"), Decimal("4.56"))
- self.assertEqual(result, Decimal("5.79"))
- def test_null_inputs_return_non_null_operand(self):
- agg = _make("sum", "INT")
+ self.assertEqual(agg.agg(None, BigDecimal("10.00")),
BigDecimal("10.00"))
+ self.assertEqual(agg.agg(BigDecimal("1.23"), BigDecimal("4.56")),
BigDecimal("5.79"))
+ self.assertEqual(agg.retract(BigDecimal("10.00"), BigDecimal("5.00")),
BigDecimal("5.00"))
+ self.assertEqual(agg.retract(None, BigDecimal("5.00")),
BigDecimal("-5.00"))
+
+ def test_decimal_high_precision_sum(self):
+ agg = _make("sum", "DECIMAL(38,0)")
+
+ result = agg.agg(
+ BigDecimal("12345678901234567890123456789012345678"),
+ BigDecimal("1"),
+ )
+
+ self.assertEqual(
+ result,
+ BigDecimal("12345678901234567890123456789012345679"),
+ )
+
+
+class FieldProductAggTest(unittest.TestCase):
+
+ @staticmethod
+ def to_decimal(value, precision: int = 10, scale: int = 0) -> "BigDecimal":
+ return Decimal.from_big_decimal(BigDecimal(str(value)), precision,
scale).to_big_decimal()
+
+ def test_int(self):
+ agg = _make("product", "INT")
+ self.assertIsInstance(agg, FieldProductAgg)
+
+ self.assertEqual(agg.agg(None, 10), 10)
+ self.assertEqual(agg.agg(1, 10), 10)
+ self.assertEqual(agg.retract(10, 5), 2)
+ self.assertIsNone(agg.retract(None, 5))
+
+ def test_byte(self):
+ agg = _make("product", "TINYINT")
+
+ self.assertEqual(agg.agg(None, 10), 10)
+ self.assertEqual(agg.agg(1, 10), 10)
+ self.assertEqual(agg.retract(10, 5), 2)
+ self.assertIsNone(agg.retract(None, 5))
+
+ def test_short(self):
+ agg = _make("product", "SMALLINT")
+
+ self.assertEqual(agg.agg(None, 10), 10)
+ self.assertEqual(agg.agg(1, 10), 10)
+ self.assertEqual(agg.retract(10, 5), 2)
+ self.assertIsNone(agg.retract(None, 5))
+
+ def test_long(self):
+ agg = _make("product", "BIGINT")
+
+ self.assertEqual(agg.agg(None, 10), 10)
+ self.assertEqual(agg.agg(1, 10), 10)
+ self.assertEqual(agg.retract(10, 5), 2)
+ self.assertIsNone(agg.retract(None, 5))
+
+ def test_float(self):
+ agg = _make("product", "FLOAT")
+
+ self.assertEqual(agg.agg(None, 10.0), 10.0)
+ self.assertEqual(agg.agg(1.0, 10.0), 10.0)
+ self.assertEqual(agg.retract(10.0, 5.0), 2.0)
+ self.assertIsNone(agg.retract(None, 5.0))
+
+ def test_double(self):
+ agg = _make("product", "DOUBLE")
+
+ self.assertEqual(agg.agg(None, 10.0), 10.0)
+ self.assertEqual(agg.agg(1.0, 10.0), 10.0)
+ self.assertEqual(agg.retract(10.0, 5.0), 2.0)
+ self.assertIsNone(agg.retract(None, 5.0))
+
+ def test_decimal_no_precision_scale(self):
+ agg = _make("product", "DECIMAL")
+
+ self.assertEqual(agg.agg(None, self.to_decimal(10)),
self.to_decimal(10))
+ self.assertEqual(agg.agg(self.to_decimal(1), self.to_decimal(10)),
self.to_decimal(10))
+ self.assertEqual(agg.agg(self.to_decimal(1.3), self.to_decimal(10)),
self.to_decimal(10))
+ self.assertEqual(agg.agg(self.to_decimal(1.5), self.to_decimal(10)),
self.to_decimal(20))
+ self.assertEqual(agg.retract(self.to_decimal(10), self.to_decimal(5)),
self.to_decimal(2))
+ self.assertIsNone(agg.retract(None, self.to_decimal(5)))
+
+ def test_decimal(self):
+ agg = _make("product", "DECIMAL(8,2)")
+
+ self.assertEqual(agg.agg(BigDecimal("1.50"), BigDecimal("2.00")),
BigDecimal("3.00"))
+ self.assertEqual(agg.agg(BigDecimal("1.50"), BigDecimal("2.01")),
BigDecimal("3.02"))
+ self.assertEqual(agg.retract(BigDecimal("3.00"), BigDecimal("2.00")),
BigDecimal("1.50"))
+ self.assertEqual(agg.agg(None, self.to_decimal(10.15)),
self.to_decimal(10.15))
+ self.assertIsNone(agg.retract(None, self.to_decimal(5.02)))
+
+ def test_numeric(self):
+ agg = _make("product", "NUMERIC(12,2)")
+
+ self.assertEqual(agg.agg(None, self.to_decimal(10, 12, 2)),
self.to_decimal(10, 12, 2))
+ self.assertEqual(agg.agg(self.to_decimal(1), self.to_decimal(10, 12,
2)), self.to_decimal(10, 12, 2))
+ self.assertEqual(agg.retract(self.to_decimal(10), self.to_decimal(5,
12, 2)), self.to_decimal(2, 12, 2))
+ self.assertIsNone(agg.retract(None, self.to_decimal(5, 12, 2)))
+
+ def test_dec(self):
+ agg = _make("product", "DEC(12)")
+
+ self.assertEqual(agg.agg(None, self.to_decimal(10, 12)),
self.to_decimal(10, 12))
+ self.assertEqual(agg.agg(self.to_decimal(1), self.to_decimal(10, 12)),
self.to_decimal(10, 12))
+ self.assertEqual(agg.retract(self.to_decimal(10), self.to_decimal(5,
12)), self.to_decimal(2, 12))
+ self.assertIsNone(agg.retract(None, self.to_decimal(5, 12)))
+
+ def test_byte_overflow(self):
+ agg = _make("product", "TINYINT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(64, 2)
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(-64, 4)
+
+ def test_short_overflow(self):
+ agg = _make("product", "SMALLINT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(1000, 100)
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(-32768, 2)
+
+ def test_int_overflow(self):
+ agg = _make("product", "INT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(100000, 100000)
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(-2147483648, -1)
+
+ def test_long_overflow(self):
+ agg = _make("product", "BIGINT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(9223372036854775807, 2)
+
+ with self.assertRaises(ArithmeticError):
+ agg.agg(-9223372036854775808, -1)
+
+ def test_byte_retract_overflow(self):
+ agg = _make("product", "TINYINT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(-128, -1)
+
+ def test_short_retract_overflow(self):
+ agg = _make("product", "SMALLINT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(-32768, -1)
+
+ def test_int_retract_overflow(self):
+ agg = _make("product", "INT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(-2147483648, -1)
+
+ def test_long_retract_overflow(self):
+ agg = _make("product", "BIGINT")
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(-9223372036854775808, -1)
+
+ def test_null_inputs(self):
+ agg = _make("product", "INT")
+
self.assertEqual(agg.agg(None, 5), 5)
self.assertEqual(agg.agg(5, None), 5)
self.assertIsNone(agg.agg(None, None))
+ self.assertEqual(agg.retract(5, None), 5)
+ self.assertIsNone(agg.retract(None, 5))
+ self.assertIsNone(agg.retract(None, None))
+
def test_non_numeric_type_rejected_at_construction(self):
with self.assertRaises(ValueError) as ctx:
- _make("sum", "VARCHAR")
+ _make("product", "VARCHAR")
+
self.assertIn("numeric", str(ctx.exception))
+ def test_decimal_high_precision_product(self):
+ agg = _make("product", "DECIMAL(38,0)")
+
+ left = BigDecimal("1234567890123456789")
+ right = BigDecimal("1000000000000000001")
+
+ result = agg.agg(left, right)
+
+ self.assertEqual(
+ result,
+ BigDecimal("1234567890123456790234567890123456789"),
+ )
+
+ def test_decimal_divide_requires_exact_result(self):
+ agg = _make("product", "DECIMAL(38,0)")
+
+ with self.assertRaises(ArithmeticError):
+ agg.retract(
+ BigDecimal("1"),
+ BigDecimal("3"),
+ )
+
+ def test_float_divide_by_zero(self):
+ agg = _make("product", "FLOAT")
+
+ self.assertTrue(math.isnan(agg.retract(0.0, 0.0)))
+ self.assertTrue(math.isinf(agg.retract(1.0, 0.0)))
+ self.assertEqual(agg.retract(1.0, 0.0), float("inf"))
+ self.assertEqual(agg.retract(-1.0, 0.0), float("-inf"))
+
class FieldMaxAggTest(unittest.TestCase):