| ################################################################################ |
| # 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 MERGE INTO for Paimon data-evolution tables.""" |
| |
| from dataclasses import dataclass |
| from typing import Any, List, Optional, Sequence |
| |
| import pyarrow as pa |
| import pyarrow.compute as pc |
| |
| from pypaimon.common.options.core_options import CoreOptions |
| from pypaimon.ray.data_evolution_merge_into import ( |
| _normalize_on, |
| _normalize_set_spec, |
| _resolve_target_projection, |
| _union_update_cols, |
| _validate_source_has_target_cols, |
| ) |
| from pypaimon.ray.data_evolution_merge_join import ( |
| _build_matched_delete_transform, |
| _build_matched_transform, |
| ) |
| from pypaimon.ray.data_evolution_merge_transform import ( |
| OnSpec, |
| SourceColumnRef, |
| WhenMatched, |
| WhenNotMatched, |
| _NormalizedClause, |
| build_delete_schema, |
| build_update_schema, |
| cast_to_schema, |
| lit, |
| source_col, |
| target_col, |
| vectorized_insert_transform, |
| ) |
| 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.table_write import BatchTableWrite, StreamTableWrite |
| from pypaimon.write.table_delete import TableDeleteByRowId |
| from pypaimon.write.table_update_by_row_id import TableUpdateByRowId |
| |
| __all__ = [ |
| "merge_into", |
| "WhenMatched", |
| "WhenNotMatched", |
| "source_col", |
| "target_col", |
| "lit", |
| ] |
| |
| |
| @dataclass(frozen=True) |
| class _PrepareCtx: |
| target_on_cols: List[str] |
| source_on_cols: List[str] |
| settable_field_names: List[str] |
| full_target_field_names: List[str] |
| update_pa_schema: pa.Schema |
| full_pa_schema: pa.Schema |
| is_self_merge: bool = False |
| |
| |
| def merge_into( |
| target_table, |
| source: Any, |
| *, |
| on: OnSpec, |
| when_matched: Sequence[WhenMatched] = (), |
| when_not_matched: Sequence[WhenNotMatched] = (), |
| commit_user: str, |
| commit_identifier: int = BATCH_COMMIT_IDENTIFIER, |
| ) -> List[CommitMessage]: |
| """Prepare MERGE INTO commit messages in the current Python process. |
| |
| The returned messages must be committed by the caller's matching |
| ``TableCommit``. |
| """ |
| base_snapshot = target_table.snapshot_manager().get_latest_snapshot() |
| source_table, matched_specs, not_matched_specs, ctx = _prepare( |
| target_table, |
| source, |
| list(when_matched), |
| list(when_not_matched), |
| on, |
| ) |
| |
| 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 _prepare( |
| target_table, |
| source, |
| when_matched, |
| when_not_matched, |
| on, |
| ): |
| if not when_matched and not when_not_matched: |
| raise ValueError( |
| "At least one of when_matched or when_not_matched must be non-empty." |
| ) |
| for label, clauses in [("when_matched", when_matched), |
| ("when_not_matched", when_not_matched)]: |
| for i, clause in enumerate(clauses[:-1]): |
| if clause.condition is None: |
| raise ValueError( |
| "Only the last {} clause may omit its condition. " |
| "Clause at index {} has no condition, making subsequent " |
| "clauses unreachable.".format(label, i) |
| ) |
| |
| target_on_cols, source_on_cols = _normalize_on(on) |
| if not target_table.options.data_evolution_enabled(): |
| raise ValueError( |
| "merge_into requires 'data-evolution.enabled' = 'true' on target table." |
| ) |
| if not target_table.options.row_tracking_enabled(): |
| raise ValueError( |
| "merge_into requires 'row-tracking.enabled' = 'true' on target table." |
| ) |
| if any(c.delete for c in when_matched) and not ( |
| target_table.options.deletion_vectors_enabled(False) |
| ): |
| raise ValueError( |
| "merge_into DELETE requires 'deletion-vectors.enabled' = " |
| "'true' on target table." |
| ) |
| |
| full_target_field_names = list(target_table.field_names) |
| settable_field_names = list(full_target_field_names) |
| on_map = dict(zip(target_on_cols, source_on_cols)) |
| |
| matched_specs = [] |
| for c in when_matched: |
| spec = {} |
| if not c.delete: |
| spec = _normalize_set_spec( |
| c.update, |
| settable_field_names, |
| on_map, |
| ) |
| matched_specs.append( |
| _NormalizedClause( |
| spec=spec, |
| condition=c.condition, |
| delete=c.delete, |
| ) |
| ) |
| if matched_specs and target_table.partition_keys: |
| partition_set = set(target_table.partition_keys) |
| for clause in matched_specs: |
| modified_partition_cols = partition_set & set(clause.spec.keys()) |
| if modified_partition_cols: |
| raise ValueError( |
| "merge_into does not support updating partition columns " |
| "{}; cross-partition row movement is not implemented." |
| .format(sorted(modified_partition_cols)) |
| ) |
| |
| has_condition = any( |
| c.condition is not None |
| for c in list(when_matched) + list(when_not_matched) |
| ) |
| if has_condition: |
| from pypaimon.ray.merge_condition import ( |
| _load_datafusion, |
| extract_target_columns, |
| ) |
| |
| _load_datafusion() |
| for c in when_not_matched: |
| if c.condition is not None: |
| t_refs = extract_target_columns(c.condition) |
| if t_refs: |
| raise ValueError( |
| "WhenNotMatched condition must not reference " |
| "target columns (t.*), but found: {}".format( |
| sorted(t_refs) |
| ) |
| ) |
| |
| not_matched_specs = [] |
| for c in when_not_matched: |
| spec = _normalize_set_spec( |
| c.insert, |
| settable_field_names, |
| on_map, |
| allow_target_refs=False, |
| ) |
| for tk, sk in on_map.items(): |
| if tk in settable_field_names and tk not in spec: |
| spec[tk] = SourceColumnRef(sk) |
| not_matched_specs.append( |
| _NormalizedClause(spec=spec, condition=c.condition) |
| ) |
| |
| is_self_merge = _is_self_merge( |
| target_table, source, target_on_cols, source_on_cols |
| ) |
| if is_self_merge and not_matched_specs: |
| raise ValueError( |
| "Self-merge (source == target with ON _ROW_ID) does not " |
| "support WHEN NOT MATCHED clauses." |
| ) |
| |
| if is_self_merge: |
| source_table = None |
| source_col_names = set(full_target_field_names) | set(source_on_cols) |
| else: |
| source_table = _normalize_source(source) |
| _validate_source_on_cols(source_table, source_on_cols) |
| source_col_names = set(source_table.schema.names) |
| |
| _validate_source_has_target_cols( |
| source_col_names, matched_specs + not_matched_specs |
| ) |
| |
| if has_condition: |
| from pypaimon.ray.merge_condition import extract_columns |
| |
| target_names = set(full_target_field_names) |
| if is_self_merge: |
| target_names |= set(target_on_cols) |
| for c in list(when_matched) + list(when_not_matched): |
| if c.condition is not None: |
| for ref in extract_columns(c.condition): |
| prefix, col = ref.split(".", 1) |
| if prefix == "s" and col not in source_col_names: |
| raise ValueError( |
| "condition references unknown source column '{}'" |
| .format(col) |
| ) |
| if prefix == "t" and col not in target_names: |
| raise ValueError( |
| "condition references unknown target column '{}'" |
| .format(col) |
| ) |
| |
| from pypaimon.schema.data_types import PyarrowFieldParser |
| |
| full_pa_schema = PyarrowFieldParser.from_paimon_schema( |
| target_table.table_schema.fields |
| ) |
| update_pa_schema = pa.schema( |
| [full_pa_schema.field(c) for c in settable_field_names] |
| ) |
| ctx = _PrepareCtx( |
| target_on_cols=target_on_cols, |
| source_on_cols=source_on_cols, |
| settable_field_names=settable_field_names, |
| full_target_field_names=full_target_field_names, |
| update_pa_schema=update_pa_schema, |
| full_pa_schema=full_pa_schema, |
| is_self_merge=is_self_merge, |
| ) |
| return source_table, matched_specs, not_matched_specs, ctx |
| |
| |
| 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 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.ray.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.ray.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 = target_renamed.join( |
| 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", |
| ) |
| 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 = target_renamed.join( |
| 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", |
| ) |
| 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 = source_renamed.join( |
| 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 _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 |
| ) |
| updater = TableUpdateByRowId( |
| 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(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): |
| if commit_identifier == BATCH_COMMIT_IDENTIFIER: |
| return BatchTableWrite(table, commit_user) |
| return StreamTableWrite(table, commit_user) |
| |
| |
| def _build_insert_transform( |
| clauses: List[_NormalizedClause], |
| target_field_names: Sequence[str], |
| target_pa_schema: pa.Schema, |
| ): |
| prepared_clauses = [] |
| for clause in clauses: |
| rewritten = None |
| if clause.condition is not None: |
| from pypaimon.ray.merge_condition import rewrite_condition |
| |
| rewritten = rewrite_condition(clause.condition) |
| prepared_clauses.append((clause.spec, rewritten)) |
| |
| _filter_batch = None |
| if any(r is not None for _, r in prepared_clauses): |
| from pypaimon.ray.merge_condition import filter_batch as _filter_batch |
| |
| def _transform(batch: pa.Table) -> pa.Table: |
| remaining = batch |
| parts = [] |
| for spec, rewritten in prepared_clauses: |
| if remaining.num_rows == 0: |
| break |
| if rewritten is not None: |
| matched = _filter_batch( |
| remaining, rewritten, _pre_rewritten=True |
| ) |
| if matched.num_rows > 0: |
| parts.append( |
| vectorized_insert_transform( |
| matched, |
| spec, |
| target_field_names, |
| target_pa_schema, |
| ) |
| ) |
| if matched.num_rows < remaining.num_rows: |
| not_cond = "COALESCE(NOT ({}), TRUE)".format(rewritten) |
| remaining = _filter_batch( |
| remaining, not_cond, _pre_rewritten=True |
| ) |
| else: |
| remaining = remaining.slice(0, 0) |
| else: |
| parts.append( |
| vectorized_insert_transform( |
| remaining, |
| spec, |
| target_field_names, |
| target_pa_schema, |
| ) |
| ) |
| remaining = remaining.slice(0, 0) |
| if not parts: |
| return target_pa_schema.empty_table() |
| return cast_to_schema(pa.concat_tables(parts), target_pa_schema) |
| |
| return _transform |
| |
| |
| def _normalize_source(source: Any) -> pa.Table: |
| if isinstance(source, pa.Table): |
| return source |
| if _is_table_like(source): |
| snapshot = source.snapshot_manager().get_latest_snapshot() |
| snapshot_id = snapshot.id if snapshot is not None else None |
| return _read_table(source, snapshot_id=snapshot_id) |
| try: |
| import pandas as pd |
| except ImportError: |
| pd = None |
| if pd is not None and isinstance(source, pd.DataFrame): |
| return pa.Table.from_pandas(source, preserve_index=False) |
| raise TypeError( |
| "source must be a pyarrow.Table, a pandas.DataFrame, or a " |
| "Paimon table; got {}.".format(type(source).__name__) |
| ) |
| |
| |
| 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_for_write().splits()) |
| |
| |
| def _copy_at_snapshot(table, snapshot_id: Optional[int]): |
| if snapshot_id is None: |
| return table |
| return table.copy({CoreOptions.SCAN_SNAPSHOT_ID.key(): str(snapshot_id)}) |
| |
| |
| def _validate_source_on_cols(source_table: pa.Table, on: Sequence[str]) -> None: |
| names = set(source_table.schema.names) |
| missing = [c for c in on if c not in names] |
| if missing: |
| raise ValueError( |
| "'on' columns {} missing from source schema {}.".format( |
| missing, list(names) |
| ) |
| ) |
| |
| |
| 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 _is_self_merge(target_table, source, target_on, source_on) -> bool: |
| row_id_name = SpecialFields.ROW_ID.name |
| return ( |
| _is_table_like(source) |
| and _same_table(target_table, source) |
| and target_on == [row_id_name] |
| and source_on == [row_id_name] |
| ) |
| |
| |
| def _is_table_like(obj) -> bool: |
| return hasattr(obj, "new_read_builder") and hasattr(obj, "snapshot_manager") |
| |
| |
| def _same_table(left, right) -> bool: |
| if left is right: |
| return True |
| return ( |
| getattr(left, "table_path", None) == getattr(right, "table_path", None) |
| and str(getattr(left, "identifier", "")) == str( |
| getattr(right, "identifier", "") |
| ) |
| and _current_branch(left) == _current_branch(right) |
| ) |
| |
| |
| def _current_branch(table): |
| current_branch = getattr(table, "current_branch", None) |
| return current_branch() if current_branch is not None else None |
| |
| |
| 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) |