blob: 8a6ff9e08c055dc9775cf7f83f812200d09ff631 [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.
"""Transport joined Arrow batches to the Core MERGE action executor."""
def _callable_assignment(function, on_pairs, callable_input_columns=None, hidden_columns=()):
from pypaimon.write.merge_transform import _merge_callable_input
def evaluate(batch):
if hidden_columns:
batch = batch.drop(hidden_columns)
if callable_input_columns is not None:
import pyarrow as pa
view = pa.Table.from_arrays(
[batch.column('t.' + name) for name in callable_input_columns],
names=list(callable_input_columns),
)
else:
view = _merge_callable_input(batch, on_pairs)
return function(view)
return evaluate
def encode_merge_clause(clause, on_pairs=(), on_map=None, input_schema=None,
callable_input_columns=None, hidden_columns=(), not_matched=False):
"""Adapt a normalized PyPaimon clause to the typed SDK without running callbacks."""
from pypaimon_rust.datafusion import MergeAssignment, WhenMatched, WhenNotMatched
from pypaimon.write.merge_types import SourceColumnRef, TargetColumnRef, LiteralValue
from pypaimon.write.merge_condition import remap_source_on_keys, rewrite_condition
available = None if input_schema is None else set(input_schema.names)
values = {}
for name, value in clause.spec.items():
if isinstance(value, SourceColumnRef):
kind, column = 'source', value.column
if available is not None and 's.' + column not in available:
target = next((target for source, target in on_pairs
if source == column and 't.' + target in available), None)
if target is None:
values[name] = MergeAssignment.literal(None)
continue
kind, column = 'target', target
values[name] = (MergeAssignment.source_column(column) if kind == 'source' else
MergeAssignment.target_column(column))
elif isinstance(value, TargetColumnRef):
if available is not None and 't.' + value.column not in available:
values[name] = MergeAssignment.literal(None)
else:
values[name] = MergeAssignment.target_column(value.column)
elif isinstance(value, LiteralValue):
values[name] = MergeAssignment.literal(value.value)
elif callable(value) and not isinstance(value, type):
values[name] = MergeAssignment.function(_callable_assignment(
value, on_pairs, callable_input_columns, hidden_columns))
else:
return None
condition = None
if clause.condition is not None:
rewritten = rewrite_condition(clause.condition)
if on_map:
rewritten = remap_source_on_keys(rewritten, on_map)
condition = rewritten
if not_matched:
return WhenNotMatched(values, condition)
if clause.delete:
return WhenMatched.delete(condition)
return WhenMatched.update(values, condition)
class _NativeMergeTransform:
def __init__(self, clauses, output_schema, mode, on_pairs=(), on_map=None,
callable_input_columns=None, row_id_name='_ROW_ID'):
# Require this release's protocol before consuming input. An installed
# incompatible SDK must not silently select another executor.
from pypaimon_rust.datafusion import ( # noqa: F401
MergeAssignment, MergeBatchExecution, WhenMatched, WhenNotMatched,
)
self.clauses = clauses
self.output_schema = output_schema
self.mode = mode
self.on_pairs = on_pairs
self.on_map = on_map
self.callable_input_columns = callable_input_columns
self.row_id_name = row_id_name
self._execution = None
def __getstate__(self):
# Ray transports the request; each worker compiles its own operator.
state = self.__dict__.copy()
state['_execution'] = None
return state
def __call__(self, batch):
if batch.num_rows == 0:
return self.output_schema.empty_table()
output_schema = self.output_schema
hidden_columns = ()
if self.mode != 'insert' and self.row_id_name != '_ROW_ID':
batch = batch.append_column('t._ROW_ID', batch.column('t.' + self.row_id_name))
hidden_columns = ('t._ROW_ID',)
index = output_schema.get_field_index(self.row_id_name)
output_schema = output_schema.set(index, output_schema.field(index).with_name('_ROW_ID'))
if self._execution is None:
from pypaimon_rust.datafusion import MergeBatchExecution
clauses = [encode_merge_clause(
clause, self.on_pairs, self.on_map, batch.schema,
self.callable_input_columns, hidden_columns, not_matched=self.mode == 'insert',
) for clause in self.clauses]
if any(clause is None for clause in clauses):
raise TypeError('Unsupported normalized MERGE assignment')
if self.mode == 'update':
self._execution = MergeBatchExecution.for_updates(batch.schema, output_schema, clauses)
elif self.mode == 'delete':
self._execution = MergeBatchExecution.for_deletes(batch.schema, clauses)
else:
self._execution = MergeBatchExecution.for_inserts(batch.schema, output_schema, clauses)
result = self._execution.execute(batch)
if hidden_columns or (self.mode == 'delete' and not result.schema.equals(
self.output_schema, check_metadata=True)):
import pyarrow as pa
result = pa.Table.from_arrays(result.columns, schema=self.output_schema)
return result
def _build_matched_transform(
clauses, on_map, on_pairs, update_cols, row_id_name,
update_schema, callable_input_columns=None,
):
return _NativeMergeTransform(
clauses, update_schema, 'update', on_pairs, on_map,
callable_input_columns, row_id_name,
)
def _build_matched_delete_transform(clauses, on_map, row_id_name, delete_schema):
return _NativeMergeTransform(
clauses, delete_schema, 'delete', on_map=on_map, row_id_name=row_id_name,
)
def _build_insert_transform(clauses, target_field_names, target_pa_schema):
return _NativeMergeTransform(clauses, target_pa_schema, 'insert')