blob: b527e6cec452c2e6a6f9d0b163ca32914113f9b1 [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.
"""DECIMAL predicates compare values without rounding literals to column scale."""
from contextlib import ExitStack
from decimal import Decimal, localcontext
from unittest.mock import patch
import pyarrow as pa
import pytest
from pypaimon import CatalogFactory, Schema
from pypaimon.execution.python.write.table_update import PythonTableUpdate
from pypaimon.common.predicate import Predicate
from pypaimon.globalindex.create_global_index import GlobalIndexBuilder
pytestmark = pytest.mark.native_plan
_VALUES = [Decimal(value) for value in ['-1.24', '-1.23', '0.00', '1.23', '1.24']] + [None]
_PREDICATES = [
('equal', ['1.235'], []),
('notEqual', ['1.235'], [0, 1, 2, 3, 4]),
('lessThan', ['1.235'], [0, 1, 2, 3]),
('lessOrEqual', ['1.235'], [0, 1, 2, 3]),
('greaterThan', ['1.235'], [4]),
('greaterOrEqual', ['1.235'], [4]),
('in', ['1.235', '1.240', None], [4]),
('notIn', ['1.235', '1.240'], [0, 1, 2, 3]),
('notIn', ['1.235', None], []),
('between', ['-1.235', '1.235'], [1, 2, 3]),
('notBetween', ['-1.235', '1.235'], [0, 4]),
('lessThan', ['123456789'], [0, 1, 2, 3, 4]),
('equal', ['1E+37'], []),
('greaterThan', ['1E-38'], [3, 4]),
('lessThan', ['0E+100'], [0, 1]),
('lessThan', ['-1.23500000000000000000000000000000000000000'], [0]),
]
def _predicate(method, literals):
return Predicate(method, 1, 'amount', [Decimal(value) if value is not None else None for value in literals])
def _table(tmp_path, native=True, evolution=False, indexed=None, precision=10):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
schema = pa.schema([('id', pa.int32()), ('amount', pa.decimal128(precision, 2)), ('value', pa.int32())])
evolution = evolution or indexed is not None
options = {'write.native.enabled': str(native).lower(),
'row-tracking.enabled': str(evolution).lower(),
'deletion-vectors.enabled': str(evolution).lower(),
'data-evolution.enabled': str(evolution).lower()}
if indexed is not None:
options.update({'global-index.enabled': 'true', 'global-index.search-mode': 'full'})
catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, options=options), False)
table = catalog.get_table('default.t')
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
# Separate files make incorrect min/max pruning observable too.
for start in range(0, len(_VALUES), 2):
writer.write_arrow(pa.table({'id': [start, start + 1], 'amount': _VALUES[start:start + 2],
'value': [0, 0]}, schema=schema))
commit.commit(writer.prepare_commit())
writer.close()
commit.close()
if start + 2 < len(_VALUES):
writer, commit = builder.new_write(), builder.new_commit()
finally:
writer.close()
commit.close()
if indexed is not None:
messages = GlobalIndexBuilder(table, 'amount', index_type=indexed).build()
assert messages
commit = table.new_batch_write_builder().new_commit()
try:
commit.commit(messages)
finally:
commit.close()
return table
def _read(table, predicate, native_plan, native_read, projection=('id',)):
table = table.copy({'scan.native-plan.enabled': str(native_plan).lower(),
'read.native.enabled': str(native_read).lower()})
builder = table.new_read_builder().with_projection(list(projection))
if predicate is not None:
builder.with_filter(predicate)
plan = builder.new_scan().plan()
if native_plan:
assert plan.stats().native_planned
reader = builder.new_read()
with ExitStack() as stack:
if native_read:
stack.enter_context(patch.object(reader, '_native_fallback',
side_effect=AssertionError('Python read fallback')))
stack.enter_context(patch(
'pypaimon.execution.python.read.table_read.PythonTableRead._create_split_read',
side_effect=AssertionError('Python split reader')
))
rows = reader.to_arrow(plan.splits(), parallelism=1).to_pylist()
return sorted(rows, key=lambda row: row['id'])
@pytest.mark.parametrize('native_plan,native_read', [(False, False), (True, False), (False, True), (True, True)])
@pytest.mark.parametrize('indexed', [None, 'btree', 'bitmap'])
@pytest.mark.parametrize('precision', [10, 38], ids=['compact', 'wide'])
def test_decimal_predicates_preserve_literal_value_in_scan_and_read(
tmp_path, native_plan, native_read, indexed, precision):
table = _table(tmp_path, indexed=indexed, precision=precision)
# Literal construction and conversion must not depend on Decimal context.
with localcontext() as context:
context.prec = 3
for method, literals, expected in _PREDICATES:
assert _read(table, _predicate(method, literals), native_plan, native_read) == [
{'id': i} for i in expected], (method, literals)
fine = _predicate('lessThan', ['1.235'])
exact = Predicate('equal', 1, 'amount', [Decimal('1.24')])
for method, expected in [('and', []), ('or', list(range(5)))]:
combined = Predicate(method, None, None, [fine, exact])
assert _read(table, combined, native_plan, native_read) == [{'id': i} for i in expected]
@pytest.mark.parametrize('native', [False, True], ids=['python', 'native'])
@pytest.mark.parametrize('stream', [False, True], ids=['batch', 'stream'])
@pytest.mark.parametrize('operation', ['literal', 'callable', 'delete'])
def test_decimal_predicates_select_the_same_rows_for_updates_and_deletes(tmp_path, native, stream, operation):
table = _table(tmp_path, native=native, evolution=True)
builder = table.new_stream_write_builder() if stream else table.new_batch_write_builder()
update = builder.new_update()
predicate = _predicate('between', ['-1.235', '1.235'])
with ExitStack() as stack:
if native:
for method in ['_build_predicate_update_table', '_matched_delete_row_ids']:
stack.enter_context(patch.object(PythonTableUpdate, method,
side_effect=AssertionError('Python matching fallback')))
if operation == 'delete':
messages = update.delete_by_predicate(predicate, 19) if stream else update.delete_by_predicate(predicate)
else:
if operation == 'callable':
def assignment(rows):
assert rows.column_names == ['id', '_ROW_ID']
return pa.array([i + 10 for i in rows['id'].to_pylist()], type=pa.int32())
kwargs = {'read_columns': ['id']}
else:
assignment, kwargs = 42, {}
messages = (update.update_by_predicate(predicate, {'value': assignment}, 19, **kwargs) if stream
else update.update_by_predicate(predicate, {'value': assignment}, **kwargs))
commit = builder.new_commit()
try:
commit.commit(messages, 19) if stream else commit.commit(messages)
finally:
commit.close()
ids = [0, 4, 5] if operation == 'delete' else list(range(6))
expected = [{'id': i, 'amount': _VALUES[i], 'value': (
(i + 10 if operation == 'callable' else 42) if i in (1, 2, 3) else 0)} for i in ids]
for read_native in [False, True]:
assert _read(table, None, read_native, read_native, ('id', 'amount', 'value')) == expected
@pytest.mark.parametrize('engine', ['deduplicate', 'partial-update'])
@pytest.mark.parametrize('native_plan,native_read', [(False, False), (True, False), (False, True), (True, True)])
def test_decimal_filter_observes_merged_primary_key_values(tmp_path, engine, native_plan, native_read):
catalog = CatalogFactory.create({'warehouse': str(tmp_path)})
catalog.create_database('default', True)
schema = pa.schema([('id', pa.int32()), ('amount', pa.decimal128(10, 2)), ('value', pa.int32())])
catalog.create_table('default.t', Schema.from_pyarrow_schema(schema, primary_keys=['id'], options={
'bucket': '1', 'merge-engine': engine, 'write.native.enabled': 'true',
}), False)
table = catalog.get_table('default.t')
batches = [
pa.table({'id': range(6), 'amount': _VALUES, 'value': [0] * 6}, schema=schema),
# Filtering either file before merging can resurrect id=3's obsolete
# value or discard id=4's new value on the other side of 1.235.
pa.table({'id': [3, 4], 'amount': [Decimal('1.24'), Decimal('1.23')], 'value': [42, 43]}, schema=schema),
]
for batch in batches:
builder = table.new_batch_write_builder()
writer, commit = builder.new_write(), builder.new_commit()
try:
writer.write_arrow(batch)
commit.commit(writer.prepare_commit())
finally:
writer.close()
commit.close()
for method, literals, expected in _PREDICATES:
expected = sorted(4 if i == 3 else 3 if i == 4 else i for i in expected)
rows = _read(table, _predicate(method, literals), native_plan, native_read, ('id', 'value'))
assert rows == [{'id': i, 'value': 42 if i == 3 else 43 if i == 4 else 0} for i in expected]