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 8b43f291eb [python] Fix DECIMAL Arrow predicates across scales (#10317)
8b43f291eb is described below
commit 8b43f291eb8e341fa6569ffa8360601c666bbb8b
Author: zhigang <[email protected]>
AuthorDate: Fri Oct 2 09:41:15 2026 +0800
[python] Fix DECIMAL Arrow predicates across scales (#10317)
---
.../pypaimon/common/decimal_arrow_predicate.py | 142 ++++++++++++++
paimon-python/pypaimon/common/predicate.py | 53 ++++-
.../read/reader/filter_record_batch_reader.py | 2 +-
paimon-python/pypaimon/read/split_read.py | 4 +-
.../pypaimon/tests/decimal_predicate_test.py | 216 +++++++++++++++++++++
.../pypaimon/tests/schema_evolution_read_test.py | 13 ++
paimon-python/pypaimon/tests/where_parser_test.py | 3 +
7 files changed, 425 insertions(+), 8 deletions(-)
diff --git a/paimon-python/pypaimon/common/decimal_arrow_predicate.py
b/paimon-python/pypaimon/common/decimal_arrow_predicate.py
new file mode 100644
index 0000000000..b53ae95a7f
--- /dev/null
+++ b/paimon-python/pypaimon/common/decimal_arrow_predicate.py
@@ -0,0 +1,142 @@
+# 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 operator
+from decimal import Decimal
+from typing import List, Optional, Sequence, Tuple, Union
+
+import pyarrow
+from pyarrow import dataset as pyarrow_dataset
+
+DecimalLiteral = Union[Decimal, int]
+
+
+def _decimal_scaled_integer(value: DecimalLiteral, scale: int) -> Tuple[int,
int]:
+ """Return the floor and remainder of the exact value in column units."""
+ numerator, denominator = Decimal(value).as_integer_ratio()
+ if scale >= 0:
+ numerator *= 10**scale
+ else:
+ denominator *= 10**-scale
+ return divmod(numerator, denominator)
+
+
+def _decimal_scalar(unscaled: int, field_type: pyarrow.DataType) ->
pyarrow.Scalar:
+ """Build a scalar at the column's precision and scale."""
+ digits = tuple(int(digit) for digit in str(abs(unscaled)))
+ return pyarrow.scalar(
+ Decimal((int(unscaled < 0), digits, -field_type.scale)),
type=field_type
+ )
+
+
+def _decimal_comparison_on_field_scale(
+ field: pyarrow_dataset.Expression,
+ field_type: pyarrow.DataType,
+ method: str,
+ value: Optional[DecimalLiteral],
+) -> pyarrow_dataset.Expression:
+ """Compare against the column's representable values without rounding."""
+ # https://github.com/apache/arrow/issues/41011
+ # TODO: Recheck this workaround after upgrading PyArrow.
+ if value is None:
+ return field.is_valid() & field.is_null()
+ floor, remainder = _decimal_scaled_integer(value, field_type.scale)
+ ceiling = floor + (remainder != 0)
+ if method in ('equal', 'notEqual'):
+ if remainder:
+ return (
+ field.is_valid()
+ if method == 'notEqual'
+ else field.is_valid() & field.is_null()
+ )
+ threshold = floor
+ elif method in ('lessThan', 'greaterOrEqual'):
+ threshold = ceiling
+ else:
+ threshold = floor
+
+ compare = {
+ 'equal': operator.eq,
+ 'notEqual': operator.ne,
+ 'lessThan': operator.lt,
+ 'lessOrEqual': operator.le,
+ 'greaterThan': operator.gt,
+ 'greaterOrEqual': operator.ge,
+ }[method]
+ maximum = 10**field_type.precision - 1
+ if method in ('equal', 'notEqual'):
+ if abs(threshold) > maximum:
+ return (
+ field.is_valid()
+ if method == 'notEqual'
+ else field.is_valid() & field.is_null()
+ )
+ else:
+ low, high = compare(-maximum, threshold), compare(maximum, threshold)
+ if low == high:
+ return field.is_valid() if low else field.is_valid() &
field.is_null()
+ return compare(field, _decimal_scalar(threshold, field_type))
+
+
+def decimal_arrow_expression(
+ field: pyarrow_dataset.Expression,
+ field_type: pyarrow.DataType,
+ method: str,
+ literals: Sequence[Optional[DecimalLiteral]],
+) -> pyarrow_dataset.Expression:
+ """Compose exact DECIMAL comparisons for one predicate leaf."""
+
+ def compare(
+ comparison: str, value: Optional[DecimalLiteral]
+ ) -> pyarrow_dataset.Expression:
+ return _decimal_comparison_on_field_scale(field, field_type,
comparison, value)
+
+ if method in (
+ 'equal',
+ 'notEqual',
+ 'lessThan',
+ 'lessOrEqual',
+ 'greaterThan',
+ 'greaterOrEqual',
+ ):
+ return compare(method, literals[0])
+ if method == 'between':
+ return compare('greaterOrEqual', literals[0]) & compare(
+ 'lessOrEqual', literals[1]
+ )
+ if method == 'notBetween':
+ return compare('lessThan', literals[0]) | compare('greaterThan',
literals[1])
+ if method == 'notIn' and any(value is None for value in literals):
+ return field.is_valid() & field.is_null()
+ if method in ('in', 'notIn'):
+ maximum = 10**field_type.precision - 1
+ scalars: List[pyarrow.Scalar] = []
+ for value in literals:
+ if value is None:
+ continue
+ unscaled, remainder = _decimal_scaled_integer(value,
field_type.scale)
+ if not remainder and abs(unscaled) <= maximum:
+ scalars.append(_decimal_scalar(unscaled, field_type))
+ if not scalars:
+ return (
+ field.is_valid()
+ if method == 'notIn'
+ else field.is_valid() & field.is_null()
+ )
+ matches = field.isin(scalars)
+ return (~matches if method == 'notIn' else matches) & field.is_valid()
+ raise ValueError(f'Unsupported decimal predicate method: {method}')
diff --git a/paimon-python/pypaimon/common/predicate.py
b/paimon-python/pypaimon/common/predicate.py
index d93bbedfea..8967e2cecb 100644
--- a/paimon-python/pypaimon/common/predicate.py
+++ b/paimon-python/pypaimon/common/predicate.py
@@ -18,13 +18,17 @@
import re
from abc import ABC, ABCMeta, abstractmethod
from dataclasses import dataclass
-from typing import Any, Dict, List, Optional
-from typing import ClassVar
+from decimal import Decimal
+from typing import Any, ClassVar, Dict, List, Optional, Sequence, cast
import pyarrow
from pyarrow import compute as pyarrow_compute
from pyarrow import dataset as pyarrow_dataset
+from pypaimon.common.decimal_arrow_predicate import (
+ DecimalLiteral,
+ decimal_arrow_expression,
+)
from pypaimon.manifest.schema.simple_stats import SimpleStats
from pypaimon.table.row.internal_row import InternalRow
@@ -124,15 +128,22 @@ class Predicate:
return tester.test_by_stats(min_value, max_value, self.literals)
raise ValueError(f"Unsupported predicate method: {self.method}")
- def to_arrow(self) -> Any:
+ def to_arrow(self, schema: Optional[pyarrow.Schema] = None) -> Any:
+ """Convert to Arrow, using the input schema for exact decimal
comparisons."""
if self.method == 'and':
return _combine_arrow_expressions(
- [p.to_arrow() for p in self.literals],
+ [
+ p.to_arrow(schema) if schema is not None else p.to_arrow()
+ for p in self.literals
+ ],
lambda left, right: left & right,
)
if self.method == 'or':
return _combine_arrow_expressions(
- [p.to_arrow() for p in self.literals],
+ [
+ p.to_arrow(schema) if schema is not None else p.to_arrow()
+ for p in self.literals
+ ],
lambda left, right: left | right,
)
@@ -183,9 +194,39 @@ class Predicate:
return None
field = pyarrow_dataset.field(self.field)
+ literals = self.literals
+ if schema is not None and self.field in schema.names:
+ field_type = schema.field(self.field).type
+ if (
+ pyarrow.types.is_decimal(field_type)
+ and literals
+ and all(
+ value is None or isinstance(value, (Decimal, int))
+ for value in literals
+ )
+ and self.method
+ in (
+ 'equal',
+ 'notEqual',
+ 'lessThan',
+ 'lessOrEqual',
+ 'greaterThan',
+ 'greaterOrEqual',
+ 'in',
+ 'notIn',
+ 'between',
+ 'notBetween',
+ )
+ ):
+ return decimal_arrow_expression(
+ field,
+ field_type,
+ self.method,
+ cast(Sequence[Optional[DecimalLiteral]], literals),
+ )
tester = Predicate.testers.get(self.method)
if tester:
- return tester.test_by_arrow(field, self.literals)
+ return tester.test_by_arrow(field, literals)
raise ValueError("Unsupported predicate method:
{}".format(self.method))
diff --git a/paimon-python/pypaimon/read/reader/filter_record_batch_reader.py
b/paimon-python/pypaimon/read/reader/filter_record_batch_reader.py
index 2100dd9361..22dfc43ab6 100644
--- a/paimon-python/pypaimon/read/reader/filter_record_batch_reader.py
+++ b/paimon-python/pypaimon/read/reader/filter_record_batch_reader.py
@@ -70,7 +70,7 @@ class FilterRecordBatchReader(RecordBatchReader):
def _filter_batch(self, batch: pa.RecordBatch) -> Optional[pa.RecordBatch]:
if not self._use_arrow_filter:
return self._filter_batch_by_row(batch)
- expr = self.predicate.to_arrow()
+ expr = self.predicate.to_arrow(batch.schema)
if expr is None:
return self._filter_batch_by_row(batch)
result = ds.InMemoryDataset(pa.Table.from_batches([batch])).scanner(
diff --git a/paimon-python/pypaimon/read/split_read.py
b/paimon-python/pypaimon/read/split_read.py
index a26b6e6803..50a2c0ea7a 100644
--- a/paimon-python/pypaimon/read/split_read.py
+++ b/paimon-python/pypaimon/read/split_read.py
@@ -642,7 +642,9 @@ class SplitRead(ABC):
read_predicate =
(trim_predicate_by_fields(self.push_down_predicate, read_file_fields)
if schema_id == self.table.table_schema.id else
None)
read_arrow_predicate = (
- read_predicate.to_arrow()
+ read_predicate.to_arrow(
+ PyarrowFieldParser.from_paimon_schema(schema_fields)
+ )
if read_predicate and self._arrow_filter_pushdown_enabled
else None
)
diff --git a/paimon-python/pypaimon/tests/decimal_predicate_test.py
b/paimon-python/pypaimon/tests/decimal_predicate_test.py
new file mode 100644
index 0000000000..b8512491a2
--- /dev/null
+++ b/paimon-python/pypaimon/tests/decimal_predicate_test.py
@@ -0,0 +1,216 @@
+# 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.
+
+from decimal import Decimal, localcontext
+import operator
+from pathlib import Path
+from typing import Callable, List, Optional, Union
+
+import pyarrow as pa
+import pyarrow.dataset as ds
+import pyarrow.parquet as pq
+import pytest
+
+from pypaimon import Schema
+from pypaimon.common.predicate import Predicate
+from pypaimon.filesystem.local_file_io import LocalFileIO
+from pypaimon.read.reader.format_pyarrow_reader import FormatPyArrowReader
+from pypaimon.read.reader.filter_record_batch_reader import
FilterRecordBatchReader
+
+
+COMPARISONS = [
+ ('equal', operator.eq),
+ ('notEqual', operator.ne),
+ ('lessThan', operator.lt),
+ ('lessOrEqual', operator.le),
+ ('greaterThan', operator.gt),
+ ('greaterOrEqual', operator.ge),
+]
+
+
[email protected]('method,compare', COMPARISONS)
[email protected](
+ 'bound',
+ [
+ Decimal('2'),
+ Decimal('24.000'),
+ Decimal('1.001'),
+ Decimal('-1.001'),
+ Decimal('1E-41'),
+ Decimal('1E+100'),
+ Decimal('-1E+100'),
+ 24,
+ ],
+)
+def test_decimal_comparison_matches_python(
+ method: str,
+ compare: Callable[[Decimal, Union[Decimal, int]], bool],
+ bound: Union[Decimal, int],
+) -> None:
+ decimal_values: List[Optional[Decimal]] = [
+ Decimal(value)
+ for value in (
+ '-24.00',
+ '-1.01',
+ '-1.00',
+ '0.00',
+ '1.00',
+ '1.01',
+ '2.00',
+ '24.00',
+ )
+ ] + [None]
+ table = pa.table({'value': pa.array(decimal_values, type=pa.decimal128(15,
2))})
+ predicate = Predicate(method, 0, 'value', [bound])
+ expected = [
+ value for value in decimal_values if value is not None and
compare(value, bound)
+ ]
+ # A small context must not round a high precision bound or column value.
+ with localcontext() as context:
+ context.prec = 6
+ expr = predicate.to_arrow(table.schema)
+ actual = ds.Scanner.from_batches(table.to_reader(), filter=expr).to_table()
+ assert actual.column('value').to_pylist() == expected
+
+
[email protected](
+ 'method,literals,expected',
+ [
+ ('in', [Decimal('1'), Decimal('2.001'), None], [Decimal('1.00')]),
+ ('notIn', [Decimal('1'), None], []),
+ (
+ 'between',
+ [Decimal('-1'), Decimal('1.001')],
+ [Decimal('-1.00'), Decimal('1.00')],
+ ),
+ ('notBetween', [Decimal('-1'), Decimal('1.001')], [Decimal('2.00')]),
+ (
+ 'in',
+ [Decimal('1E-41'), Decimal('1E+100'), Decimal('1.00')],
+ [Decimal('1.00')],
+ ),
+ (
+ 'notIn',
+ [Decimal('1E-41'), Decimal('1E+100'), Decimal('1.00')],
+ [Decimal('-1.00'), Decimal('2.00')],
+ ),
+ ],
+)
+def test_decimal_set_and_range_filters(
+ method: str, literals: List[Optional[Decimal]], expected: List[Decimal]
+) -> None:
+ values = [Decimal('-1.00'), Decimal('1.00'), Decimal('2.00'), None]
+ table = pa.table({'value': pa.array(values, type=pa.decimal128(15, 2))})
+ predicate = Predicate(method, 0, 'value', literals)
+ actual = ds.Scanner.from_batches(
+ table.to_reader(), filter=predicate.to_arrow(table.schema)
+ ).to_table()
+ assert actual.column('value').to_pylist() == expected
+
+
+def test_decimal_large_literal_preserves_precision() -> None:
+ values = [Decimal('9007199254740992.00'), Decimal('9007199254740993.00')]
+ table = pa.table({'value': pa.array(values, type=pa.decimal128(38, 2))})
+ predicate = Predicate('equal', 0, 'value',
[Decimal('9007199254740993.00')])
+ actual = ds.Scanner.from_batches(
+ table.to_reader(), filter=predicate.to_arrow(table.schema)
+ ).to_table()
+ assert actual.column('value').to_pylist() == [values[1]]
+
+
[email protected](
+ 'bound,expected',
+ [
+ (Decimal('24.000'), [Decimal('1.00'), Decimal('17.00')]),
+ (Decimal('24.001'), [Decimal('1.00'), Decimal('17.00'),
Decimal('24.00')]),
+ ],
+)
+def test_decimal_bound_preserves_row_group_pruning(
+ tmp_path: Path, bound: Decimal, expected: List[Decimal]
+) -> None:
+ values = [Decimal('1.00'), Decimal('17.00'), Decimal('24.00'),
Decimal('50.00')]
+ table = pa.table({'value': pa.array(values, type=pa.decimal128(15, 2))})
+ path = tmp_path / 'decimals.parquet'
+ pq.write_table(table, path, row_group_size=1)
+ dataset = ds.dataset(path)
+ predicate = Predicate('lessThan', 0, 'value', [bound])
+ expression = predicate.to_arrow(table.schema)
+ selected_groups = [
+ group
+ for fragment in dataset.get_fragments()
+ for group in fragment.split_by_row_group(filter=expression)
+ ]
+ assert len(selected_groups) == len(expected)
+ assert dataset.to_table(filter=expression).column('value').to_pylist() ==
expected
+
+
+def test_decimal_batch_filter_after_normalization(tmp_path: Path) -> None:
+ batch = pa.record_batch(
+ [
+ pa.array(
+ [Decimal('1.00'), Decimal('17.00'), Decimal('24.00'), None],
+ type=pa.decimal128(15, 2),
+ )
+ ],
+ names=['value'],
+ )
+ # Exercise the real post-read filter separately from physical file
pushdown.
+ path = tmp_path / 'batch.parquet'
+ pq.write_table(pa.Table.from_batches([batch]), path)
+ source = FormatPyArrowReader(
+ LocalFileIO(),
+ 'parquet',
+ str(path),
+ Schema.from_pyarrow_schema(batch.schema).fields,
+ None,
+ )
+ reader = FilterRecordBatchReader(
+ source, Predicate('lessThan', 0, 'value', [Decimal('24')])
+ )
+ try:
+ first = reader.read_arrow_batch()
+ assert first is not None
+ assert first.column(0).to_pylist() == [Decimal('1.00'),
Decimal('17.00')]
+ assert reader.read_arrow_batch() is None
+ finally:
+ reader.close()
+
+
+def test_decimal_composed_predicate_uses_schema() -> None:
+ values = [Decimal('-1.00'), Decimal('0.00'), Decimal('1.00'),
Decimal('2.00')]
+ table = pa.table({'value': pa.array(values, type=pa.decimal128(15, 2))})
+ predicate = Predicate(
+ 'or',
+ None,
+ None,
+ [
+ Predicate(
+ 'and',
+ None,
+ None,
+ [
+ Predicate('greaterOrEqual', 0, 'value', [Decimal('0')]),
+ Predicate('lessThan', 0, 'value', [Decimal('1.001')]),
+ ],
+ ),
+ Predicate('equal', 0, 'value', [Decimal('2')]),
+ ],
+ )
+ actual = ds.Scanner.from_batches(
+ table.to_reader(), filter=predicate.to_arrow(table.schema)
+ ).to_table()
+ assert actual.column('value').to_pylist() == values[1:]
diff --git a/paimon-python/pypaimon/tests/schema_evolution_read_test.py
b/paimon-python/pypaimon/tests/schema_evolution_read_test.py
index ea02304657..144129cb52 100644
--- a/paimon-python/pypaimon/tests/schema_evolution_read_test.py
+++ b/paimon-python/pypaimon/tests/schema_evolution_read_test.py
@@ -304,6 +304,19 @@ class SchemaEvolutionReadTest(unittest.TestCase):
schema=new_schema)
self.assertEqual(expected, actual)
+ if name == 'decimal_scale_up':
+ read_builder = table.new_read_builder()
+ predicate = read_builder.new_predicate_builder().less_than(
+ 'v', decimal.Decimal('4.5601')
+ )
+ read_builder.with_filter(predicate).with_projection(['k'])
+ filtered = read_builder.new_read().to_arrow(
+ self._scan_table(read_builder)
+ )
+ self.assertEqual(
+ [1, 2, 4], sorted(filtered.column('k').to_pylist())
+ )
+
def test_schema_evolution_type_lossy_old_file_only(self):
# Reading ONLY old-schema files after a lossy type change (no
# newer-schema file in the splits). The output type must equal the
diff --git a/paimon-python/pypaimon/tests/where_parser_test.py
b/paimon-python/pypaimon/tests/where_parser_test.py
index 4f38d71a91..762f4db485 100644
--- a/paimon-python/pypaimon/tests/where_parser_test.py
+++ b/paimon-python/pypaimon/tests/where_parser_test.py
@@ -508,6 +508,9 @@ class WhereParserScanTest(unittest.TestCase):
# any stored value; it must return no rows rather than round into a
match.
self.assertEqual(self._scan_ids("price = 99.999"), [])
+ def test_decimal_fractional_range_uses_column_scale(self) -> None:
+ self.assertEqual(self._scan_ids("price < 50.001"), [2])
+
def test_high_precision_decimal_integer_literal_matches(self):
# A DECIMAL(38, 2) integer-form literal exceeds the default decimal
# context; it must still rescale and match the stored scale-2 value.