blob: ae96b0f073a28be5ad37ad5ce413b6d524367979 [file]
################################################################################
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
################################################################################
import logging
import re
import sys
from typing import Mapping, Optional, Set
import pyarrow as pa
from pypaimon.common.predicate import Predicate
from pypaimon.common.predicate_builder import PredicateBuilder
from pypaimon.schema.data_types import AtomicType
_COL_REF_PATTERN = re.compile(r'\b([st])\.(\w+)\b')
logger = logging.getLogger(__name__)
def _load_datafusion():
if sys.version_info[:2] < (3, 10):
raise ImportError(
"merge_into condition expressions require Python 3.10 or newer"
)
try:
import datafusion
return datafusion
except ImportError:
raise ImportError(
"merge_into condition expressions require DataFusion. "
"Install it with: pip install 'pypaimon[datafusion]'"
)
_STRING_LITERAL = re.compile(r"'(?:[^']|'')*'")
def _strip_string_literals(condition: str) -> str:
return _STRING_LITERAL.sub('', condition)
def rewrite_condition(condition: str) -> str:
parts, last = [], 0
for m in _STRING_LITERAL.finditer(condition):
parts.append(_COL_REF_PATTERN.sub(r'"\1.\2"', condition[last:m.start()]))
parts.append(m.group())
last = m.end()
parts.append(_COL_REF_PATTERN.sub(r'"\1.\2"', condition[last:]))
return ''.join(parts)
def remap_source_on_keys(
rewritten: str, on_map: Mapping[str, str],
) -> str:
for s_col, t_col in on_map.items():
old, new = f'"s.{s_col}"', f'"t.{t_col}"'
parts, last = [], 0
for m in _STRING_LITERAL.finditer(rewritten):
parts.append(rewritten[last:m.start()].replace(old, new))
parts.append(m.group())
last = m.end()
parts.append(rewritten[last:].replace(old, new))
rewritten = ''.join(parts)
return rewritten
def extract_columns(condition: str) -> Set[str]:
stripped = _strip_string_literals(condition)
return {f"{m.group(1)}.{m.group(2)}"
for m in _COL_REF_PATTERN.finditer(stripped)}
def extract_target_columns(condition: str) -> Set[str]:
stripped = _strip_string_literals(condition)
return {m.group(2) for m in _COL_REF_PATTERN.finditer(stripped)
if m.group(1) == "t"}
def try_parse_self_merge_predicate(condition, fields) -> Optional[Predicate]:
"""Best-effort conversion of a self-merge condition for scan pruning."""
if not isinstance(condition, str):
return None
# Conversion failure keeps the original unfiltered DataFusion execution.
try:
expression = _parse_self_merge_expression(condition, fields)
return _to_paimon_predicate(
expression,
PredicateBuilder(fields),
{field.name: field for field in fields},
)
except Exception:
logger.debug(
"Unable to push down self-merge condition %r",
condition,
exc_info=True,
)
return None
def _parse_self_merge_expression(condition, fields):
from pypaimon.schema.data_types import PyarrowFieldParser
from pypaimon.table.special_fields import SpecialFields
pa_schema = PyarrowFieldParser.from_paimon_schema(fields)
arrays, names = [], []
for alias in ('s', 't'):
for field in pa_schema:
arrays.append(pa.array([], type=field.type))
names.append('{}.{}'.format(alias, field.name))
arrays.append(pa.array([], type=pa.int64()))
names.append('{}.{}'.format(alias, SpecialFields.ROW_ID.name))
batch = pa.RecordBatch.from_arrays(arrays, names)
context = _load_datafusion().SessionContext()
context.register_record_batches('_self_merge', [[batch]])
df_schema = context.table(
'_self_merge'
).logical_plan().to_variant().schema()
return context.parse_sql_expr(rewrite_condition(condition), df_schema)
def _to_paimon_predicate(expression, builder, fields_by_name):
kind = expression.variant_name()
node = expression.to_variant()
if kind == 'BinaryExpr':
op = node.op().upper()
if op in ('AND', 'OR'):
predicates = []
pending = [expression]
while pending:
current = pending.pop()
if current.variant_name() == 'BinaryExpr':
current_node = current.to_variant()
if current_node.op().upper() == op:
pending.append(current_node.right())
pending.append(current_node.left())
continue
predicate = _to_paimon_predicate(
current, builder, fields_by_name,
)
if predicate is None:
return None
predicates.append(predicate)
if op == 'AND':
return PredicateBuilder.and_predicates(predicates)
return PredicateBuilder.or_predicates(predicates)
return _comparison_predicate(node, builder, fields_by_name)
if kind == 'InList':
field = _datafusion_field(node.expr(), fields_by_name)
literals = [_datafusion_literal(item) for item in node.list()]
if (field is None or any(not found for found, _ in literals)
or not _safe_literals(field, [v for _, v in literals])):
return None
values = [value for _, value in literals]
if node.negated():
return builder.is_not_in(field.name, values)
return builder.is_in(field.name, values)
if kind == 'Between':
field = _datafusion_field(node.expr(), fields_by_name)
low_found, low = _datafusion_literal(node.low())
high_found, high = _datafusion_literal(node.high())
if (field is None or not low_found or not high_found
or not _safe_literals(field, [low, high])):
return None
if node.negated():
return builder.not_between(field.name, low, high)
return builder.between(field.name, low, high)
if kind in ('IsNull', 'IsNotNull'):
field = _datafusion_field(node.expr(), fields_by_name)
if field is None or not isinstance(field.type, AtomicType):
return None
if kind == 'IsNull':
return builder.is_null(field.name)
return builder.is_not_null(field.name)
return None
def _comparison_predicate(node, builder, fields_by_name):
field = _datafusion_field(node.left(), fields_by_name)
found, literal = _datafusion_literal(node.right())
if field is None or not found or not _safe_literals(field, [literal]):
return None
methods = {
'=': builder.equal,
'!=': builder.not_equal,
'<': builder.less_than,
'<=': builder.less_or_equal,
'>': builder.greater_than,
'>=': builder.greater_or_equal,
}
method = methods.get(node.op())
if method is None:
return None
return method(field.name, literal)
def _datafusion_field(expression, fields_by_name):
if expression.variant_name() != 'Column':
return None
name = expression.to_variant().name()
if not (name.startswith('s.') or name.startswith('t.')):
return None
return fields_by_name.get(name[2:])
def _datafusion_literal(expression):
if expression.variant_name() != 'Literal':
return False, None
value = expression.python_value()
if isinstance(value, pa.Scalar):
value = value.as_py()
return True, value
def _safe_literals(field, literals) -> bool:
if not isinstance(field.type, AtomicType):
return False
type_name = field.type.type.upper().split('(', 1)[0].strip()
if type_name in {'TINYINT', 'SMALLINT', 'INT', 'INTEGER', 'BIGINT'}:
return all(
isinstance(literal, int)
and not isinstance(literal, bool)
and -(1 << 63) <= literal <= (1 << 63) - 1
for literal in literals
)
if type_name == 'BOOLEAN':
return all(isinstance(literal, bool) for literal in literals)
if type_name in {'STRING', 'CHAR', 'VARCHAR'}:
return all(isinstance(literal, str) for literal in literals)
return False
def filter_batch(
batch: pa.Table, condition: str, _pre_rewritten: bool = False,
) -> pa.Table:
if batch.num_rows == 0:
return batch
datafusion = _load_datafusion()
rewritten = condition if _pre_rewritten else rewrite_condition(condition)
config = datafusion.SessionConfig().set(
"datafusion.optimizer.enable_round_robin_repartition", "false"
)
ctx = datafusion.SessionContext(config)
input_batches = batch.to_batches()
# Use one batch per partition and rebuild from partitioned batches so
# DataFusion neither concatenates 32-bit offsets nor reorders the input.
ctx.register_record_batches(
"_batch", [[record_batch] for record_batch in input_batches]
)
result = ctx.sql(
f'SELECT * FROM _batch WHERE {rewritten}'
)
output_batches = [
record_batch
for partition in result.collect_partitioned()
for record_batch in partition
]
if not output_batches:
return batch.schema.empty_table()
return pa.Table.from_batches(output_batches)
def apply_condition(
batch: pa.Table, rewritten: str, empty_schema: pa.Schema,
) -> pa.Table:
batch = filter_batch(batch, rewritten, _pre_rewritten=True)
if batch.num_rows == 0:
return empty_schema.empty_table()
return batch