| ################################################################################ | |
| # 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. | |
| ################################################################################ | |
| """Single-process Python execution for data-evolution MERGE INTO.""" | |
| from typing import Any, List, Optional, Sequence | |
| import pyarrow as pa | |
| import pyarrow.compute as pc | |
| from pypaimon.execution.python.write.merge_transform import ( | |
| _build_insert_transform, | |
| _build_matched_delete_transform, | |
| _build_matched_transform, | |
| ) | |
| from pypaimon.write.merge_transform import ( | |
| build_delete_schema, | |
| build_update_schema, | |
| ) | |
| from pypaimon.snapshot.snapshot import BATCH_COMMIT_IDENTIFIER | |
| from pypaimon.table.special_fields import SpecialFields | |
| from pypaimon.write.commit_message import CommitMessage | |
| from pypaimon.write.backend_dispatch import create_python_write | |
| from pypaimon.write.table_delete import TableDeleteByRowId | |
| from pypaimon.write.merge_inputs import ( | |
| _PrepareCtx, | |
| _is_self_merge, | |
| _is_table_like, | |
| _normalize_memory_source, | |
| _normalize_on, | |
| _prepare, | |
| _resolve_target_projection, | |
| _union_update_cols, | |
| ) | |
| from pypaimon.write.merge_types import OnSpec, SourceColumnRef, WhenMatched, WhenNotMatched, _NormalizedClause | |
| class PythonTableMergeInto: | |
| """Execute one prepared MERGE request using the Python backend.""" | |
| def __init__( | |
| self, target_table, source: Any, *, on: OnSpec, | |
| when_matched: Sequence[WhenMatched] = (), | |
| when_not_matched: Sequence[WhenNotMatched] = (), | |
| commit_user: str, commit_identifier: int = BATCH_COMMIT_IDENTIFIER): | |
| self._target_table = target_table | |
| self._source = source | |
| self._on = on | |
| self._when_matched = when_matched | |
| self._when_not_matched = when_not_matched | |
| self._commit_user = commit_user | |
| self._commit_identifier = commit_identifier | |
| def prepare_commit(self) -> List[CommitMessage]: | |
| """Prepare MERGE INTO commit messages in the current Python process. | |
| The returned messages must be committed by the caller's matching | |
| ``TableCommit``. | |
| SET and INSERT mappings may contain callables accepting an Arrow table | |
| with qualified ``t.`` target and ``s.`` source columns (INSERT has only | |
| source columns). Each callable evaluates the rows selected by its clause | |
| and returns an Arrow Array or ChunkedArray of the same length. Empty | |
| clauses do not invoke callbacks; callback errors propagate to the caller. | |
| """ | |
| target_table = self._target_table | |
| source = self._source | |
| on = self._on | |
| when_matched = self._when_matched | |
| when_not_matched = self._when_not_matched | |
| commit_user = self._commit_user | |
| commit_identifier = self._commit_identifier | |
| # Empty-target INSERT skips the target scan, but still needs the same | |
| # unrestricted whole-table grant before any user callback or staging. | |
| from pypaimon.write.update_validation import _authorize_update | |
| _authorize_update(target_table) | |
| if any(clause.condition is not None | |
| for clauses in (when_matched, when_not_matched) for clause in clauses): | |
| from pypaimon.write.merge_condition import _load_datafusion | |
| _load_datafusion() | |
| target_on, source_on = _normalize_on(on) | |
| self_merge = _is_self_merge(target_table, source, target_on, source_on) | |
| from pypaimon.snapshot.time_travel_util import TimeTravelUtil | |
| base_snapshot = TimeTravelUtil.resolve_snapshot(target_table) | |
| target_table = target_table._copy_with_snapshot(base_snapshot) | |
| if self_merge: | |
| source = target_table | |
| source_table, matched_specs, not_matched_specs, ctx = _prepare( | |
| target_table, | |
| source, | |
| list(when_matched), | |
| list(when_not_matched), | |
| on, | |
| source_loader=_normalize_source, | |
| ) | |
| update_table, delete_table, insert_table, update_cols_union = _build_tables( | |
| target_table, | |
| source_table, | |
| matched_specs, | |
| not_matched_specs, | |
| ctx, | |
| base_snapshot, | |
| ) | |
| return _prepare_commit_messages( | |
| target_table, | |
| update_table, | |
| delete_table, | |
| insert_table, | |
| update_cols_union, | |
| base_snapshot, | |
| commit_user, | |
| commit_identifier, | |
| ) | |
| def _build_tables( | |
| target_table, | |
| source_table: Optional[pa.Table], | |
| matched_specs: List[_NormalizedClause], | |
| not_matched_specs: List[_NormalizedClause], | |
| ctx: _PrepareCtx, | |
| base_snapshot, | |
| ): | |
| base_snapshot_id = base_snapshot.id if base_snapshot is not None else None | |
| update_table = None | |
| delete_table = None | |
| insert_table = None | |
| update_cols_union: List[str] = [] | |
| if ctx.is_self_merge: | |
| if matched_specs and base_snapshot is not None: | |
| update_cols_union = _union_update_cols(matched_specs) | |
| if update_cols_union: | |
| update_table = _build_self_merge_update_table( | |
| target_table, | |
| matched_specs, | |
| ctx, | |
| update_cols_union, | |
| base_snapshot_id, | |
| ) | |
| if any(c.delete for c in matched_specs): | |
| delete_table = _build_self_merge_delete_table( | |
| target_table, | |
| matched_specs, | |
| ctx, | |
| base_snapshot_id, | |
| ) | |
| return update_table, delete_table, insert_table, update_cols_union | |
| if matched_specs and base_snapshot is not None: | |
| update_cols_union = _union_update_cols(matched_specs) | |
| if not update_cols_union and not any(c.delete for c in matched_specs): | |
| names = list(dict.fromkeys(ctx.target_on_cols + [SpecialFields.ROW_ID.name])) | |
| target = _read_table(target_table, projection=names, snapshot_id=base_snapshot_id) | |
| target = _rename_with_prefix(target, "t.") | |
| _join_with_row_indices(target, _rename_with_prefix(source_table, "s."), | |
| ["t." + name for name in ctx.target_on_cols], | |
| ["s." + name for name in ctx.source_on_cols], | |
| "inner", check_cardinality=True) | |
| if update_cols_union: | |
| update_table = _build_matched_update_table( | |
| target_table, | |
| source_table, | |
| matched_specs, | |
| ctx, | |
| update_cols_union, | |
| base_snapshot_id, | |
| ) | |
| if any(c.delete for c in matched_specs): | |
| delete_table = _build_matched_delete_table( | |
| target_table, | |
| source_table, | |
| matched_specs, | |
| ctx, | |
| base_snapshot_id, | |
| ) | |
| if not_matched_specs: | |
| insert_table = _build_not_matched_insert_table( | |
| target_table, | |
| source_table, | |
| not_matched_specs, | |
| ctx, | |
| base_snapshot_id, | |
| target_empty=base_snapshot is None, | |
| ) | |
| return update_table, delete_table, insert_table, update_cols_union | |
| def _build_self_merge_update_table( | |
| target_table, | |
| clauses: List[_NormalizedClause], | |
| ctx: _PrepareCtx, | |
| update_cols: Sequence[str], | |
| snapshot_id: Optional[int], | |
| ) -> pa.Table: | |
| row_id_name = SpecialFields.ROW_ID.name | |
| needed_cols = set( | |
| _resolve_target_projection( | |
| clauses, | |
| [row_id_name], | |
| update_cols, | |
| ctx.full_target_field_names, | |
| ) | |
| ) | |
| for clause in clauses: | |
| for value in clause.spec.values(): | |
| if isinstance(value, SourceColumnRef): | |
| needed_cols.add(value.column) | |
| target_set = set(ctx.full_target_field_names) | |
| for clause in clauses: | |
| if clause.condition is not None: | |
| from pypaimon.write.merge_condition import extract_columns | |
| for ref in extract_columns(clause.condition): | |
| prefix, col = ref.split(".", 1) | |
| if prefix == "s" and col in target_set: | |
| needed_cols.add(col) | |
| projection = [row_id_name] + [ | |
| c for c in ctx.full_target_field_names if c in needed_cols | |
| ] | |
| target = _read_table(target_table, projection=projection, snapshot_id=snapshot_id) | |
| update_schema = build_update_schema( | |
| ctx.update_pa_schema, update_cols, row_id_name | |
| ) | |
| if target.num_rows == 0: | |
| return update_schema.empty_table() | |
| orig_names = list(target.schema.names) | |
| target_renamed = _rename_with_prefix(target, "t.") | |
| aliased = _add_self_merge_source_aliases( | |
| target_renamed, orig_names, row_id_name | |
| ) | |
| transform = _build_matched_transform( | |
| clauses, | |
| on_map={row_id_name: row_id_name}, | |
| on_pairs=[(row_id_name, row_id_name)], | |
| update_cols=list(update_cols), | |
| row_id_name=row_id_name, | |
| update_schema=update_schema, | |
| ) | |
| return transform(aliased) | |
| def _build_self_merge_delete_table( | |
| target_table, | |
| clauses: List[_NormalizedClause], | |
| ctx: _PrepareCtx, | |
| snapshot_id: Optional[int], | |
| ) -> pa.Table: | |
| row_id_name = SpecialFields.ROW_ID.name | |
| needed_cols = set( | |
| _resolve_target_projection( | |
| clauses, | |
| [row_id_name], | |
| [], | |
| ctx.full_target_field_names, | |
| ) | |
| ) | |
| target_set = set(ctx.full_target_field_names) | |
| for clause in clauses: | |
| if clause.condition is not None: | |
| from pypaimon.write.merge_condition import extract_columns | |
| for ref in extract_columns(clause.condition): | |
| prefix, col = ref.split(".", 1) | |
| if prefix == "s" and col in target_set: | |
| needed_cols.add(col) | |
| projection = [row_id_name] + [ | |
| c for c in ctx.full_target_field_names if c in needed_cols | |
| ] | |
| target = _read_table(target_table, projection=projection, snapshot_id=snapshot_id) | |
| delete_schema = build_delete_schema(row_id_name) | |
| if target.num_rows == 0: | |
| return delete_schema.empty_table() | |
| orig_names = list(target.schema.names) | |
| target_renamed = _rename_with_prefix(target, "t.") | |
| aliased = _add_self_merge_source_aliases( | |
| target_renamed, orig_names, row_id_name | |
| ) | |
| transform = _build_matched_delete_transform( | |
| clauses, | |
| on_map={row_id_name: row_id_name}, | |
| row_id_name=row_id_name, | |
| delete_schema=delete_schema, | |
| ) | |
| return transform(aliased) | |
| def _build_matched_update_table( | |
| target_table, | |
| source_table: pa.Table, | |
| clauses: List[_NormalizedClause], | |
| ctx: _PrepareCtx, | |
| update_cols: Sequence[str], | |
| snapshot_id: Optional[int], | |
| ) -> pa.Table: | |
| row_id_name = SpecialFields.ROW_ID.name | |
| needed_cols = _resolve_target_projection( | |
| clauses, | |
| ctx.target_on_cols, | |
| update_cols, | |
| ctx.settable_field_names, | |
| ) | |
| projection = [row_id_name] + [c for c in needed_cols if c != row_id_name] | |
| target = _read_table(target_table, projection=projection, snapshot_id=snapshot_id) | |
| update_schema = build_update_schema( | |
| ctx.update_pa_schema, update_cols, row_id_name | |
| ) | |
| if target.num_rows == 0 or source_table.num_rows == 0: | |
| return update_schema.empty_table() | |
| target_renamed = _rename_with_prefix(target, "t.") | |
| source_renamed = _rename_with_prefix(source_table, "s.") | |
| joined = _join_with_row_indices( | |
| target_renamed, | |
| source_renamed, | |
| keys=["t.{}".format(c) for c in ctx.target_on_cols], | |
| right_keys=["s.{}".format(c) for c in ctx.source_on_cols], | |
| join_type="inner", | |
| check_cardinality=True, | |
| ) | |
| transform = _build_matched_transform( | |
| clauses, | |
| on_map=dict(zip(ctx.source_on_cols, ctx.target_on_cols)), | |
| on_pairs=list(zip(ctx.source_on_cols, ctx.target_on_cols)), | |
| update_cols=list(update_cols), | |
| row_id_name=row_id_name, | |
| update_schema=update_schema, | |
| ) | |
| return transform(joined) | |
| def _build_matched_delete_table( | |
| target_table, | |
| source_table: pa.Table, | |
| clauses: List[_NormalizedClause], | |
| ctx: _PrepareCtx, | |
| snapshot_id: Optional[int], | |
| ) -> pa.Table: | |
| row_id_name = SpecialFields.ROW_ID.name | |
| needed_cols = _resolve_target_projection( | |
| clauses, | |
| ctx.target_on_cols, | |
| [], | |
| ctx.settable_field_names, | |
| ) | |
| projection = [row_id_name] + [c for c in needed_cols if c != row_id_name] | |
| target = _read_table(target_table, projection=projection, snapshot_id=snapshot_id) | |
| delete_schema = build_delete_schema(row_id_name) | |
| if target.num_rows == 0 or source_table.num_rows == 0: | |
| return delete_schema.empty_table() | |
| target_renamed = _rename_with_prefix(target, "t.") | |
| source_renamed = _rename_with_prefix(source_table, "s.") | |
| joined = _join_with_row_indices( | |
| target_renamed, | |
| source_renamed, | |
| keys=["t.{}".format(c) for c in ctx.target_on_cols], | |
| right_keys=["s.{}".format(c) for c in ctx.source_on_cols], | |
| join_type="inner", | |
| check_cardinality=not (len(clauses) == 1 and clauses[0].delete and clauses[0].condition is None), | |
| deduplicate_left=len(clauses) == 1 and clauses[0].delete and clauses[0].condition is None, | |
| ) | |
| transform = _build_matched_delete_transform( | |
| clauses, | |
| on_map=dict(zip(ctx.source_on_cols, ctx.target_on_cols)), | |
| row_id_name=row_id_name, | |
| delete_schema=delete_schema, | |
| ) | |
| return transform(joined) | |
| def _build_not_matched_insert_table( | |
| target_table, | |
| source_table: pa.Table, | |
| clauses: List[_NormalizedClause], | |
| ctx: _PrepareCtx, | |
| snapshot_id: Optional[int], | |
| target_empty: bool = False, | |
| ) -> pa.Table: | |
| source_renamed = _rename_with_prefix(source_table, "s.") | |
| if source_renamed.num_rows == 0: | |
| return ctx.full_pa_schema.empty_table() | |
| if target_empty: | |
| unmatched = source_renamed | |
| else: | |
| target = _read_table( | |
| target_table, | |
| projection=list(ctx.target_on_cols), | |
| snapshot_id=snapshot_id, | |
| ) | |
| if target.num_rows == 0: | |
| unmatched = source_renamed | |
| else: | |
| target_renamed = _rename_with_prefix(target, "t.") | |
| unmatched = _join_with_row_indices( | |
| source_renamed, | |
| target_renamed, | |
| keys=["s.{}".format(c) for c in ctx.source_on_cols], | |
| right_keys=["t.{}".format(c) for c in ctx.target_on_cols], | |
| join_type="left anti", | |
| ) | |
| transform = _build_insert_transform( | |
| clauses, ctx.full_target_field_names, ctx.full_pa_schema | |
| ) | |
| return transform(unmatched) | |
| def _join_with_row_indices(left, right, keys, right_keys, join_type, | |
| check_cardinality=False, deduplicate_left=False): | |
| """Keep nested payloads out of Arrow's join, then gather matched rows.""" | |
| key_names = ["key_%d" % index for index in range(len(keys))] | |
| left_keys = left.select(keys).rename_columns(key_names).append_column( | |
| "left_index", pa.array(range(left.num_rows), type=pa.int64())) | |
| right_keys_table = right.select(right_keys).rename_columns(key_names) | |
| if join_type == "inner": | |
| right_keys_table = right_keys_table.append_column( | |
| "right_index", pa.array(range(right.num_rows), type=pa.int64())) | |
| indices = left_keys.join(right_keys_table, keys=key_names, join_type=join_type) | |
| # Spark checks source-match cardinality before action predicates. A sole | |
| # unconditional DELETE is unambiguous and may collapse duplicate matches. | |
| if check_cardinality and pc.count_distinct(indices["left_index"]).as_py() != indices.num_rows: | |
| raise ValueError("MERGE matched multiple source rows to the same target _ROW_ID. " | |
| "Deduplicate the source before merging.") | |
| if deduplicate_left: | |
| indices = indices.group_by("left_index", use_threads=False).aggregate([ | |
| ("right_index", "first")]).rename_columns(["left_index", "right_index"]) | |
| result = left.take(indices["left_index"]) | |
| if join_type == "inner": | |
| # Match Table.join's coalesced-key schema: keep only the left keys. | |
| payload = right.select([ | |
| name for name in right.column_names if name not in right_keys | |
| ]).take(indices["right_index"]) | |
| for field, column in zip(payload.schema, payload.columns): | |
| result = result.append_column(field, column) | |
| return result | |
| def _prepare_commit_messages( | |
| table, | |
| update_table: Optional[pa.Table], | |
| delete_table: Optional[pa.Table], | |
| insert_table: Optional[pa.Table], | |
| update_cols_union: Sequence[str], | |
| base_snapshot, | |
| commit_user: str, | |
| commit_identifier: int, | |
| ) -> List[CommitMessage]: | |
| all_msgs: list = [] | |
| _validate_unique_action_row_ids(update_table, delete_table) | |
| if update_table is not None and update_table.num_rows > 0: | |
| update_snapshot_table = _copy_at_snapshot( | |
| table, base_snapshot.id if base_snapshot is not None else None | |
| ) | |
| from pypaimon.write.table_update_by_row_id import TableUpdateByRowId | |
| updater = TableUpdateByRowId._from_python( | |
| update_snapshot_table, commit_user, commit_identifier) | |
| update_msgs = updater.update_columns( | |
| update_table, list(update_cols_union) | |
| ) | |
| all_msgs.extend(update_msgs) | |
| if delete_table is not None and delete_table.num_rows > 0: | |
| delete_snapshot_table = _copy_at_snapshot( | |
| table, base_snapshot.id if base_snapshot is not None else None | |
| ) | |
| deleter = TableDeleteByRowId._from_python(delete_snapshot_table) | |
| row_id_name = SpecialFields.ROW_ID.name | |
| delete_msgs = deleter.delete( | |
| delete_table.column(row_id_name).to_pylist() | |
| ) | |
| all_msgs.extend(delete_msgs) | |
| if insert_table is not None and insert_table.num_rows > 0: | |
| writer = _new_table_write(table, commit_user, commit_identifier) | |
| try: | |
| writer.write_arrow(insert_table) | |
| if commit_identifier == BATCH_COMMIT_IDENTIFIER: | |
| insert_msgs = writer.prepare_commit() | |
| else: | |
| insert_msgs = writer.prepare_commit(commit_identifier) | |
| finally: | |
| writer.close() | |
| all_msgs.extend(insert_msgs) | |
| return all_msgs | |
| def _new_table_write(table, commit_user: str, commit_identifier: int): | |
| return create_python_write(table, commit_user, stream=commit_identifier != BATCH_COMMIT_IDENTIFIER) | |
| def _normalize_source(source: Any, on_columns=()) -> pa.Table: | |
| if isinstance(source, pa.Table): | |
| return source | |
| if _is_table_like(source): | |
| # Let the read resolve its branch and point-in-time selectors once. | |
| # Pinning the latest snapshot here would override the source's options. | |
| projection = list(dict.fromkeys(list(source.field_names) + list(on_columns))) | |
| return _read_table(source, projection=projection) | |
| return _normalize_memory_source(source) | |
| def _read_table(table, projection: Optional[List[str]] = None, | |
| snapshot_id: Optional[int] = None) -> pa.Table: | |
| read_table = _copy_at_snapshot(table, snapshot_id) | |
| read_builder = read_table.new_read_builder() | |
| if projection is not None: | |
| read_builder.with_projection(projection) | |
| scan = read_builder.new_scan() | |
| return read_builder.new_read().to_arrow(scan.plan_all().splits()) | |
| def _copy_at_snapshot(table, snapshot_id: Optional[int]): | |
| if snapshot_id is None: | |
| return table | |
| from pypaimon.snapshot.time_travel_util import TimeTravelUtil | |
| snapshot = TimeTravelUtil.resolve_snapshot(table) | |
| if snapshot is None or snapshot.id != snapshot_id: | |
| snapshot = table.snapshot_manager().get_snapshot_by_id(snapshot_id) | |
| return table._copy_with_snapshot(snapshot) | |
| def _validate_unique_action_row_ids( | |
| update_table: Optional[pa.Table], | |
| delete_table: Optional[pa.Table], | |
| ) -> None: | |
| chunks = [] | |
| total_rows = 0 | |
| row_id_name = SpecialFields.ROW_ID.name | |
| for table in (update_table, delete_table): | |
| if table is None or table.num_rows == 0: | |
| continue | |
| total_rows += table.num_rows | |
| chunks.extend(table.column(row_id_name).chunks) | |
| if not chunks: | |
| return | |
| row_ids = pa.chunked_array(chunks) | |
| if pc.count_distinct(row_ids).as_py() != total_rows: | |
| raise ValueError( | |
| "MERGE matched multiple source rows to the same target _ROW_ID. " | |
| "Deduplicate the source before merging." | |
| ) | |
| def _rename_with_prefix(table: pa.Table, prefix: str) -> pa.Table: | |
| return table.rename_columns( | |
| ["{}{}".format(prefix, name) for name in table.schema.names] | |
| ) | |
| def _add_self_merge_source_aliases( | |
| target_renamed: pa.Table, orig_names: Sequence[str], row_id_name: str | |
| ) -> pa.Table: | |
| columns = list(target_renamed.columns) | |
| names = list(target_renamed.schema.names) | |
| for orig in orig_names: | |
| if orig == row_id_name: | |
| continue | |
| target_name = "t.{}".format(orig) | |
| if target_name in names: | |
| idx = names.index(target_name) | |
| columns.append(columns[idx]) | |
| names.append("s.{}".format(orig)) | |
| return pa.table(columns, names=names) |