blob: 4d1a960db960119a98e95b1e1203431bf13fd495 [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.
################################################################################
"""Built-in :class:`FieldAggregator` implementations.
Each class registers itself with the global registry at import time
via :func:`register_aggregator`, so importing
``pypaimon.read.reader.aggregate`` makes all of them discoverable.
This module ships 10 aggregators — the primary-key placeholder plus
the 9 most commonly-used value aggregators: ``primary_key`` /
``last_value`` / ``last_non_null_value`` / ``first_value`` /
``first_non_null_value`` / ``sum`` / ``max`` / ``min`` / ``bool_or``
/ ``bool_and``. Other aggregators (``product`` / ``listagg`` /
``collect`` / ``merge_map`` / ``nested_update`` / ``theta_sketch`` /
``hll_sketch`` / ``roaring_bitmap_*``) are intentionally deferred —
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
from pypaimon.table.row.generic_row import GenericRow
from pypaimon.table.row.internal_row import InternalRow
from pypaimon.utils.roaring_bitmap import RoaringBitmap
# aggregator input type hints variables
Record = Union[InternalRow, Dict[str, Any]]
# Aggregator identifiers exposed via ``fields.<name>.aggregate-function``
# and ``fields.default-aggregate-function``.
NAME_PRIMARY_KEY = "primary_key"
NAME_LAST_VALUE = "last_value"
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"
NAME_BOOL_AND = "bool_and"
NAME_LISTAGG = "listagg"
NAME_NESTED_UPDATE = "nested_update"
NAME_NESTED_PARTIAL_UPDATE = "nested_partial_update"
NAME_COLLECT = "collect"
NAME_MERGE_MAP_WITH_KEYTIME = "merge_map_with_keytime"
NAME_MERGE_MAP = "merge_map"
NAME_THETA_SKETCH = "theta_sketch"
NAME_RBM32 = "rbm32"
# 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.
_NUMERIC_BASE_TYPES = frozenset([
"TINYINT", "SMALLINT", "INT", "INTEGER", "BIGINT",
"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`,
stripping precision arguments (``DECIMAL(10,2)``) and trailing
``NOT NULL``. Returns ``None`` for non-atomic types so callers can
raise a uniform "unsupported type" error.
"""
if not isinstance(field_type, AtomicType):
return None
raw = field_type.type
head = raw.split('(', 1)[0].split(' ', 1)[0]
return head.upper()
def _check_numeric(name: str, field_type: DataType) -> None:
base = _atomic_base_name(field_type)
if base not in _NUMERIC_BASE_TYPES:
raise ValueError(
"Data type for '{}' column must be a numeric type but was "
"'{}'.".format(name, field_type)
)
def _check_boolean(name: str, field_type: DataType) -> None:
base = _atomic_base_name(field_type)
if base != "BOOLEAN":
raise ValueError(
"Data type for '{}' column must be 'BOOLEAN' but was "
"'{}'.".format(name, field_type)
)
def _check_array_row(name: str, field_type: DataType) -> ArrayType:
"""Check field_type is ARRAY<ROW> and return the ArrayType."""
if not isinstance(field_type, ArrayType):
raise ValueError(
"Data type for '{}' column must be 'ARRAY<ROW>' but was '{}'."
.format(name, field_type)
)
if not isinstance(field_type.element, RowType):
raise ValueError(
"Data type for '{}' column must be 'ARRAY<ROW>' but was '{}'."
.format(name, field_type)
)
return field_type
def _check_roaring_bitmap(name: str, field_type: DataType):
"""Check field_type is VarBinaryType and return the VarBinaryType."""
base = _atomic_base_name(field_type)
if base not in ("VARBINARY", "BYTES"):
raise ValueError(
"Data type for {} column must be 'VARBINARY' or 'BYTES' but was "
"'{}'.".format(name, field_type)
)
return field_type
def is_blank(s: str) -> bool:
if s is None:
return True
for ch in s:
if not ch.isspace():
return False
return True
def _compare_objects(left: Any, right: Any) -> int:
"""
Compare two comparable Python objects using Paimon's ordering.
Nulls are ordered before non-null values (Nulls First).
"""
if left is None:
return 0 if right is None else -1
if right is None:
return 1
return (left > right) - (left < right)
def _compare_tuple(left: Tuple[Any, ...], right: Tuple[Any, ...]) -> int:
"""
Lexicographical comparison with Nulls First.
"""
for l, r in zip(left, right):
cmp = _compare_objects(l, r)
if cmp != 0:
return cmp
if len(left) == len(right):
return 0
return -1 if len(left) < len(right) else 1
def _row_equals(left: Record, right: Record) -> bool:
"""
Compare two records for equality.
Supports both ``InternalRow`` and ``dict`` representations.
"""
if isinstance(left, dict) and isinstance(right, dict):
return left == right
if isinstance(left, InternalRow) and isinstance(right, InternalRow):
if len(left) != len(right):
return False
for i in range(len(left)):
if left.get_field(i) != right.get_field(i):
return False
return True
raise TypeError(
"Cannot compare records of different or unsupported types: "
f"{type(left).__name__} and {type(right).__name__}. "
"Expected both records to be either InternalRow or dict."
)
class FieldProjection:
"""
Extracts selected fields from a row.
This helper is primarily used by nested aggregators (e.g.
``nested_update`` and ``nested_partial_update``) to retrieve
configured fields from nested rows.
It supports both row representations currently used by pypaimon:
* :class:`InternalRow` - fields are accessed by ordinal position.
* ``dict`` - fields are accessed by field name (used by the
PyArrow -> Polars read path).
The extracted values are returned as a tuple so they can be used
directly as comparison keys, dictionary keys, or sequence values.
"""
def __init__(
self,
index_mapping: List[int],
field_names: List[str],
):
"""
Create a FieldProjection.
Args:
index_mapping: Ordinal positions of the selected fields.
field_names: Corresponding field names. Used when the input
row is represented as a dict.
"""
if len(index_mapping) != len(field_names):
raise ValueError(
"index_mapping and field_names must have the same length."
)
self.index_mapping = index_mapping
self.field_names = field_names
@staticmethod
def from_fields(
index_mapping: List[int],
field_names: List[str],
) -> "FieldProjection":
"""Create a FieldProjection from field indexes and names."""
return FieldProjection(index_mapping, field_names)
def apply(self, element: Record) -> Tuple[Any, ...]:
"""
Return the projected fields as a tuple.
Args:
element: Either an ``InternalRow`` or a ``dict``.
Returns:
Tuple containing projected field values.
Raises:
TypeError: If the row type is unsupported.
"""
if isinstance(element, InternalRow):
return tuple(
element.get_field(index) if index >= 0 else None
for index in self.index_mapping
)
if isinstance(element, dict):
return tuple(
element.get(name)
for name in self.field_names
)
raise TypeError(
"Unsupported row type '{}', expected InternalRow or dict.".format(
type(element).__name__
)
)
# ---------------------------------------------------------------------------
# Aggregator classes
# ---------------------------------------------------------------------------
class FieldPrimaryKeyAgg(FieldAggregator):
"""Carries the primary-key column through merge unchanged."""
def agg(self, accumulator: Any, input_field: Any) -> Any:
return input_field
class FieldLastValueAgg(FieldAggregator):
"""Latest value wins, including ``None``."""
def agg(self, accumulator: Any, input_field: Any) -> Any:
return input_field
class FieldLastNonNullValueAgg(FieldAggregator):
"""Latest non-null value; ``None`` inputs are absorbed.
This is the system-wide default aggregator when no per-field
override and no ``fields.default-aggregate-function`` are set.
"""
def agg(self, accumulator: Any, input_field: Any) -> Any:
return accumulator if input_field is None else input_field
class FieldFirstValueAgg(FieldAggregator):
"""First value (including ``None``) wins; locks after the first
:meth:`agg` call until the next :meth:`reset`.
"""
def __init__(self, name: str, field_type: DataType):
super().__init__(name, field_type)
self._initialized = False
def agg(self, accumulator: Any, input_field: Any) -> Any:
if not self._initialized:
self._initialized = True
return input_field
return accumulator
def reset(self) -> None:
self._initialized = False
class FieldFirstNonNullValueAgg(FieldAggregator):
"""First non-null value; locks after the first non-null
:meth:`agg` call until the next :meth:`reset`.
"""
def __init__(self, name: str, field_type: DataType):
super().__init__(name, field_type)
self._initialized = False
def agg(self, accumulator: Any, input_field: Any) -> Any:
if not self._initialized and input_field is not None:
self._initialized = True
return input_field
return accumulator
def reset(self) -> None:
self._initialized = False
class FieldSumAgg(FieldAggregator):
"""
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
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):
"""Maximum value. ``None`` on either side returns the non-null
operand. Uses Python's native ``<`` so any orderable type
(numeric, string, date, datetime, Decimal) works.
"""
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 input_field if accumulator < input_field else accumulator
class FieldMinAgg(FieldAggregator):
"""Minimum value. ``None`` on either side returns the non-null
operand. Uses Python's native ``<``.
"""
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 if accumulator < input_field else input_field
class FieldBoolOrAgg(FieldAggregator):
"""Logical OR. ``None`` on either side returns the non-null
operand.
"""
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 bool(accumulator) or bool(input_field)
class FieldBoolAndAgg(FieldAggregator):
"""Logical AND. ``None`` on either side returns the non-null
operand.
"""
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 bool(accumulator) and bool(input_field)
class FieldListaggAgg(FieldAggregator):
"""LISTAGG aggregator for STRING fields.
Concatenates string values using the configured delimiter.
If ``distinct`` is True, duplicated tokens are removed while
preserving their first appearance order.
"""
def __init__(
self,
name: str,
field_type: DataType,
field_name: str,
options: CoreOptions,
):
super().__init__(name, field_type)
if _atomic_base_name(field_type) != "STRING":
raise ValueError(
"Data type for '{}' column must be 'STRING' but was '{}'."
.format(name, field_type)
)
self.delimiter = options.field_listagg_delimiter(field_name)
self.distinct = options.field_collect_distinct(field_name)
self._separator = " " if self.delimiter in (None, "") else self.delimiter
def agg(self, accumulator: Any, input_field: Any) -> Any:
if input_field is None or is_blank(str(input_field)):
return accumulator
if accumulator is None or is_blank(str(accumulator)):
return input_field
accumulator = str(accumulator)
input_field = str(input_field)
if not self.distinct:
return accumulator + self.delimiter + input_field
accumulator_tokens = accumulator.split(self._separator)
existing_tokens = set(accumulator_tokens)
result = [accumulator]
for token in input_field.split(self._separator):
if is_blank(token) or token in existing_tokens:
continue
existing_tokens.add(token)
result.append(token)
if len(result) == 1:
return accumulator
return self.delimiter.join(result)
class FieldCollectAgg(FieldAggregator):
def __init__(
self,
name: str,
field_type: ArrayType,
field_name: str,
options: CoreOptions,
):
super().__init__(name, field_type)
if not isinstance(field_type, ArrayType):
raise ValueError(
"Data type for collect column must be 'Array' but was '{}'.".format(field_type)
)
self.distinct = options.field_collect_distinct(field_name)
def agg_reversed(self, accumulator: Any, input_field: Any) -> Any:
# we don't need to actually do the reverse here for this agg
# because accumulator has been distinct, just let accumulator be accumulator will speed up
# distinct process
return self.agg(accumulator, input_field)
def agg(self, accumulator: Any, input_field: Any) -> Any:
if accumulator is None and input_field is None:
return None
if (accumulator is None or input_field is None) and not self.distinct:
return input_field if accumulator is None else accumulator
if self.distinct:
result = []
self._collect(result, accumulator)
self._collect(result, input_field)
return result
else:
return list(accumulator) + list(input_field)
def retract(self, accumulator: Any, retract_field: Any) -> Any:
if accumulator is None:
return None
if retract_field is None:
return accumulator
if len(retract_field) == 0:
return accumulator
result = list(accumulator)
for element in retract_field:
if element in result:
result.remove(element)
return result
def _collect(self, result: List[Any], data: List[Any]):
if data is None:
return
for element in data:
if element not in result:
result.append(element)
class FieldNestedUpdateAgg(FieldAggregator):
"""
Used to update a field which representing a nested table.
The data type of nested table field is ARRAY<ROW>.
"""
def __init__(
self,
name: str,
field_type: ArrayType,
field_name: str,
options: CoreOptions,
):
field_type = _check_array_row(field_name, field_type)
self._check_option_dependencies(options, field_name)
super().__init__(name, field_type)
nested_type: RowType = field_type.element
self.nested_key = options.field_nested_update_agg_nested_key(field_name)
self.nested_key_null_strategy = options.field_nested_update_agg_nested_key_null_strategy(field_name)
self.nested_sequence_field = options.field_nested_update_agg_nested_sequence_field(field_name)
self.count_limit = options.field_nested_update_agg_count_limit(field_name)
if self.nested_key:
self.key_projection = FieldProjection.from_fields(
[nested_type.get_field_index(name) for name in self.nested_key],
self.nested_key
)
else:
self.key_projection = None
if self.nested_sequence_field:
self.sequence_projection = FieldProjection.from_fields(
[nested_type.get_field_index(name) for name in self.nested_sequence_field],
self.nested_sequence_field
)
self.has_sequence_field = True
else:
self.sequence_projection = None
self.has_sequence_field = False
def agg(self, accumulator: Any, input_field: Any) -> Any:
if input_field is None:
return accumulator
if self.key_projection is None:
if accumulator is None:
rows: List[Record] = []
self._add_non_null_rows(input_field, rows, self.count_limit)
return rows
if len(accumulator) >= self.count_limit:
return accumulator
remain_count = self.count_limit - len(accumulator)
rows: List[Record] = []
self._add_non_null_rows(accumulator, rows)
self._add_non_null_rows(input_field, rows, remain_count)
return rows
else:
row_map: Dict[Tuple[Any, ...], Record] = {}
if accumulator is not None:
self._add_nested_rows(accumulator, row_map, False)
self._add_nested_rows(input_field, row_map, True)
return list(row_map.values())
def retract(self, accumulator: Any, retract_field: Any) -> Any:
if accumulator is None or retract_field is None:
return accumulator
if self.key_projection is None:
rows: List[Record] = []
self._add_non_null_rows(accumulator, rows)
for retract_row in retract_field:
if retract_row is None:
continue
rows = [row for row in rows if not _row_equals(row, retract_row)]
return rows
else:
row_map: Dict[Tuple[Any, ...], Record] = {}
for row in accumulator:
if row is None:
continue
key = self.key_projection.apply(row)
if not self._apply_nested_key_null_strategy(key):
continue
row_map[key] = row
for row in retract_field:
if row is None:
continue
key = self.key_projection.apply(row)
if not self._apply_nested_key_null_strategy(key):
continue
row_map.pop(key, None)
return list(row_map.values())
@staticmethod
def _check_option_dependencies(
options: CoreOptions,
field: str,
) -> None:
nested_key = options.field_nested_update_agg_nested_key(field)
strategy_configured = options.options.contains_key(
f"{CoreOptions.FIELDS_PREFIX}.{field}.{CoreOptions.NESTED_KEY_NULL_STRATEGY}")
if strategy_configured and not nested_key:
raise ValueError(
"Option 'fields.<field-name>.nested-key-null-strategy' "
"requires 'fields.<field-name>.nested-key' to be configured."
)
if (
options.field_nested_update_agg_nested_sequence_field(field)
and not nested_key
):
raise ValueError(
"Option 'fields.<field-name>.nested-sequence-field' "
"requires 'fields.<field-name>.nested-key' to be configured."
)
def _compare_sequence(self, new_row: Record, old_row: Record) -> int:
if not self.has_sequence_field:
raise ValueError(
"compare_sequence() called but no nested_sequence_field configured."
)
new_seq_key = self.sequence_projection.apply(new_row)
old_seq_key = self.sequence_projection.apply(old_row)
return _compare_tuple(new_seq_key, old_seq_key)
def _add_non_null_rows(
self,
array: List[Record],
rows: List[Record],
remain_size: Optional[int] = None,
) -> None:
"""Append non-null rows from array.
If remain_size is specified, append at most remain_size rows.
"""
count = 0
for row in array:
if row is None:
continue
if remain_size is not None and count >= remain_size:
break
rows.append(row)
count += 1
def _add_nested_rows(
self,
array: List[Record],
row_map: Dict[Tuple[Any, ...], Record],
limit_new_keys: bool,
) -> None:
"""Merge rows from ``array`` into ``rows`` using nested keys."""
if self.key_projection is None:
raise ValueError(
"key_projection should not be None when nested_key is configured."
)
for row in array:
if row is None:
continue
key = self.key_projection.apply(row)
if not self._apply_nested_key_null_strategy(key):
continue
exists = row_map.get(key)
if exists is not None:
if not self.has_sequence_field or self._compare_sequence(row, exists) >= 0:
row_map[key] = row
elif not limit_new_keys or len(row_map) < self.count_limit:
row_map[key] = row
def _apply_nested_key_null_strategy(self, key: Tuple[Any, ...]) -> bool:
"""Apply nested-key-null-strategy."""
if all(v is not None for v in key):
return True
if self.nested_key_null_strategy == NestedKeyNullStrategy.MERGE:
return True
if self.nested_key_null_strategy == NestedKeyNullStrategy.IGNORE:
return False
if self.nested_key_null_strategy == NestedKeyNullStrategy.ERROR:
raise ValueError(
"Nested key contains null values. "
"Primary key fields must not be null."
)
raise ValueError(
"Unsupported nested-key-null-strategy '{}'".format(
self.nested_key_null_strategy
)
)
class FieldNestedPartialUpdateAgg(FieldAggregator):
"""
Used to partial update a field which representing a nested table.
The data type of nested table field is ARRAY<ROW>
"""
def __init__(
self,
name: str,
field_type: ArrayType,
field_name: str,
options: CoreOptions,
):
field_type = _check_array_row(field_name, field_type)
super().__init__(name, field_type)
nested_type: RowType = field_type.element
self.nested_fields = len(nested_type.fields)
self.nested_key = options.field_nested_update_agg_nested_key(field_name)
if not self.nested_key:
raise ValueError("nested_update_partial requires 'nested-key' to be configured.")
self.key_projection = FieldProjection.from_fields(
[nested_type.get_field_index(name) for name in self.nested_key],
self.nested_key
)
self.nested_key_null_strategy = (
options.field_nested_update_agg_nested_key_null_strategy(field_name)
)
def agg(self, accumulator: Any, input_field: Any) -> Any:
if input_field is None:
return accumulator
rows: List[Record] = []
if accumulator is not None:
self._add_non_null_rows(accumulator, rows)
self._add_non_null_rows(input_field, rows)
row_map: Dict[Tuple[Any, ...], Record] = {}
for row in rows:
key = self.key_projection.apply(row)
if not self._apply_nested_key_null_strategy(key):
continue
to_update = row_map.get(key)
if to_update is None:
if isinstance(row, InternalRow):
to_update = GenericRow([None] * self.nested_fields, row.fields)
elif isinstance(row, dict):
to_update = {}.fromkeys(row.keys())
else:
raise TypeError(
"Unsupported row type '{}'. Expected InternalRow or dict.".format(
type(row).__name__
)
)
self._partial_update(to_update, row)
row_map[key] = to_update
return list(row_map.values())
def _partial_update(self, to_update: Record, input_row: Record) -> None:
if isinstance(to_update, InternalRow) and isinstance(input_row, InternalRow):
for i in range(self.nested_fields):
value = input_row.get_field(i)
if value is not None:
to_update.values[i] = value
elif isinstance(to_update, dict) and isinstance(input_row, dict):
for k, v in input_row.items():
if v is not None:
to_update[k] = v
else:
raise TypeError(
"Unsupported row types: to_update={}, input_row={}. "
"Expected both to be either InternalRow or dict.".format(
type(to_update).__name__,
type(input_row).__name__,
)
)
def _add_non_null_rows(
self,
array: List[Record],
rows: List[Record],
) -> None:
"""Append non-null rows from array."""
for row in array:
if row is None:
continue
rows.append(row)
def _apply_nested_key_null_strategy(self, key: Tuple[Any, ...]) -> bool:
"""Apply nested-key-null-strategy."""
if all(v is not None for v in key):
return True
if self.nested_key_null_strategy == NestedKeyNullStrategy.MERGE:
return True
if self.nested_key_null_strategy == NestedKeyNullStrategy.IGNORE:
return False
if self.nested_key_null_strategy == NestedKeyNullStrategy.ERROR:
raise ValueError(
"Nested key contains null values. "
"Primary key fields must not be null."
)
raise ValueError(
"Unsupported nested-key-null-strategy '{}'".format(
self.nested_key_null_strategy
)
)
class FieldMergeMapWithKeyTimeAgg(FieldAggregator):
"""
Aggregator for merging MAP values with key and timestamp.
The input field type must be MAP with ROW values. Each ROW value must
contain a timestamp field, which is used to resolve conflicts when the
same key appears multiple times.
For the same key:
- A value with a newer timestamp replaces the existing value.
- A value with a null timestamp is ignored.
- A null value removes the corresponding key from the map.
"""
def __init__(
self,
name: str,
field_type: DataType,
field_name: str,
options: CoreOptions,
):
super().__init__(name, field_type)
if not isinstance(field_type, MapType):
raise ValueError(
"Data type for field '{}' must be 'MAP' but was '{}'".format(field_name, field_type)
)
if not isinstance(field_type.value, RowType):
raise ValueError(
"Value type of MAP for field '{}' must be 'ROW' but was '{}'".format(field_name, field_type.value)
)
if len(field_type.value.fields) < 2:
raise ValueError(
"ROW type for field '{}' must have at least 2 fields, but found {}".format(
field_name, len(field_type.value.fields)
)
)
self._resolve_ts_field_index(field_type.value, options, field_name)
def agg(self, accumulator: Any, input_field: Any) -> Any:
if accumulator is None or input_field is None:
return input_field if accumulator is None else accumulator
result_map = {}
self._put_to_map(result_map, accumulator)
self._merge_input_map(result_map, input_field)
return result_map
def _resolve_ts_field_index(self, row_type: RowType, options: CoreOptions, field_name: str) -> None:
ts_field_name = options.field_merge_map_ts_field(field_name)
if ts_field_name is None:
# default to the last field
ts_field_index = len(row_type.fields) - 1
ts_field_name = row_type.fields[ts_field_index].name
else:
ts_field_index = row_type.get_field_index(ts_field_name)
if ts_field_index < 0:
raise ValueError(
"Timestamp field '{}' not found in ROW type for field '{}'. Available fields: {}".format(
ts_field_name, field_name, [field.name for field in row_type.fields]
)
)
self.ts_field_name = ts_field_name
self.ts_field_index = ts_field_index
def _put_to_map(self, maps: Dict[Any, Any], input_field: Any):
if isinstance(input_field, dict):
maps.update(input_field)
elif isinstance(input_field, list):
tmp_map = {}
for item in input_field:
if not isinstance(item, dict):
raise TypeError(
"list element must be dict, got {}".format(type(item))
)
tmp_map[item['key']] = item['value']
maps.update(tmp_map)
else:
raise TypeError(
"input_field must be dict or list[dict], got {}".format(type(input_field))
)
def _merge_input_map(self, result_map: Dict[Any, Any], input_field: Any):
input_map = {}
self._put_to_map(input_map, input_field)
for key, new_row in input_map.items():
if new_row is None:
result_map.pop(key, None)
continue
new_ts = self._get_ts_field(new_row)
if new_ts is None:
continue
existing_row = result_map.get(key)
if existing_row is None:
result_map[key] = new_row
continue
existing_ts = self._get_ts_field(existing_row)
if existing_ts is None or new_ts > existing_ts:
result_map[key] = new_row
def _get_ts_field(self, input_row: Any) -> Any:
if isinstance(input_row, dict):
return input_row.get(self.ts_field_name)
if isinstance(input_row, InternalRow):
return input_row.get_field(self.ts_field_index)
raise TypeError(
"Unsupported row type '{}', expected InternalRow or dict.".format(
type(input_row).__name__
)
)
class FieldMergeMapAgg(FieldAggregator):
"""
Merge map values by combining all key-value pairs.
When the same key exists in both maps, the value from the input map
overwrites the value from the accumulator map.
"""
def __init__(self, name: str, field_type: DataType):
super().__init__(name, field_type)
if not isinstance(field_type, MapType):
raise ValueError(
"Data type for merge map column must be 'MAP' but was '{}'".format(field_type)
)
def agg(self, accumulator: Any, input_field: Any) -> Any:
if accumulator is None or input_field is None:
return input_field if accumulator is None else accumulator
result = {}
self._put_to_map(result, accumulator)
self._put_to_map(result, input_field)
return result
def retract(self, accumulator: Any, retract_field: Any) -> Any:
# it's hard to mark the input is retracted without accumulator
if accumulator is None:
return None
# nothing to be retracted
if retract_field is None:
return accumulator
if len(retract_field) == 0:
return accumulator
retract_keys = self._get_keys(retract_field)
acc = {}
self._put_to_map(acc, accumulator)
result = {
key: value
for key, value in acc.items()
if key not in retract_keys
}
return result
def _put_to_map(self, maps: Dict[Any, Any], input_field: Any):
if isinstance(input_field, dict):
maps.update(input_field)
elif isinstance(input_field, list):
tmp_map = {}
for item in input_field:
if not isinstance(item, dict):
raise TypeError(
"list element must be dict, got {}".format(type(item))
)
tmp_map[item['key']] = item['value']
maps.update(tmp_map)
else:
raise TypeError(
"input_field must be dict or list[dict], got {}".format(type(input_field))
)
def _get_keys(self, retract_field: Any) -> Set[Any]:
keys = set()
if isinstance(retract_field, dict):
keys.update(retract_field.keys())
elif isinstance(retract_field, list):
for item in retract_field:
if not isinstance(item, dict):
raise TypeError(
"list element must be dict, got {}".format(type(item))
)
keys.add(item["key"])
else:
raise TypeError(
"retract_field must be dict or list[dict], got {}".format(type(retract_field))
)
return keys
class FieldThetaSketchAgg(FieldAggregator):
"""Aggregator for ThetaSketch."""
def __init__(self, name: str, field_type: DataType):
super().__init__(name, field_type)
if _atomic_base_name(field_type) not in ("VARBINARY", "BYTES"):
raise ValueError(
"Data type for theta sketch column must be 'VarBinaryType' but was '{}'.".format(field_type)
)
def agg(self, accumulator: Any, input_field: Any) -> Any:
if accumulator is None or input_field is None:
return input_field if accumulator is None else accumulator
if not isinstance(accumulator, (bytes, bytearray)):
raise TypeError(
"ThetaSketch accumulator must be bytes, got {}".format(type(accumulator))
)
if not isinstance(input_field, (bytes, bytearray)):
raise TypeError(
"ThetaSketch input must be bytes, got {}".format(type(input_field))
)
if isinstance(accumulator, bytearray):
accumulator = bytes(accumulator)
if isinstance(input_field, bytearray):
input_field = bytes(input_field)
sketch1 = compact_theta_sketch.deserialize(accumulator)
sketch2 = compact_theta_sketch.deserialize(input_field)
union = theta_union()
union.update(sketch1)
union.update(sketch2)
return union.get_result().serialize()
class FieldRoaringBitmap32Agg(FieldAggregator):
"""roaring bitmap 32 aggregate a field of a row."""
def agg(self, accumulator: Any, input_field: Any) -> Any:
if accumulator is None or input_field is None:
return input_field if accumulator is None else accumulator
try:
acc = RoaringBitmap.deserialize(accumulator)
input_bitmap = RoaringBitmap.deserialize(input_field)
return RoaringBitmap.or_(acc, input_bitmap).serialize()
except Exception as ex:
raise RuntimeError("Unable to se/deserialize roaring bitmap.") from ex
# ---------------------------------------------------------------------------
# Registration. Each builder binds an identifier to a factory that
# optionally validates the column DataType before constructing the
# aggregator instance.
# ---------------------------------------------------------------------------
def _build_no_type_check(cls, identifier: str):
"""Build a factory that accepts any DataType. Used by
``primary_key`` / ``last_value`` / ``first_value`` variants and by
``max`` / ``min``, all of which work on any orderable DataType.
"""
def _factory(field_type, field_name, options):
return cls(identifier, field_type)
return _factory
def _build_numeric(cls, identifier: str):
def _factory(field_type, field_name, options):
_check_numeric(identifier, field_type)
return cls(identifier, field_type)
return _factory
def _build_boolean(cls, identifier: str):
def _factory(field_type, field_name, options):
_check_boolean(identifier, field_type)
return cls(identifier, field_type)
return _factory
def _build_field_options(cls, identifier: str):
"""Build a factory that accepts any DataType. Used by
``primary_key`` / ``last_value`` / ``first_value`` variants and by
``max`` / ``min``, all of which work on any orderable DataType.
"""
def _factory(field_type, field_name, options):
return cls(identifier, field_type, field_name, options)
return _factory
def _build_roaring_bitmap(cls, identifier: str):
def _factory(field_type, field_name, options):
_check_roaring_bitmap(identifier, field_type)
return cls(identifier, field_type)
return _factory
register_aggregator(
NAME_PRIMARY_KEY,
_build_no_type_check(FieldPrimaryKeyAgg, NAME_PRIMARY_KEY),
)
register_aggregator(
NAME_LAST_VALUE,
_build_no_type_check(FieldLastValueAgg, NAME_LAST_VALUE),
)
register_aggregator(
NAME_LAST_NON_NULL_VALUE,
_build_no_type_check(FieldLastNonNullValueAgg, NAME_LAST_NON_NULL_VALUE),
)
register_aggregator(
NAME_FIRST_VALUE,
_build_no_type_check(FieldFirstValueAgg, NAME_FIRST_VALUE),
)
register_aggregator(
NAME_FIRST_NON_NULL_VALUE,
_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(
NAME_BOOL_OR, _build_boolean(FieldBoolOrAgg, NAME_BOOL_OR)
)
register_aggregator(
NAME_BOOL_AND, _build_boolean(FieldBoolAndAgg, NAME_BOOL_AND)
)
register_aggregator(
NAME_LISTAGG, _build_field_options(FieldListaggAgg, NAME_LISTAGG)
)
register_aggregator(
NAME_NESTED_UPDATE, _build_field_options(FieldNestedUpdateAgg, NAME_NESTED_UPDATE)
)
register_aggregator(
NAME_NESTED_PARTIAL_UPDATE, _build_field_options(FieldNestedPartialUpdateAgg, NAME_NESTED_PARTIAL_UPDATE)
)
register_aggregator(
NAME_COLLECT, _build_field_options(FieldCollectAgg, NAME_COLLECT)
)
register_aggregator(
NAME_MERGE_MAP_WITH_KEYTIME, _build_field_options(FieldMergeMapWithKeyTimeAgg, NAME_MERGE_MAP_WITH_KEYTIME)
)
register_aggregator(
NAME_MERGE_MAP, _build_no_type_check(FieldMergeMapAgg, NAME_MERGE_MAP)
)
register_aggregator(
NAME_THETA_SKETCH, _build_no_type_check(FieldThetaSketchAgg, NAME_THETA_SKETCH)
)
register_aggregator(
NAME_RBM32, _build_roaring_bitmap(FieldRoaringBitmap32Agg, NAME_RBM32)
)