rambleraptor commented on code in PR #3862:
URL: https://github.com/apache/iceberg-python/pull/3862#discussion_r4429467479


##########
tests/table/test_upsert.py:
##########
@@ -446,6 +446,240 @@ def test_create_match_filter_single_condition() -> None:
     )
 
 
+def test_get_rows_to_update_compares_nulls() -> None:
+    """A null is only unchanged when the other side is null as well."""
+    schema = pa.schema([pa.field("id", pa.int32()), pa.field("value", 
pa.string())])
+    target = pa.Table.from_pylist(
+        [
+            {"id": 1, "value": None},
+            {"id": 2, "value": "b"},
+            {"id": 3, "value": None},
+            {"id": 4, "value": "d"},
+        ],
+        schema=schema,
+    )
+    source = pa.Table.from_pylist(
+        [
+            {"id": 1, "value": None},  # null to null, unchanged
+            {"id": 2, "value": None},  # value to null, updated
+            {"id": 3, "value": "c"},  # null to value, updated
+            {"id": 4, "value": "d"},  # value to value, unchanged
+        ],
+        schema=schema,
+    )
+
+    assert get_rows_to_update(source, target, ["id"]).sort_by("id") == 
pa.Table.from_pylist(
+        [
+            {"id": 2, "value": None},
+            {"id": 3, "value": "c"},
+        ],
+        schema=schema,
+    )
+
+
+def test_get_rows_to_update_when_a_column_is_missing() -> None:
+    """A source that does not carry every target column would silently null 
the ones it omits."""
+    schema = pa.schema([pa.field("id", pa.int32()), pa.field("value", 
pa.string())])
+    target = pa.Table.from_pylist([{"id": 1, "value": "a"}], schema=schema)
+    source = pa.Table.from_pylist([{"id": 1}], 
schema=pa.schema([pa.field("id", pa.int32())]))
+
+    with pytest.raises(ValueError, match="field names are not matching"):
+        get_rows_to_update(source, target, ["id"])
+
+
+def test_get_rows_to_update_compares_columns_of_a_different_type() -> None:
+    """PyArrow refuses to compare a naive timestamp with a zoned one, the 
source is cast to compare.
+
+    Comparing the two in Python would call the row changed on every run, 
though it holds the
+    same instant.
+    """
+    target = pa.table({"id": pa.array([1, 2], pa.int32()), "ts": pa.array([1, 
2], pa.timestamp("us", tz="UTC"))})
+    source = pa.table({"id": pa.array([1, 2], pa.int32()), "ts": pa.array([1, 
9], pa.timestamp("us"))})
+
+    assert get_rows_to_update(source, target, ["id"]).column("id").to_pylist() 
== [2]
+
+
+def test_get_rows_to_update_when_the_cast_is_refused() -> None:
+    """A cast PyArrow refuses leaves the comparison in Python rather than 
raising."""
+    source = pa.table(
+        {
+            "id": pa.array([1], pa.int32()),
+            "value": pa.array([[("a", 1)]], type=pa.map_(pa.string(), 
pa.int32())),
+        }
+    )
+    target = pa.table(
+        {
+            "id": pa.array([1], pa.int32()),
+            "value": pa.array([[{"a": 1}]], type=pa.list_(pa.struct([("a", 
pa.int32())]))),
+        }
+    )
+
+    assert len(get_rows_to_update(source, target, ["id"])) == 1
+
+
+def test_get_rows_to_update_compares_a_struct_that_is_null() -> None:
+    """The fields of a null struct hold no meaningful value, so the struct 
itself decides."""
+    schema = pa.schema(
+        [
+            pa.field("id", pa.int32()),
+            pa.field("nested", pa.struct([pa.field("a", pa.int32()), 
pa.field("b", pa.string())])),
+        ]
+    )
+    target = pa.Table.from_pylist(
+        [
+            {"id": 1, "nested": None},
+            {"id": 2, "nested": None},
+            {"id": 3, "nested": {"a": 1, "b": "x"}},
+            {"id": 4, "nested": {"a": 1, "b": "x"}},
+            {"id": 5, "nested": {"a": 1, "b": None}},
+        ],
+        schema=schema,
+    )
+    source = pa.Table.from_pylist(
+        [
+            {"id": 1, "nested": None},  # unchanged
+            {"id": 2, "nested": {"a": 1, "b": "x"}},  # null to a struct
+            {"id": 3, "nested": None},  # a struct to null
+            {"id": 4, "nested": {"a": 1, "b": "y"}},  # one field differs
+            {"id": 5, "nested": {"a": 1, "b": None}},  # unchanged, with a 
null field
+        ],
+        schema=schema,
+    )
+
+    assert [row["id"] for row in get_rows_to_update(source, target, 
["id"]).sort_by("id").to_pylist()] == [2, 3, 4]
+
+
+def 
test_get_rows_to_update_compares_a_struct_whose_fields_are_in_another_order() 
-> None:
+    """The fields of a struct are compared by position, so the two have to be 
in the same order.
+
+    They are: two structs of the same fields in another order are not of the 
same type, and the
+    cast that follows puts the fields of the source in the order of the target.
+    """
+    source_type = pa.struct([pa.field("a", pa.int64()), pa.field("b", 
pa.string())])
+    target_type = pa.struct([pa.field("b", pa.string()), pa.field("a", 
pa.int64())])
+    source = pa.table({"id": pa.array([1, 2]), "nested": pa.array([{"a": 1, 
"b": "x"}, {"a": 2, "b": "y"}], type=source_type)})
+    target = pa.table({"id": pa.array([1, 2]), "nested": pa.array([{"a": 1, 
"b": "x"}, {"a": 9, "b": "y"}], type=target_type)})
+
+    assert get_rows_to_update(source, target, ["id"]).column("id").to_pylist() 
== [2]
+
+
+def test_get_rows_to_update_compares_a_nested_struct() -> None:
+    """A struct holding a struct is compared by recursing into the fields of 
both."""
+    schema = pa.schema(
+        [
+            pa.field("id", pa.int32()),
+            pa.field("outer", pa.struct([pa.field("inner", 
pa.struct([pa.field("value", pa.int32())]))])),
+        ]
+    )
+    target = pa.Table.from_pylist(
+        [
+            {"id": 1, "outer": {"inner": {"value": 1}}},
+            {"id": 2, "outer": {"inner": {"value": 1}}},
+            {"id": 3, "outer": {"inner": None}},
+            {"id": 4, "outer": {"inner": {"value": 1}}},
+        ],
+        schema=schema,
+    )
+    source = pa.Table.from_pylist(
+        [
+            {"id": 1, "outer": {"inner": {"value": 1}}},  # unchanged
+            {"id": 2, "outer": {"inner": {"value": 2}}},  # the innermost 
value differs
+            {"id": 3, "outer": {"inner": {"value": 1}}},  # a null inner 
struct to a struct
+            {"id": 4, "outer": {"inner": None}},  # a struct to a null inner 
struct
+        ],
+        schema=schema,
+    )
+
+    assert [row["id"] for row in get_rows_to_update(source, target, 
["id"]).sort_by("id").to_pylist()] == [2, 3, 4]
+
+
+def test_get_rows_to_update_compares_a_list_column() -> None:
+    """PyArrow cannot compare list columns either, and unlike a struct they 
have no fields to compare."""
+    schema = pa.schema([pa.field("id", pa.int32()), pa.field("values", 
pa.list_(pa.int32()))])
+    target = pa.Table.from_pylist(
+        [
+            {"id": 1, "values": [1, 2]},
+            {"id": 2, "values": [1, 2]},
+            {"id": 3, "values": None},
+            {"id": 4, "values": [1, 2]},
+        ],
+        schema=schema,
+    )
+    source = pa.Table.from_pylist(
+        [
+            {"id": 1, "values": [1, 2]},  # unchanged
+            {"id": 2, "values": [2, 1]},  # same values, different order
+            {"id": 3, "values": [1]},  # null to a list
+            {"id": 4, "values": None},  # a list to null
+        ],
+        schema=schema,
+    )
+
+    assert [row["id"] for row in get_rows_to_update(source, target, 
["id"]).sort_by("id").to_pylist()] == [2, 3, 4]
+
+
+def test_get_rows_to_update_compares_a_column_at_a_time(monkeypatch: 
pytest.MonkeyPatch) -> None:
+    """The comparison has to walk the columns: walking the cells costs a 
PyArrow round trip each."""
+    num_columns = 12
+    comparisons = []
+
+    compare = upsert_util._get_changed_mask
+
+    def record(source: pa.ChunkedArray, target: pa.ChunkedArray) -> 
pa.ChunkedArray:
+        comparisons.append((len(source), len(target)))
+        return compare(source, target)
+
+    monkeypatch.setattr(upsert_util, "_get_changed_mask", record)

Review Comment:
   Is it possible for us to not monkeypatch here? I'd love a more direct way to 
count this, especially since this is a private API that's bound to change at 
some point.



##########
pyiceberg/table/upsert_util.py:
##########
@@ -53,17 +53,91 @@ def has_duplicate_rows(df: pyarrow_table, join_cols: 
list[str]) -> bool:
     return len(df.select(join_cols).group_by(join_cols).aggregate([([], 
"count_all")]).filter(pc.field("count_all") > 1)) > 0
 
 
+# How many values of a column are turned into Python objects at a time, when 
PyArrow cannot
+# compare them itself
+_PYTHON_COMPARISON_SLICE = 10_000
+
+
+def _get_changed_struct_mask(source_column: pa.ChunkedArray, target_column: 
pa.ChunkedArray) -> pa.ChunkedArray:
+    """Compare two struct columns field by field, which PyArrow can do even 
though it cannot compare the structs."""
+    # `struct_field` carries the null of the struct into its fields, so the 
fields of two null
+    # structs compare equal and only the struct itself decides for those rows
+    changed = pc.not_equal(pc.is_null(source_column), 
pc.is_null(target_column))
+
+    for field in source_column.type:
+        changed = pc.or_(
+            changed,
+            _get_changed_mask(pc.struct_field(source_column, field.name), 
pc.struct_field(target_column, field.name)),
+        )
+
+    return changed
+
+
+def _get_changed_mask(source_column: pa.ChunkedArray, target_column: 
pa.ChunkedArray) -> pa.ChunkedArray:
+    """Return a boolean mask that flags the positions where the two columns 
differ, treating two nulls as equal."""
+    try:
+        differs = pc.not_equal(source_column, target_column)
+    except (pa.ArrowNotImplementedError, pa.ArrowInvalid):
+        # PyArrow cannot compare columns with complex types
+        # See: https://github.com/apache/arrow/issues/35785
+        if pa.types.is_struct(source_column.type) and source_column.type == 
target_column.type:
+            return _get_changed_struct_mask(source_column, target_column)
+
+        # Two columns PyArrow refuses to compare may still hold the same 
values in another type:
+        # a naive timestamp against a zoned one, or the string of a dataframe 
against the
+        # large_string a scan reads. Comparing those in Python would call 
every row changed, on
+        # every run, and would leave a struct out of the comparison by field 
above. The types have
+        # to differ for this to make progress, the cast leaves them equal and 
the next round
+        # settles it one way or the other
+        if source_column.type != target_column.type:
+            try:
+                return 
_get_changed_mask(source_column.cast(target_column.type), target_column)
+            except pa.ArrowException:
+                # Whatever PyArrow makes of the cast, the comparison in Python 
below still holds
+                pass
+
+        # A list or a map is left to be compared in Python, value by value. A 
slice at a time,
+        # so that the objects of a whole column are never held at once
+        return pa.chunked_array(
+            [
+                [
+                    source_val != target_val
+                    for source_val, target_val in zip(
+                        source_column.slice(offset, 
_PYTHON_COMPARISON_SLICE).to_pylist(),
+                        target_column.slice(offset, 
_PYTHON_COMPARISON_SLICE).to_pylist(),
+                        strict=True,
+                    )
+                ]
+                for offset in range(0, len(source_column), 
_PYTHON_COMPARISON_SLICE)
+            ]
+            or [[]],
+            type=pa.bool_(),
+        )
+
+    # `not_equal` is null as soon as either side is null, and a null differs 
from a value
+    # but not from another null
+    return pc.fill_null(differs, pc.not_equal(pc.is_null(source_column), 
pc.is_null(target_column)))
+
+
 def get_rows_to_update(source_table: pa.Table, target_table: pa.Table, 
join_cols: list[str]) -> pa.Table:
     """
     Return a table with rows that need to be updated in the target table based 
on the join columns.
 
     The table is joined on the identifier columns, and then checked if there 
are any updated rows.
     Those are selected and everything is renamed correctly.
     """
-    all_columns = set(source_table.column_names)
-    join_cols_set = set(join_cols)
+    source_columns, target_columns = set(source_table.column_names), 
set(target_table.column_names)
+    if source_columns != target_columns:

Review Comment:
   By moving this up here, a ValueError will be thrown instead of an empty 
table being returned. I'll leave that up to the maintainers to decide if that's 
alright
   
   (My two cents: maybe try to put this below line 145?)



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]


---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]

Reply via email to