@@ -53,17 +53,91 @@ 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 field in source_column .type :
68+ changed = pc .or_ (
69+ changed ,
70+ _get_changed_mask (pc .struct_field (source_column , field .name ), pc .struct_field (target_column , field .name )),
71+ )
72+
73+ return changed
74+
75+
76+ def _get_changed_mask (source_column : pa .ChunkedArray , target_column : pa .ChunkedArray ) -> pa .ChunkedArray :
77+ """Return a boolean mask that flags the positions where the two columns differ, treating two nulls as equal."""
78+ try :
79+ differs = pc .not_equal (source_column , target_column )
80+ except (pa .ArrowNotImplementedError , pa .ArrowInvalid ):
81+ # PyArrow cannot compare columns with complex types
82+ # See: https://github.com/apache/arrow/issues/35785
83+ if pa .types .is_struct (source_column .type ) and source_column .type == target_column .type :
84+ return _get_changed_struct_mask (source_column , target_column )
85+
86+ # Two columns PyArrow refuses to compare may still hold the same values in another type:
87+ # a naive timestamp against a zoned one, or the string of a dataframe against the
88+ # large_string a scan reads. Comparing those in Python would call every row changed, on
89+ # every run, and would leave a struct out of the comparison by field above. The types have
90+ # to differ for this to make progress, the cast leaves them equal and the next round
91+ # settles it one way or the other
92+ if source_column .type != target_column .type :
93+ try :
94+ return _get_changed_mask (source_column .cast (target_column .type ), target_column )
95+ except pa .ArrowException :
96+ # Whatever PyArrow makes of the cast, the comparison in Python below still holds
97+ pass
98+
99+ # A list or a map is left to be compared in Python, value by value. A slice at a time,
100+ # so that the objects of a whole column are never held at once
101+ return pa .chunked_array (
102+ [
103+ [
104+ source_val != target_val
105+ for source_val , target_val in zip (
106+ source_column .slice (offset , _PYTHON_COMPARISON_SLICE ).to_pylist (),
107+ target_column .slice (offset , _PYTHON_COMPARISON_SLICE ).to_pylist (),
108+ strict = True ,
109+ )
110+ ]
111+ for offset in range (0 , len (source_column ), _PYTHON_COMPARISON_SLICE )
112+ ]
113+ or [[]],
114+ type = pa .bool_ (),
115+ )
116+
117+ # `not_equal` is null as soon as either side is null, and a null differs from a value
118+ # but not from another null
119+ return pc .fill_null (differs , pc .not_equal (pc .is_null (source_column ), pc .is_null (target_column )))
120+
121+
56122def get_rows_to_update (source_table : pa .Table , target_table : pa .Table , join_cols : list [str ]) -> pa .Table :
57123 """
58124 Return a table with rows that need to be updated in the target table based on the join columns.
59125
60126 The table is joined on the identifier columns, and then checked if there are any updated rows.
61127 Those are selected and everything is renamed correctly.
62128 """
63- all_columns = set (source_table .column_names )
64- join_cols_set = set (join_cols )
129+ source_columns , target_columns = set (source_table .column_names ), set (target_table .column_names )
130+ if source_columns != target_columns :
131+ raise ValueError (
132+ f"Source table's field names are not matching the target's field names, "
133+ f"missing: { sorted (target_columns - source_columns )} , "
134+ f"unexpected: { sorted (source_columns - target_columns )} "
135+ )
65136
66- non_key_cols = list (all_columns - join_cols_set )
137+ # Kept in the order of the source rather than taken from a set difference, whose order
138+ # varies from one process to the next
139+ join_cols_set = set (join_cols )
140+ non_key_cols = [col for col in source_table .column_names if col not in join_cols_set ]
67141
68142 if has_duplicate_rows (target_table , join_cols ):
69143 raise ValueError ("Target table has duplicate rows, aborting upsert" )
@@ -72,10 +146,6 @@ def get_rows_to_update(source_table: pa.Table, target_table: pa.Table, join_cols
72146 # When the target table is empty, there is nothing to update :)
73147 return source_table .schema .empty_table ()
74148
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
79149 SOURCE_INDEX_COLUMN_NAME = "__source_index"
80150 TARGET_INDEX_COLUMN_NAME = "__target_index"
81151
@@ -86,39 +156,38 @@ def get_rows_to_update(source_table: pa.Table, target_table: pa.Table, join_cols
86156 ) from None
87157
88158 # Step 1: Prepare source index with join keys and a marker index
89- # Cast to target table schema , so we can do the join
159+ # Only the join columns are cast , so the width of the table does not weigh on the join
90160 # See: https://github.com/apache/arrow/issues/37542
91161 source_index = (
92- source_table .cast ( target_table . schema )
93- .select (join_cols_set )
162+ source_table .select ( join_cols )
163+ .cast ( target_table . select (join_cols ). schema )
94164 .append_column (SOURCE_INDEX_COLUMN_NAME , pa .array (range (len (source_table ))))
95165 )
96166
97167 # 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 ))))
168+ target_index = target_table .select (join_cols ).append_column (TARGET_INDEX_COLUMN_NAME , pa .array (range (len (target_table ))))
99169
100170 # 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 :
171+ matching_indices = source_index .join (target_index , keys = join_cols , join_type = "inner" )
172+
173+ if len (matching_indices ) == 0 :
124174 return source_table .schema .empty_table ()
175+
176+ source_indices = matching_indices [SOURCE_INDEX_COLUMN_NAME ]
177+ target_indices = matching_indices [TARGET_INDEX_COLUMN_NAME ]
178+
179+ # Step 4: Compare the matched rows one column at a time. Comparing them cell by cell instead
180+ # would allocate a PyArrow scalar per cell, which does not fit in memory on a wide table.
181+ changed = pa .chunked_array ([pa .repeat (False , len (matching_indices ))])
182+ for col in non_key_cols :
183+ changed = pc .or_ (
184+ changed ,
185+ _get_changed_mask (source_table .column (col ).take (source_indices ), target_table .column (col ).take (target_indices )),
186+ )
187+ # Once every matched row has changed, the columns that are left cannot add anything, and
188+ # asking is far cheaper than taking and comparing them
189+ if pc .all (changed ).as_py ():
190+ break
191+
192+ # Step 5: Take rows from source table using the indices
193+ return source_table .take (source_indices .filter (changed ))
0 commit comments