@@ -53,17 +53,86 @@ def has_duplicate_rows(df: pyarrow_table, join_cols: list[str]) -> bool:
5353 return len (df .select (join_cols ).group_by (join_cols ).aggregate ([([], "count_all" )]).filter (pc .field ("count_all" ) > 1 )) > 0
5454
5555
56+ # How many values of a column are turned into Python objects at a time, when PyArrow cannot
57+ # compare them itself
58+ _PYTHON_COMPARISON_SLICE = 10_000
59+
60+
61+ def _get_changed_struct_mask (source_column : pa .ChunkedArray , target_column : pa .ChunkedArray ) -> pa .ChunkedArray :
62+ """Compare two struct columns field by field, which PyArrow can do even though it cannot compare the structs."""
63+ # `struct_field` carries the null of the struct into its fields, so the fields of two null
64+ # structs compare equal and only the struct itself decides for those rows
65+ changed = pc .not_equal (pc .is_null (source_column ), pc .is_null (target_column ))
66+
67+ for index in range (source_column .type .num_fields ):
68+ changed = pc .or_ (changed , _get_changed_mask (pc .struct_field (source_column , index ), pc .struct_field (target_column , index )))
69+
70+ return changed
71+
72+
73+ def _get_changed_mask (source_column : pa .ChunkedArray , target_column : pa .ChunkedArray ) -> pa .ChunkedArray :
74+ """Return a boolean mask that flags the positions where the two columns differ, treating two nulls as equal."""
75+ try :
76+ differs = pc .not_equal (source_column , target_column )
77+ except (pa .ArrowNotImplementedError , pa .ArrowInvalid ):
78+ # PyArrow cannot compare columns with complex types
79+ # See: https://github.com/apache/arrow/issues/35785
80+ if pa .types .is_struct (source_column .type ) and source_column .type == target_column .type :
81+ return _get_changed_struct_mask (source_column , target_column )
82+
83+ # Two columns PyArrow refuses to compare may still hold the same values in another type:
84+ # a naive timestamp against a zoned one, or the string of a dataframe against the
85+ # large_string a scan reads. Comparing those in Python would call every row changed, on
86+ # every run, and would leave a struct out of the comparison by field above. The types have
87+ # to differ for this to make progress, the cast leaves them equal and the next round
88+ # settles it one way or the other
89+ if source_column .type != target_column .type :
90+ try :
91+ return _get_changed_mask (source_column .cast (target_column .type ), target_column )
92+ except pa .ArrowException :
93+ # Whatever PyArrow makes of the cast, the comparison in Python below still holds
94+ pass
95+
96+ # A list or a map is left to be compared in Python, value by value. A slice at a time,
97+ # so that the objects of a whole column are never held at once
98+ return pa .chunked_array (
99+ [
100+ [
101+ source_val != target_val
102+ for source_val , target_val in zip (
103+ source_column .slice (offset , _PYTHON_COMPARISON_SLICE ).to_pylist (),
104+ target_column .slice (offset , _PYTHON_COMPARISON_SLICE ).to_pylist (),
105+ strict = True ,
106+ )
107+ ]
108+ for offset in range (0 , len (source_column ), _PYTHON_COMPARISON_SLICE )
109+ ]
110+ or [[]],
111+ type = pa .bool_ (),
112+ )
113+
114+ # `not_equal` is null as soon as either side is null, and a null differs from a value
115+ # but not from another null
116+ return pc .fill_null (differs , pc .not_equal (pc .is_null (source_column ), pc .is_null (target_column )))
117+
118+
56119def get_rows_to_update (source_table : pa .Table , target_table : pa .Table , join_cols : list [str ]) -> pa .Table :
57120 """
58121 Return a table with rows that need to be updated in the target table based on the join columns.
59122
60123 The table is joined on the identifier columns, and then checked if there are any updated rows.
61124 Those are selected and everything is renamed correctly.
62125 """
63- all_columns = set (source_table .column_names )
64- join_cols_set = set (join_cols )
126+ if set (source_table .column_names ) != set (target_table .column_names ):
127+ raise ValueError (
128+ f"Source table's field names are not matching the target's field names: "
129+ f"{ source_table .column_names } , { target_table .column_names } "
130+ )
65131
66- non_key_cols = list (all_columns - join_cols_set )
132+ # Kept in the order of the source rather than taken from a set difference, whose order
133+ # varies from one process to the next
134+ join_cols_set = set (join_cols )
135+ non_key_cols = [col for col in source_table .column_names if col not in join_cols_set ]
67136
68137 if has_duplicate_rows (target_table , join_cols ):
69138 raise ValueError ("Target table has duplicate rows, aborting upsert" )
@@ -72,10 +141,6 @@ def get_rows_to_update(source_table: pa.Table, target_table: pa.Table, join_cols
72141 # When the target table is empty, there is nothing to update :)
73142 return source_table .schema .empty_table ()
74143
75- # We need to compare non_key_cols in Python as PyArrow
76- # 1. Cannot do a join when non-join columns have complex types
77- # 2. Cannot compare columns with complex types
78- # See: https://github.com/apache/arrow/issues/35785
79144 SOURCE_INDEX_COLUMN_NAME = "__source_index"
80145 TARGET_INDEX_COLUMN_NAME = "__target_index"
81146
@@ -86,39 +151,38 @@ def get_rows_to_update(source_table: pa.Table, target_table: pa.Table, join_cols
86151 ) from None
87152
88153 # Step 1: Prepare source index with join keys and a marker index
89- # Cast to target table schema , so we can do the join
154+ # Only the join columns are cast , so the width of the table does not weigh on the join
90155 # See: https://github.com/apache/arrow/issues/37542
91156 source_index = (
92- source_table .cast ( target_table . schema )
93- .select (join_cols_set )
157+ source_table .select ( join_cols )
158+ .cast ( target_table . select (join_cols ). schema )
94159 .append_column (SOURCE_INDEX_COLUMN_NAME , pa .array (range (len (source_table ))))
95160 )
96161
97162 # Step 2: Prepare target index with join keys and a marker
98- target_index = target_table .select (join_cols_set ).append_column (TARGET_INDEX_COLUMN_NAME , pa .array (range (len (target_table ))))
163+ target_index = target_table .select (join_cols ).append_column (TARGET_INDEX_COLUMN_NAME , pa .array (range (len (target_table ))))
99164
100165 # Step 3: Perform an inner join to find which rows from source exist in target
101- matching_indices = source_index .join (target_index , keys = list (join_cols_set ), join_type = "inner" )
102-
103- # Step 4: Compare all rows using Python
104- to_update_indices = []
105- for source_idx , target_idx in zip (
106- matching_indices [SOURCE_INDEX_COLUMN_NAME ].to_pylist (),
107- matching_indices [TARGET_INDEX_COLUMN_NAME ].to_pylist (),
108- strict = True ,
109- ):
110- source_row = source_table .slice (source_idx , 1 )
111- target_row = target_table .slice (target_idx , 1 )
112-
113- for key in non_key_cols :
114- source_val = source_row .column (key )[0 ].as_py ()
115- target_val = target_row .column (key )[0 ].as_py ()
116- if source_val != target_val :
117- to_update_indices .append (source_idx )
118- break
119-
120- # Step 5: Take rows from source table using the indices and cast to target schema
121- if to_update_indices :
122- return source_table .take (to_update_indices )
123- else :
166+ matching_indices = source_index .join (target_index , keys = join_cols , join_type = "inner" )
167+
168+ if len (matching_indices ) == 0 :
124169 return source_table .schema .empty_table ()
170+
171+ source_indices = matching_indices [SOURCE_INDEX_COLUMN_NAME ]
172+ target_indices = matching_indices [TARGET_INDEX_COLUMN_NAME ]
173+
174+ # Step 4: Compare the matched rows one column at a time. Comparing them cell by cell instead
175+ # would allocate a PyArrow scalar per cell, which does not fit in memory on a wide table.
176+ changed = pa .chunked_array ([pa .repeat (False , len (matching_indices ))])
177+ for col in non_key_cols :
178+ changed = pc .or_ (
179+ changed ,
180+ _get_changed_mask (source_table .column (col ).take (source_indices ), target_table .column (col ).take (target_indices )),
181+ )
182+ # Once every matched row has changed, the columns that are left cannot add anything, and
183+ # asking is far cheaper than taking and comparing them
184+ if pc .all (changed ).as_py ():
185+ break
186+
187+ # Step 5: Take rows from source table using the indices
188+ return source_table .take (source_indices .filter (changed ))
0 commit comments