JingsongLi commented on code in PR #8334:
URL: https://github.com/apache/paimon/pull/8334#discussion_r3888775156


##########
paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala:
##########
@@ -689,6 +791,16 @@ case class MergeIntoPaimonDataEvolutionTable(
         .sortWithinPartitions(FIRST_ROW_ID_NAME, ROW_ID_NAME)
     }
 
+    // dotted write paths: a whole column -> its name; a pruned struct -> 
"col.subfield..." leaves
+    val writePaths = updateColumnsSorted.flatMap {
+      attr =>
+        prunedByExprId.get(attr.exprId) match {
+          case Some((paths, _)) => paths.map(p => (attr.name +: 
p).mkString("."))

Review Comment:
   [P1] Make both Spark conflict rewriters understand these dotted write paths. 
A nested MERGE now persists entries such as nest.a, but 
DataEvolutionRowIdConflictRewriter later treats every entry as a top-level 
relation attribute and throws Cannot find column nest.a when concurrent 
compaction changes the row-id boundaries. In the reverse commit order, 
DataEvolutionCompactMergeConflictRewriter uses exact top-level-name 
containment, finds no updated field for nest.a, and cannot rebase the staged 
compact output. Please carry path-aware write types through both rewriters; the 
row-id path must overlay only the staged leaves onto the current struct so 
untouched siblings are not clobbered. Apply the same change to the Spark 4 copy 
and add nested variants of the existing concurrent-compaction tests.



##########
paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/DataEvolutionMergeIntoAction.java:
##########
@@ -319,13 +309,206 @@ public Tuple2<DataStream<RowData>, RowType> 
buildSource() {
         Table source = batchTEnv.sqlQuery(query);
 
         checkSchema(source);
-        RowType sourceType =
-                SpecialFields.rowTypeWithRowId(table.rowType())
-                        .project(source.getResolvedSchema().getColumnNames());
+
+        RowType sourceType;
+        if (updateAll) {
+            List<String> columnNames = 
source.getResolvedSchema().getColumnNames();
+            sourceType = 
SpecialFields.rowTypeWithRowId(table.rowType()).project(columnNames);
+            writePaths =
+                    columnNames.stream()
+                            .filter(name -> 
!SpecialFields.ROW_ID.name().equals(name))
+                            .collect(Collectors.toList());
+        } else {
+            // build the source type manually so _ROW_ID is first and the 
column order matches the
+            // SQL projection order; for nested columns the field is the 
partial (pruned) struct.
+            RowType pruned = table.rowType().projectByPaths(writePaths);
+            List<DataField> srcFields = new ArrayList<>();
+            srcFields.add(SpecialFields.ROW_ID);
+            for (String topCol : explicitTopColumnOrder(writePaths)) {
+                
srcFields.add(pruned.getField(table.rowType().getField(topCol).id()));
+            }
+            sourceType = new RowType(srcFields);
+        }
 
         return Tuple2.of(toDataStream(source), sourceType);
     }
 
+    /**
+     * Validate the SET targets and build the SQL projection list. A target 
may address a top-level
+     * column ({@code col} / {@code T.col}) or, for sub-field-level data 
evolution, a nested
+     * sub-field ({@code nest.a} / {@code T.nest.a}). A partially-updated 
struct column is rebuilt
+     * as a partial {@code CAST(ROW(...) AS ROW<...>)} so only the touched 
sub-fields are written.
+     * Also sets {@link #writePaths}.
+     */
+    private List<String> buildExplicitProject() {
+        checkNoDuplicateSetTargets();
+        Map<String, String> changes = 
parseCommaSeparatedKeyValues(matchedUpdateSet);
+
+        // group by top-level column, preserving first-seen order
+        Map<String, String> wholeCols = new LinkedHashMap<>();
+        Map<String, LinkedHashMap<String, String>> nestedCols = new 
LinkedHashMap<>();
+        List<String> order = new ArrayList<>();
+
+        for (Map.Entry<String, String> entry : changes.entrySet()) {
+            List<String> path = parseTargetPath(entry.getKey());
+            String topCol = path.get(0);
+            if (!targetFieldNames.contains(topCol)) {
+                throw new RuntimeException(
+                        String.format(
+                                "Invalid column reference '%s' of table '%s' 
at matched-upsert action.",
+                                entry.getKey(), identifier.getFullName()));
+            }
+            if (!order.contains(topCol)) {
+                order.add(topCol);
+            }
+            if (path.size() == 1) {
+                // whole top-level column
+                if (nestedCols.containsKey(topCol) || 
wholeCols.containsKey(topCol)) {
+                    throw new RuntimeException(
+                            "Conflicting updates for column '" + topCol + "' 
in SET clause.");
+                }
+                wholeCols.put(topCol, entry.getValue());
+            } else {
+                // nested sub-field update
+                if (!coreOptions.dataEvolutionNestedFieldEnabled()) {
+                    throw new UnsupportedOperationException(
+                            "Updating a nested sub-field ('"
+                                    + entry.getKey()
+                                    + "') requires '"
+                                    + 
CoreOptions.DATA_EVOLUTION_NESTED_FIELD_ENABLED.key()
+                                    + "=true'.");
+                }
+                if (path.size() > 2) {
+                    throw new UnsupportedOperationException(
+                            "Sub-field-level data evolution only supports one 
level of nesting, "
+                                    + "but got '"
+                                    + entry.getKey()
+                                    + "'.");
+                }
+                if (wholeCols.containsKey(topCol)) {
+                    throw new RuntimeException(
+                            "Conflicting updates for column '" + topCol + "' 
in SET clause.");
+                }
+                String subName = path.get(1);
+                LinkedHashMap<String, String> subs =
+                        nestedCols.computeIfAbsent(topCol, k -> new 
LinkedHashMap<>());
+                if (subs.containsKey(subName)) {
+                    throw new RuntimeException(
+                            "Duplicated update for sub-field '"
+                                    + topCol
+                                    + "."
+                                    + subName
+                                    + "' in SET clause.");
+                }
+                subs.put(subName, entry.getValue());
+            }
+        }
+
+        // first pass: writePaths (so projectByPaths can build the pruned 
nested types)
+        writePaths = new ArrayList<>();
+        for (String topCol : order) {
+            if (wholeCols.containsKey(topCol)) {
+                writePaths.add(topCol);
+            } else {
+                // Emit sub-fields in schema declaration order rather than 
SET-clause order: the
+                // write paths become the physical column layout of the 
incremental file (both
+                // projectByPaths and the writeCols recorded in the manifest 
keep the given order),
+                // and that layout should not depend on how the user happened 
to order the SET
+                // clauses. The projection values below follow the pruned 
struct, so they adapt.
+                LinkedHashMap<String, String> subs = nestedCols.get(topCol);
+                RowType topColType = (RowType) 
table.rowType().getField(topCol).type();
+                for (DataField subField : topColType.getFields()) {
+                    if (subs.containsKey(subField.name())) {
+                        writePaths.add(topCol + "." + subField.name());
+                    }
+                }
+            }
+        }
+
+        // second pass: build projection expressions
+        RowType pruned = table.rowType().projectByPaths(writePaths);
+        List<String> project = new ArrayList<>();
+        for (String topCol : order) {
+            if (wholeCols.containsKey(topCol)) {
+                project.add(String.format("%s AS `%s`", wholeCols.get(topCol), 
topCol));
+            } else {
+                LinkedHashMap<String, String> subs = nestedCols.get(topCol);
+                DataType prunedColType =
+                        
pruned.getField(table.rowType().getField(topCol).id()).type();
+                // value order must match the pruned struct's schema field 
order
+                List<String> values = new ArrayList<>();
+                for (DataField subField : ((RowType) 
prunedColType).getFields()) {
+                    String value = subs.get(subField.name());
+                    Preconditions.checkState(
+                            value != null,
+                            "Missing value for sub-field '%s.%s', it's a bug.",
+                            topCol,
+                            subField.name());
+                    values.add(value);
+                }
+                String typeStr =
+                        
LogicalTypeConversion.toLogicalType(prunedColType).asSerializableString();
+                project.add(
+                        String.format(
+                                "CAST(ROW(%s) AS %s) AS `%s`",
+                                String.join(", ", values), typeStr, topCol));
+            }
+        }
+        return project;
+    }
+
+    /** The first-seen order of top-level columns present in the (dotted) 
write paths. */
+    private List<String> explicitTopColumnOrder(List<String> paths) {
+        List<String> order = new ArrayList<>();
+        for (String path : paths) {
+            int dot = path.indexOf('.');
+            String topCol = dot < 0 ? path : path.substring(0, dot);
+            if (!order.contains(topCol)) {
+                order.add(topCol);
+            }
+        }
+        return order;
+    }
+
+    /**
+     * Parse a SET target into a path relative to the target table: strip an 
optional leading
+     * table-qualifier segment (the target table name/alias), leaving {@code 
[topColumn, sub...]}.
+     */
+    private List<String> parseTargetPath(String target) {
+        List<String> segs = new 
ArrayList<>(Arrays.asList(target.split("\\.")));
+        if (segs.size() > 1 && segs.get(0).equals(targetTableName())) {

Review Comment:
   [P2] Reject ambiguous target paths before stripping the qualifier. If the 
target table or alias is payload and the schema contains both payload ROW<a 
...> and a top-level column a, the documented unqualified nested target 
payload.a is normalized to [a] here and the action successfully updates the 
top-level a instead. Please resolve both interpretations against the target 
schema and reject the input when both are valid, requiring an explicit form 
such as payload.payload.a for the nested field or bare a for the top-level 
field.



-- 
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]

Reply via email to