blob: 40aa0a63d4af8a9c4569a7d0960e2dcb8f1ea19d [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.
"""Select update execution before mutations, preserving public owner hooks."""
from typing import Any, Iterable, List, Mapping, Optional, Sequence
import pyarrow as pa
from pypaimon.common.predicate import Predicate
from pypaimon.write.backend_dispatch import (
create_python_merge_into_operation, create_python_update_operation, create_python_upsert_operation,
prepare_native_write_operation,
)
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.row_utils import row_to_named_values
from pypaimon.write.row_input import RowIdInput, normalize_row_ids
from pypaimon.write.table_update_by_row_id import TableUpdateByRowId
from pypaimon.write.table_upsert_by_key import TableUpsertByKey
from pypaimon.write.update_validation import _authorize_update
class TableUpdateOperations:
def __init__(self, context):
self._context = context
def _new_row_id_updater(self, commit_identifier: int, _precomputed_files_info=None) -> TableUpdateByRowId:
context = self._context
# The public constructor and builders share one selection boundary,
# including complete plans transported to distributed workers.
return TableUpdateByRowId(context.table, context.commit_user, commit_identifier,
_precomputed_files_info=_precomputed_files_info)
def new_shard_updator(self, shard_num: int, total_shard_count: int):
context = self._context
from pypaimon.write.table_update import StreamTableUpdate
"""Create a shard updater for scan and rewrite style updates."""
if total_shard_count <= 0 or shard_num < 0 or shard_num >= total_shard_count:
raise ValueError('Shard index must be smaller than a positive shard count')
if context.update_cols == []:
raise ValueError('Shard update columns must not be empty')
_authorize_update(context.table)
native = prepare_native_write_operation(
'shard_update',
context.table,
context.commit_user,
context.projection,
context.update_cols,
shard_num,
total_shard_count,
stream=isinstance(
context,
StreamTableUpdate))
if native is not None:
return native
from pypaimon.write.table_update import ShardTableUpdator
return ShardTableUpdator._from_python(
context.table, context.projection, context.update_cols, context.commit_user, shard_num, total_shard_count)
def _update_by_arrow_with_row_id(self, table: pa.Table, commit_identifier: int) -> List[CommitMessage]:
context = self._context
cols = context.update_cols if context.update_cols is not None else [
c for c in table.column_names if c != SpecialFields.ROW_ID.name]
operation = None
if table.num_rows and cols and (SpecialFields.ROW_ID.name in table.column_names):
operation = prepare_native_write_operation('update', context.table, context.commit_user, cols)
if operation is None:
operation = create_python_update_operation(context, commit_identifier)
return operation.update_by_arrow_with_row_id(table)
def _upsert_by_arrow_with_key(
self, table: pa.Table, upsert_keys: List[str], commit_identifier: int) -> List[CommitMessage]:
"""Shared implementation for ``upsert_by_arrow_with_key``.
For each row in the input Arrow table:
* If one or more rows with the same composite ``upsert_keys`` value
already exist → update all of them in-place.
* Otherwise → append as a new row.
The public method lives on the concrete subclasses so each can
expose the signature appropriate to its mode (batch vs stream).
Args:
table: Input Arrow table containing rows to upsert.
upsert_keys: One or more column names forming the composite match key.
commit_identifier: Identifier to tag the produced commit messages with.
Returns:
List of :class:`CommitMessage` objects to be committed.
"""
context = self._context
return TableUpsertByKey(context.table, context.commit_user, commit_identifier).upsert(
table, upsert_keys, context.update_cols)
def _upsert_by_key(self, rows, upsert_keys: List[str], commit_identifier: int) -> List[CommitMessage]:
context = self._context
return TableUpsertByKey(context.table, context.commit_user, commit_identifier).upsert_rows(
rows, upsert_keys, context.update_cols)
def _merge_into(self, source: Any, on, when_matched: Sequence, when_not_matched: Sequence,
commit_identifier: int) -> List[CommitMessage]:
context = self._context
from pypaimon.table.data_evolution_merge_into import merge_into
return merge_into(context.table, source, on=on, when_matched=when_matched, when_not_matched=when_not_matched,
commit_user=context.commit_user, commit_identifier=commit_identifier)
def _update_by_predicate(
self, predicate: Optional[Predicate], assignments: Mapping[str, Any],
commit_identifier: int, read_columns: Optional[Sequence[str]] = None) -> List[CommitMessage]:
"""Shared implementation for SQL-like ``UPDATE ... WHERE ...``.
``predicate`` identifies the target rows. Assignment values may be
literals or callables receiving one matched logical file group as an
Arrow table.
"""
context = self._context
has_callable = any((callable(value) for value in assignments.values()))
has_array = any((isinstance(value, (pa.Array, pa.ChunkedArray)) for value in assignments.values()))
read_columns = tuple(read_columns or ())
context._validate_predicate_update(assignments, read_columns, has_callable, has_array)
operation = None
if context.table.options.native_write_enabled():
operation = prepare_native_write_operation(
'predicate_update',
context.table,
context.commit_user,
predicate,
list(assignments))
if operation is None:
operation = create_python_update_operation(
context, commit_identifier, predicate, has_callable=has_callable, has_array=has_array)
return operation.update(assignments, read_columns)
def _delete_by_predicate(self, predicate: Optional[Predicate], commit_identifier: int) -> List[CommitMessage]:
context = self._context
operation = prepare_native_write_operation('predicate_delete', context.table, context.commit_user, predicate)
if operation is None:
operation = create_python_update_operation(context, commit_identifier, predicate)
return operation.delete()
def _delete_by_row_id(self, row_ids: RowIdInput, commit_identifier: int) -> List[CommitMessage]:
context = self._context
row_ids = normalize_row_ids(row_ids)
operation = prepare_native_write_operation('delete', context.table, context.commit_user)
if operation is None:
operation = create_python_update_operation(context, commit_identifier)
return operation.delete_by_row_id(row_ids)
def update_by_arrow_batches_with_row_id(self, tables: Iterable[pa.Table]) -> List[CommitMessage]:
"""Apply row-id updates from batches using one target-file index.
For each updated column, batches must target disjoint ``first_row_id``
file groups. Conflicting overlap is rejected. Already prepared files
are retained because they may have been published by the caller.
"""
context = self._context
operation = prepare_native_write_operation('update', context.table, context.commit_user, context.update_cols)
if operation is None:
operation = create_python_update_operation(context, BATCH_COMMIT_IDENTIFIER)
return operation.update_by_arrow_batches_with_row_id(tables)
class UpsertOperations:
def __init__(self, context):
self._context = context
def upsert(self, data: pa.Table, upsert_keys: List[str],
update_cols: Optional[List[str]] = None) -> List[CommitMessage]:
"""
Upsert rows into an append-only table by the specified key columns.
Execution is driven partition-by-partition:
1. Group input rows by their partition values.
2. For each partition, scan only that partition to build the
key -> _ROW_ID map.
3. Split the partition's input rows into matched (update) and
unmatched (append).
4. Perform updates and appends.
This keeps memory usage proportional to a single partition's key set
and avoids scanning the entire table.
Args:
data: Input Arrow table containing rows to upsert.
Must contain all upsert_keys columns.
upsert_keys: One or more column names used together as a composite
match key.
update_cols: Columns to update for matched rows.
If None, all columns in the table schema are updated.
Returns:
List of CommitMessages to be committed.
"""
context = self._context
context._validate_inputs(data, upsert_keys, update_cols)
if update_cols is None or len(update_cols) == len(context.table.field_names):
effective_update_cols = None
else:
effective_update_cols = update_cols
columns = list(effective_update_cols) if effective_update_cols else list(context.table.field_names)
operation = context._create_native_upsert(data, upsert_keys, columns)
if operation is None:
operation = create_python_upsert_operation(context, data, upsert_keys, effective_update_cols)
return operation.upsert()
def upsert_rows(self, rows, upsert_keys: List[str], update_cols: Optional[List[str]] = None) -> List[CommitMessage]:
context = self._context
row_list = context._normalize_rows(rows)
if not row_list:
raise ValueError('rows must not be empty.')
row_items = [(row, row_to_named_values(row, context.table.table_schema.fields)) for row in row_list]
for _, values_by_name in row_items:
context._validate_row_inputs(values_by_name, upsert_keys, update_cols)
if update_cols is None or len(update_cols) == len(context.table.field_names):
effective_update_cols = None
else:
effective_update_cols = update_cols
columns = list(effective_update_cols) if effective_update_cols is not None else list(context.table.field_names)
operation = context._create_native_upsert([values for _, values in row_items], upsert_keys, columns)
if operation is None:
operation = create_python_upsert_operation(
context, row_items, upsert_keys, effective_update_cols, rows=True)
return operation.upsert()
def _create_native_upsert(self, data, upsert_keys, columns):
context = self._context
return prepare_native_write_operation('upsert', context.table, context.commit_user, data, upsert_keys, columns)
def merge_into(
target_table, source, *, on, when_matched=(), when_not_matched=(),
commit_user, commit_identifier=BATCH_COMMIT_IDENTIFIER):
"""Select MERGE execution before consuming source input or staging files."""
if target_table.options.native_write_enabled():
from pypaimon.write.backend_dispatch import prepare_native_merge_into
native = prepare_native_merge_into(
target_table, source, on, when_matched, when_not_matched,
commit_user, commit_identifier,
)
if native is not None:
return native.prepare_commit()
return create_python_merge_into_operation(
target_table, source, on=on, when_matched=when_matched,
when_not_matched=when_not_matched, commit_user=commit_user,
commit_identifier=commit_identifier,
).prepare_commit()