blob: ac556b66757cfdde39a6e6ce33ada11818e41039 [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.
################################################################################
"""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)