This is an automated email from the ASF dual-hosted git repository.

JingsongLi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/paimon.git


The following commit(s) were added to refs/heads/master by this push:
     new f2de0ee193 [python][ray] Add distributed update_by_row_id for 
data-evolution tables (#8445)
f2de0ee193 is described below

commit f2de0ee193e7991575f4418db44343f35fef92bb
Author: XiaoHongbo <[email protected]>
AuthorDate: Sat Jul 4 12:45:47 2026 +0800

    [python][ray] Add distributed update_by_row_id for data-evolution tables 
(#8445)
---
 docs/docs/pypaimon/ray-data.md                     |  43 ++++
 paimon-python/pypaimon/ray/__init__.py             |   2 +
 .../pypaimon/ray/data_evolution_merge_into.py      |   2 +-
 .../pypaimon/ray/data_evolution_merge_join.py      |  10 +-
 paimon-python/pypaimon/ray/update_by_row_id.py     | 156 +++++++++++
 .../pypaimon/tests/ray_update_by_row_id_test.py    | 286 +++++++++++++++++++++
 6 files changed, 497 insertions(+), 2 deletions(-)

diff --git a/docs/docs/pypaimon/ray-data.md b/docs/docs/pypaimon/ray-data.md
index fb56c54ac6..fc1e6cc9a9 100644
--- a/docs/docs/pypaimon/ray-data.md
+++ b/docs/docs/pypaimon/ray-data.md
@@ -499,3 +499,46 @@ For an end-to-end feature update workflow on Blob tables, 
see
 - Blob columns can be updated and inserted by `merge_into`. With `update="*"`
   or `insert="*"`, the source must include the corresponding blob columns.
   If an insert mapping omits a blob column, that column is written as `NULL`.
+
+## Update By Row Id
+
+`update_by_row_id` updates columns of a **data-evolution** table straight from 
a
+source that already carries `_ROW_ID` and the new values. Each row is routed 
to the
+data file that owns its row id and only those files are rewritten — the target 
is
+**never fully read** and there is **no join against it** (unlike
+`merge_into(on=["_ROW_ID"])`, which reads and shuffle-joins the whole target). 
It
+pairs with `bucket_join`, which produces the row ids without a shuffle. 
Requires
+`ray >= 2.50` and a target with `data-evolution.enabled` and 
`row-tracking.enabled`.
+
+```python
+from pypaimon.ray import update_by_row_id
+
+metrics = update_by_row_id(
+    target="database_name.table_name",
+    source=ray_dataset,          # ray.data.Dataset / pa.Table / pandas, 
carrying _ROW_ID
+    catalog_options={"warehouse": "/path/to/warehouse"},
+    update_cols=["feature"],     # non-blob columns to overwrite
+)
+print(metrics)   # {"num_updated": 50}
+```
+
+**Parameters:**
+- `source`: a `ray.data.Dataset`, `pyarrow.Table`, or `pandas.DataFrame` 
carrying the
+  target `_ROW_ID` and every column in `update_cols`; extra columns are 
ignored, and
+  values are cast to the target column types. A table-name source is not 
accepted: a
+  table's system `_ROW_ID` is its own and cannot address the target's rows.
+- `update_cols`: the non-blob columns to overwrite. Must be non-empty.
+- `num_partitions`: parallelism for grouping the update rows by target file;
+  defaults to `max(1, cluster_cpus * 2)`.
+- `ray_remote_args`: Ray remote options applied to the update tasks.
+
+**Returns:** `{"num_updated": <rows>}`.
+
+**Notes:**
+- The row ids must exist in the target's current snapshot; a stale or foreign
+  `_ROW_ID` raises rather than silently writing.
+- Multiple source rows mapping to the same `_ROW_ID` is rejected — deduplicate 
first.
+- Blob columns cannot be updated through this path.
+- Partition columns cannot be updated (in-place rewrite can't move a row 
across partitions).
+- Deletion-vectors-enabled tables are not supported yet: a DV-deleted row 
still lives
+  in its data file, so it can't be told apart from a live row without reading 
the target.
diff --git a/paimon-python/pypaimon/ray/__init__.py 
b/paimon-python/pypaimon/ray/__init__.py
index ba88fe5c7a..f2daad3a69 100644
--- a/paimon-python/pypaimon/ray/__init__.py
+++ b/paimon-python/pypaimon/ray/__init__.py
@@ -27,12 +27,14 @@ from pypaimon.ray.data_evolution_merge_transform import (
     target_col,
     lit,
 )
+from pypaimon.ray.update_by_row_id import update_by_row_id
 
 __all__ = [
     "read_paimon",
     "write_paimon",
     "bucket_join",
     "merge_into",
+    "update_by_row_id",
     "WhenMatched",
     "WhenNotMatched",
     "source_col",
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_into.py 
b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
index 499ac12a35..ec85b784a4 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_into.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_into.py
@@ -468,7 +468,7 @@ def _require_ray_join() -> None:
 
     if parse(ray.__version__) < parse("2.50.0"):
         raise RuntimeError(
-            f"merge_into requires ray>=2.50; "
+            f"this Ray operation requires ray>=2.50; "
             f"installed ray is {ray.__version__}."
         )
 
diff --git a/paimon-python/pypaimon/ray/data_evolution_merge_join.py 
b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
index 2671daf678..03fc068ea2 100644
--- a/paimon-python/pypaimon/ray/data_evolution_merge_join.py
+++ b/paimon-python/pypaimon/ray/data_evolution_merge_join.py
@@ -441,8 +441,16 @@ def distributed_update_apply(
                 f"Column '{col}' is not in target table schema."
             )
 
+    # Pin the planner to the caller's base snapshot so row-id routing and the
+    # commit-time conflict check agree even if a concurrent commit lands 
(mirrors
+    # the delete path).
+    from pypaimon.common.options.core_options import CoreOptions
+    scan_table = (
+        table.copy({CoreOptions.SCAN_SNAPSHOT_ID.key(): str(base_snapshot_id)})
+        if base_snapshot_id is not None else table
+    )
     planner = TableUpdateByRowId(
-        table,
+        scan_table,
         "_merge_into_planner_" + uuid.uuid4().hex[:8],
         BATCH_COMMIT_IDENTIFIER,
     )
diff --git a/paimon-python/pypaimon/ray/update_by_row_id.py 
b/paimon-python/pypaimon/ray/update_by_row_id.py
new file mode 100644
index 0000000000..a538fb2f4b
--- /dev/null
+++ b/paimon-python/pypaimon/ray/update_by_row_id.py
@@ -0,0 +1,156 @@
+#  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.
+
+"""Distributed row-id update on Ray for data-evolution tables.
+
+Update columns of a data-evolution table straight from a Ray Dataset that 
already
+carries ``_ROW_ID`` and the new values -- no full-target read and no big-table
+shuffle join (unlike ``merge_into(on=["_ROW_ID"])``, which reads and joins the 
whole
+target). Pairs with ``bucket_join``, which produces the row ids.
+"""
+
+from typing import Any, Dict, List, Optional
+
+import pyarrow as pa
+
+from pypaimon.ray.data_evolution_merge_into import (
+    _normalize_source,
+    _reraise_inner,
+    _require_ray_join,
+    _resolve_num_partitions,
+)
+from pypaimon.ray.data_evolution_merge_join import distributed_update_apply
+from pypaimon.ray.data_evolution_merge_transform import build_update_schema
+
+__all__ = ["update_by_row_id"]
+
+
+def _blob_col_names(table: "FileStoreTable") -> set:
+    return {f.name for f in table.table_schema.fields
+            if getattr(f.type, "type", None) == "BLOB"}
+
+
+def update_by_row_id(
+    target: str,
+    source: Any,
+    catalog_options: Dict[str, str],
+    *,
+    update_cols: List[str],
+    num_partitions: Optional[int] = None,
+    ray_remote_args: Optional[Dict[str, Any]] = None,
+) -> Dict[str, int]:
+    """Update ``update_cols`` of a data-evolution table by ``_ROW_ID``.
+
+    ``source`` (a ``ray.data.Dataset`` / ``pyarrow.Table`` / 
``pandas.DataFrame``)
+    must already carry the target ``_ROW_ID`` and the new values. Each row is
+    routed to the data file owning its row id and only those files are 
rewritten --
+    the target is never fully read and there is no join against it. Requires
+    ``ray >= 2.50`` and a target with ``data-evolution.enabled`` + 
``row-tracking.enabled``.
+
+    Returns ``{"num_updated": <rows>}``.
+    """
+    from pypaimon.catalog.catalog_factory import CatalogFactory
+    from pypaimon.schema.data_types import PyarrowFieldParser
+    from pypaimon.table.special_fields import SpecialFields
+
+    _require_ray_join()
+    if not update_cols:
+        raise ValueError("update_cols must be non-empty.")
+    update_cols = list(dict.fromkeys(update_cols))  # de-dup, keep order
+    num_partitions = _resolve_num_partitions(num_partitions)
+
+    table = CatalogFactory.create(catalog_options).get_table(target)
+    if not table.options.data_evolution_enabled():
+        raise ValueError(
+            f"update_by_row_id requires 'data-evolution.enabled'='true' on 
'{target}'.")
+    if not table.options.row_tracking_enabled():
+        raise ValueError(
+            f"update_by_row_id requires 'row-tracking.enabled'='true' on 
'{target}'.")
+    if table.options.deletion_vectors_enabled():
+        # A DV-deleted row still lives in its data file, so row-id ranges 
can't tell it
+        # apart without reading the target; refuse rather than update a 
deleted row.
+        raise ValueError(
+            f"update_by_row_id does not support deletion-vectors-enabled 
tables yet: "
+            f"'{target}'.")
+
+    rid = SpecialFields.ROW_ID.name
+    blob_cols = _blob_col_names(table)
+    partition_keys = set(table.partition_keys or [])
+    for col in update_cols:
+        if col not in table.field_names:
+            raise ValueError(f"update column {col!r} is not in target 
'{target}'.")
+        if col in blob_cols:
+            # Update writes plain data files; blob deltas are a separate path.
+            raise ValueError(f"update_by_row_id cannot update blob column 
{col!r}.")
+        if col in partition_keys:
+            # In-place rewrite can't move a row across partitions.
+            raise ValueError(
+                f"update_by_row_id cannot update partition column {col!r}; "
+                "cross-partition row movement is not supported.")
+
+    if isinstance(source, str):
+        # A table's system _ROW_ID is its own, independent of the target's, so 
a
+        # table-name source can't address target rows. Require in-memory data 
that
+        # already carries the target row ids (e.g. produced by bucket_join).
+        raise ValueError(
+            "update_by_row_id does not accept a table-name source; pass a 
ray.data."
+            f"Dataset / pyarrow.Table / pandas.DataFrame carrying the target 
{rid}.")
+    source_ds = _normalize_source(source, catalog_options)
+    src_cols = set(source_ds.schema().names)
+    missing = [c for c in [rid] + update_cols if c not in src_cols]
+    if missing:
+        raise ValueError(
+            f"source is missing columns {missing}; it must carry {rid} and 
{update_cols}.")
+
+    # Cast to the on-disk schema (int64 _ROW_ID + target column types) so the 
writer
+    # gets exactly the target types regardless of the source's arrow types.
+    target_pa = 
PyarrowFieldParser.from_paimon_schema(table.table_schema.fields)
+    update_schema = build_update_schema(target_pa, update_cols, rid)
+
+    def _project_cast(batch: pa.Table) -> pa.Table:
+        return batch.select([rid] + update_cols).cast(update_schema)
+
+    update_ds = source_ds.map_batches(_project_cast, batch_format="pyarrow")
+
+    base = table.snapshot_manager().get_latest_snapshot()
+    # Without deletion vectors (rejected above), total_record_count is the 
live row
+    # count, so 0 means the target is empty (never written, or emptied by 
overwrite).
+    if base is None or base.total_record_count == 0:
+        # Every source row id is foreign; don't silently no-op non-empty input.
+        if update_ds.limit(1).count() > 0:
+            raise ValueError(
+                f"target '{target}' has no rows; every _ROW_ID in the source 
is foreign.")
+        return {"num_updated": 0}
+    try:
+        msgs, num_updated, _ = distributed_update_apply(
+            update_ds, table, update_cols,
+            num_partitions=num_partitions,
+            ray_remote_args=ray_remote_args,
+            base_snapshot_id=base.id,
+        )
+    except Exception as e:
+        _reraise_inner(e)
+        raise  # _reraise_inner always raises; keeps msgs/num_updated defined 
for linters
+
+    if msgs:
+        wb = table.new_batch_write_builder()
+        tc = wb.new_commit()
+        try:
+            tc.commit(msgs)
+        finally:
+            tc.close()
+    return {"num_updated": num_updated}
diff --git a/paimon-python/pypaimon/tests/ray_update_by_row_id_test.py 
b/paimon-python/pypaimon/tests/ray_update_by_row_id_test.py
new file mode 100644
index 0000000000..8b3c71d80a
--- /dev/null
+++ b/paimon-python/pypaimon/tests/ray_update_by_row_id_test.py
@@ -0,0 +1,286 @@
+#  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.
+
+import os
+import shutil
+import tempfile
+import unittest
+import uuid
+from unittest import mock
+
+import pyarrow as pa
+import pytest
+
+pypaimon = pytest.importorskip("pypaimon")
+ray = pytest.importorskip("ray")
+
+from pypaimon import CatalogFactory, Schema
+from pypaimon.ray import update_by_row_id
+
+
+class RayUpdateByRowIdTest(unittest.TestCase):
+    """Distributed row-id update: rewrite only the files owning the given row 
ids,
+    without reading or joining the whole target (unlike 
merge_into(on=_ROW_ID))."""
+
+    pa_schema = pa.schema([
+        ("id", pa.int32()),
+        ("name", pa.string()),
+        ("age", pa.int32()),
+    ])
+    de_options = {"row-tracking.enabled": "true", "data-evolution.enabled": 
"true"}
+
+    @classmethod
+    def setUpClass(cls):
+        cls.tempdir = tempfile.mkdtemp()
+        cls.catalog_options = {"warehouse": os.path.join(cls.tempdir, "wh")}
+        cls.catalog = CatalogFactory.create(cls.catalog_options)
+        cls.catalog.create_database("default", True)
+        if not ray.is_initialized():
+            ray.init(ignore_reinit_error=True, num_cpus=2)
+
+    @classmethod
+    def tearDownClass(cls):
+        try:
+            if ray.is_initialized():
+                ray.shutdown()
+        except Exception:
+            pass
+        shutil.rmtree(cls.tempdir, ignore_errors=True)
+
+    def _create(self, options=None):
+        name = f"default.u_{uuid.uuid4().hex[:8]}"
+        opts = self.de_options if options is None else options
+        self.catalog.create_table(
+            name, Schema.from_pyarrow_schema(self.pa_schema, options=opts), 
False)
+        return name
+
+    def _write(self, target, data):
+        t = self.catalog.get_table(target)
+        wb = t.new_batch_write_builder()
+        w = wb.new_write()
+        w.write_arrow(data)
+        wb.new_commit().commit(w.prepare_commit())
+        w.close()
+
+    def _read(self, target, projection=None):
+        t = self.catalog.get_table(target)
+        rb = t.new_read_builder()
+        if projection is not None:
+            rb = rb.with_projection(projection)
+        return rb.new_read().to_arrow(rb.new_scan().plan().splits())
+
+    def _rowid_by_id(self, target):
+        tab = self._read(target, ["_ROW_ID", "id"])
+        return dict(zip(tab.column("id").to_pylist(), 
tab.column("_ROW_ID").to_pylist()))
+
+    def test_update_by_row_id_basic(self):
+        target = self._create()
+        self._write(target, pa.Table.from_pydict(
+            {"id": list(range(1, 7)), "name": [f"n{i}" for i in range(1, 7)],
+             "age": [i * 10 for i in range(1, 7)]}, schema=self.pa_schema))
+        rid = self._rowid_by_id(target)
+
+        # update age for ids 2 and 5 only, addressed by their _ROW_ID
+        src = pa.table({"_ROW_ID": [rid[2], rid[5]], "age": [999, 888]},
+                       schema=pa.schema([("_ROW_ID", pa.int64()), ("age", 
pa.int32())]))
+
+        # Proof of no full-target read: read_paimon is never called (source is 
a
+        # Dataset, and the update routes by manifest metadata, not a scan).
+        import pypaimon.ray.ray_paimon as rp
+        with mock.patch.object(rp, "read_paimon",
+                               side_effect=AssertionError("target was read!")):
+            stats = update_by_row_id(target, ray.data.from_arrow(src),
+                                     self.catalog_options, update_cols=["age"])
+        self.assertEqual(stats, {"num_updated": 2})
+
+        back = self._read(target).sort_by("id").to_pydict()
+        self.assertEqual(back["age"], [10, 999, 30, 40, 888, 60])
+        self.assertEqual(back["name"], [f"n{i}" for i in range(1, 7)])  # 
untouched
+
+    def test_updates_correct_row_across_files(self):
+        # A _ROW_ID owned by a middle data file must update only that row.
+        target = self._create()
+        for chunk in ([10, 11, 12], [20, 21], [30, 31, 32, 33]):
+            self._write(target, pa.Table.from_pydict(
+                {"id": chunk, "name": ["x"] * len(chunk), "age": [0] * 
len(chunk)},
+                schema=self.pa_schema))
+        rid = self._rowid_by_id(target)
+        src = pa.table({"_ROW_ID": [rid[21]], "age": [999]},
+                       schema=pa.schema([("_ROW_ID", pa.int64()), ("age", 
pa.int32())]))
+        stats = update_by_row_id(target, ray.data.from_arrow(src),
+                                 self.catalog_options, update_cols=["age"])
+        self.assertEqual(stats, {"num_updated": 1})
+        back = self._read(target).sort_by("id").to_pydict()
+        got = dict(zip(back["id"], back["age"]))
+        self.assertEqual(got[21], 999)
+        self.assertTrue(all(v == 0 for k, v in got.items() if k != 21))
+
+    def test_pins_base_snapshot_for_conflict_detection(self):
+        # The update pins its base snapshot and threads it to 
distributed_update_apply,
+        # which uses it for commit-time conflict detection against concurrent 
writers.
+        import importlib
+        m = importlib.import_module("pypaimon.ray.update_by_row_id")  # 
module, not the fn
+        target = self._create()
+        self._write(target, pa.Table.from_pydict(
+            {"id": [1, 2], "name": ["a", "b"], "age": [1, 2]}, 
schema=self.pa_schema))
+        expected_sid = self.catalog.get_table(
+            target).snapshot_manager().get_latest_snapshot().id
+        rid = self._rowid_by_id(target)
+        src = pa.table({"_ROW_ID": [rid[1]], "age": [9]},
+                       schema=pa.schema([("_ROW_ID", pa.int64()), ("age", 
pa.int32())]))
+
+        captured = {}
+
+        def fake_apply(update_ds, table, cols, *, num_partitions,
+                       ray_remote_args=None, base_snapshot_id=None):
+            captured["base_snapshot_id"] = base_snapshot_id
+            return [], 0, []
+
+        with mock.patch.object(m, "distributed_update_apply", fake_apply):
+            update_by_row_id(target, src, self.catalog_options, 
update_cols=["age"])
+        self.assertEqual(captured["base_snapshot_id"], expected_sid)
+
+    def test_accepts_pyarrow_and_pandas_source(self):
+        target = self._create()
+        self._write(target, pa.Table.from_pydict(
+            {"id": [1, 2], "name": ["a", "b"], "age": [1, 2]}, 
schema=self.pa_schema))
+        rid = self._rowid_by_id(target)
+        # pyarrow.Table source
+        update_by_row_id(
+            target,
+            pa.table({"_ROW_ID": [rid[1]], "age": [77]},
+                     schema=pa.schema([("_ROW_ID", pa.int64()), ("age", 
pa.int32())])),
+            self.catalog_options, update_cols=["age"])
+        self.assertEqual(self._read(target).sort_by("id").to_pydict()["age"], 
[77, 2])
+
+        # pandas.DataFrame source, updating multiple columns at once
+        import pandas as pd
+        update_by_row_id(
+            target,
+            pd.DataFrame({"_ROW_ID": pd.array([rid[2]], dtype="int64"),
+                          "name": ["z"], "age": pd.array([88], 
dtype="int32")}),
+            self.catalog_options, update_cols=["name", "age"])
+        back = self._read(target).sort_by("id").to_pydict()
+        self.assertEqual(back["age"], [77, 88])
+        self.assertEqual(back["name"], ["a", "z"])
+
+    def test_rejects_table_name_source(self):
+        # A source table's system _ROW_ID is its own, not the target's row 
ids, so a
+        # table-name source is rejected rather than silently updating wrong 
rows.
+        target = self._create()
+        self._write(target, pa.Table.from_pydict(
+            {"id": [1], "name": ["a"], "age": [1]}, schema=self.pa_schema))
+        with self.assertRaises(ValueError):
+            update_by_row_id(target, "default.some_source", 
self.catalog_options,
+                             update_cols=["age"])
+
+    def test_rejects_non_data_evolution_table(self):
+        target = self._create(options={})  # plain append table
+        self._write(target, pa.Table.from_pydict(
+            {"id": [1], "name": ["a"], "age": [1]}, schema=self.pa_schema))
+        src = pa.table({"_ROW_ID": [0], "age": [9]},
+                       schema=pa.schema([("_ROW_ID", pa.int64()), ("age", 
pa.int32())]))
+        with self.assertRaises(ValueError):
+            update_by_row_id(target, src, self.catalog_options, 
update_cols=["age"])
+
+    def test_rejects_missing_row_id_column(self):
+        target = self._create()
+        self._write(target, pa.Table.from_pydict(
+            {"id": [1], "name": ["a"], "age": [1]}, schema=self.pa_schema))
+        src = pa.table({"age": [9]}, schema=pa.schema([("age", pa.int32())]))
+        with self.assertRaises(ValueError):
+            update_by_row_id(target, src, self.catalog_options, 
update_cols=["age"])
+
+    def test_rejects_partition_column_update(self):
+        name = f"default.u_{uuid.uuid4().hex[:8]}"
+        s = Schema.from_pyarrow_schema(self.pa_schema, partition_keys=["name"],
+                                       options=self.de_options)
+        self.catalog.create_table(name, s, False)
+        self._write(name, pa.Table.from_pydict(
+            {"id": [1], "name": ["a"], "age": [1]}, schema=self.pa_schema))
+        src = pa.table({"_ROW_ID": [0], "name": ["b"]},
+                       schema=pa.schema([("_ROW_ID", pa.int64()), ("name", 
pa.string())]))
+        with self.assertRaises(ValueError):
+            update_by_row_id(name, src, self.catalog_options, 
update_cols=["name"])
+
+    def test_rejects_deletion_vectors_table(self):
+        # A DV-deleted row still lives in its file, so update_by_row_id can't 
tell it is
+        # gone without reading the target; DV tables are refused for now.
+        opts = dict(self.de_options, **{"deletion-vectors.enabled": "true"})
+        target = self._create(options=opts)
+        self._write(target, pa.Table.from_pydict(
+            {"id": [1], "name": ["a"], "age": [1]}, schema=self.pa_schema))
+        src = pa.table({"_ROW_ID": [0], "age": [9]},
+                       schema=pa.schema([("_ROW_ID", pa.int64()), ("age", 
pa.int32())]))
+        with self.assertRaises(ValueError):
+            update_by_row_id(target, src, self.catalog_options, 
update_cols=["age"])
+
+    def test_rejects_blob_column_update(self):
+        blob_schema = pa.schema([("id", pa.int32()), ("payload", 
pa.large_binary())])
+        name = f"default.u_{uuid.uuid4().hex[:8]}"
+        self.catalog.create_table(
+            name, Schema.from_pyarrow_schema(blob_schema, 
options=self.de_options), False)
+        self._write(name, pa.Table.from_pydict(
+            {"id": [1], "payload": pa.array([b"x"], pa.large_binary())}, 
schema=blob_schema))
+        src = pa.table({"_ROW_ID": [0], "payload": pa.array([b"y"], 
pa.large_binary())},
+                       schema=pa.schema([("_ROW_ID", pa.int64()), ("payload", 
pa.large_binary())]))
+        with self.assertRaises(ValueError):
+            update_by_row_id(name, src, self.catalog_options, 
update_cols=["payload"])
+
+    def test_empty_target_foreign_row_id_raises(self):
+        src = pa.table({"_ROW_ID": [0], "age": [9]},
+                       schema=pa.schema([("_ROW_ID", pa.int64()), ("age", 
pa.int32())]))
+        empty_src = pa.table({"_ROW_ID": pa.array([], pa.int64()),
+                              "age": pa.array([], pa.int32())})
+
+        # (a) never written -> no snapshot
+        target = self._create()
+        with self.assertRaises(ValueError):
+            update_by_row_id(target, src, self.catalog_options, 
update_cols=["age"])
+        # empty source against an empty target is a no-op, not an error
+        self.assertEqual(
+            update_by_row_id(target, empty_src, self.catalog_options, 
update_cols=["age"]),
+            {"num_updated": 0})
+
+        # (b) written then emptied by overwrite -> snapshot exists but 0 live 
rows
+        target2 = self._create()
+        self._write(target2, pa.Table.from_pydict(
+            {"id": [1], "name": ["a"], "age": [1]}, schema=self.pa_schema))
+        wb = 
self.catalog.get_table(target2).new_batch_write_builder().overwrite()
+        w = wb.new_write()
+        w.write_arrow(pa.Table.from_pydict(
+            {"id": pa.array([], pa.int32()), "name": pa.array([], pa.string()),
+             "age": pa.array([], pa.int32())}, schema=self.pa_schema))
+        wb.new_commit().commit(w.prepare_commit())
+        w.close()
+        with self.assertRaises(ValueError):
+            update_by_row_id(target2, src, self.catalog_options, 
update_cols=["age"])
+
+    def test_rejects_unknown_and_empty_update_cols(self):
+        target = self._create()
+        self._write(target, pa.Table.from_pydict(
+            {"id": [1], "name": ["a"], "age": [1]}, schema=self.pa_schema))
+        src = pa.table({"_ROW_ID": [0], "age": [9]},
+                       schema=pa.schema([("_ROW_ID", pa.int64()), ("age", 
pa.int32())]))
+        with self.assertRaises(ValueError):
+            update_by_row_id(target, src, self.catalog_options, 
update_cols=["nope"])
+        with self.assertRaises(ValueError):
+            update_by_row_id(target, src, self.catalog_options, update_cols=[])
+
+
+if __name__ == "__main__":
+    unittest.main()

Reply via email to