JingsongLi commented on code in PR #8334:
URL: https://github.com/apache/paimon/pull/8334#discussion_r3788779285
##########
paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala:
##########
@@ -541,7 +611,12 @@ case class MergeIntoPaimonDataEvolutionTable(
if (rawBlobUpdateColumns.exists(_.sameRef(attr))) {
Literal(null, attr.dataType)
} else {
- attr
+ prunedByExprId.get(attr.exprId) match {
+ case Some((paths, _)) =>
+ val st = attr.dataType.asInstanceOf[StructType]
+ buildPrunedStruct(st, Nil, paths, p => passthroughExpr(attr,
st, p))
Review Comment:
[P1] Preserve the parent struct's nullness on copy/passthrough paths.
`buildPrunedStruct` always returns a non-null `CreateNamedStruct`; when `attr`
is NULL, this converts the copied value into a non-NULL struct whose selected
children are NULL. I reproduced this by matching two source rows, conditionally
updating `nest.a` only for row 1, and leaving row 2's `nest` as NULL: row 2
reads back with `nest IS NULL = false`. Please guard this construction with the
parent-null condition (for example, an `If(IsNull(attr), typedNull,
prunedStruct)`) and add a regression where the NULL row is included in the
touched merge range. The Spark 4 copy has the same issue.
##########
paimon-spark/paimon-spark-common/src/main/scala/org/apache/paimon/spark/commands/MergeIntoPaimonDataEvolutionTable.scala:
##########
@@ -472,7 +472,64 @@ case class MergeIntoPaimonDataEvolutionTable(
AttributeReference(MERGE_DELETED_NAME, BooleanType, nullable = false)()
// Row metadata, _FIRST_ROW_ID added by addFirstRowId, and the delete
marker.
val fixedMergeOutputColumnCount = metadataColumns.size + 2
- val mergeOutput = (updateColumnsSorted ++ metadataColumns ++
rawBlobMarkerAttributes) :+
+
+ // Sub-field-level pruning: for a struct column whose SET only touches
some sub-fields, only the
+ // changed leaves are written (an incremental column-group file containing
the partial struct);
+ // the rest are copied from the target. Falls back to whole-column write
when the changed leaves
+ // cannot be safely determined, so behaviour never regresses.
+ val matchedUpdateActions = matchedActions.collect { case ua: UpdateAction
=> ua }
+ // Gated by data-evolution.nested-field.enabled (default off): when
disabled, no column is
+ // pruned, so every struct column is rewritten whole (behaviour identical
to before this
+ // feature). When enabled, struct columns whose SET only touches some
sub-fields are pruned.
+ val nestedFieldEnabled =
table.coreOptions().dataEvolutionNestedFieldEnabled()
+ val prunedByExprId: Map[ExprId, (Seq[Seq[String]], StructType)] =
+ if (!nestedFieldEnabled) Map.empty
+ else
+ updateColumnsSorted.flatMap {
+ attr =>
+ if (rawBlobUpdateColumns.exists(_.sameRef(attr))) {
+ None
+ } else {
+ attr.dataType match {
+ case st: StructType =>
+ val perAction = matchedUpdateActions.flatMap {
+ ua =>
+ ua.assignments
+ .find(
+ a => isModifiedAssignment(a) &&
assignmentKeyAttribute(a).sameRef(attr))
+ .map(a => changedLeaves(a.value, st, attr))
+ }
+ if (perAction.isEmpty || perAction.exists(_.isEmpty)) {
+ None
+ } else {
+ val union = perAction.flatten.flatten.map(_._1).distinct
Review Comment:
[P1] Canonicalize the leaf order before constructing the write schema.
`union` preserves MATCHED-action order, while `prunedStructType` and
`buildPrunedStruct` emit fields in table-schema order. `writePaths` later
reuses this action-ordered sequence, so the Spark row layout and Paimon's
`writeType` disagree positionally. I reproduced this with `nest<a,b,c>` and two
clauses updating `c` and then `a`: expected `(10,x,100)` / `(200,y,40)`, but
read back `(100,x,10)` / `(40,y,200)`. This silently corrupts persisted data.
Please canonicalize the paths once in schema order and use that exact sequence
for the output struct, `writePaths`, and `writeType`; apply the same fix to the
Spark 4 copy and add a reverse-action-order regression test.
##########
paimon-api/src/main/java/org/apache/paimon/types/RowType.java:
##########
@@ -333,6 +334,166 @@ public RowType project(String... names) {
return project(Arrays.asList(names));
}
+ /**
+ * Project this row type by a list of (possibly nested) dotted paths, e.g.
{@code ["f0",
+ * "nest.a"]}. A path without a dot selects the whole top-level field
(same as {@link
+ * #project(List)}); a dotted path selects only the addressed sub-field of
a nested {@link
+ * RowType}, preserving field ids and nullability of every level. Fields
are emitted in the
+ * order the paths are given (exactly like {@link #project(List)}), not in
schema declaration
+ * order. This is used by data evolution to reconstruct the partial nested
schema of a
+ * column-group file from its {@code writeCols}.
+ */
+ public RowType projectByPaths(List<String> paths) {
+ return projectTypeByPaths(this, paths);
+ }
+
+ private static RowType projectTypeByPaths(RowType type, List<String>
paths) {
+ // group paths by their immediate child name, keeping the order in
which the paths are
+ // given; a child appearing without a tail (or also with a tail) is
selected as a whole
+ // field
+ Map<String, List<String>> childToSubPaths = new LinkedHashMap<>();
+ Set<String> wholeChildren = new HashSet<>();
+ Map<String, DataField> fieldByName = new LinkedHashMap<>();
+ for (DataField field : type.getFields()) {
+ fieldByName.put(field.name(), field);
+ }
+ for (String path : paths) {
+ int dot = path.indexOf('.');
+ // Prefer an exact field-name match so a column whose name itself
contains a dot (and
+ // any
+ // plain top-level name) is selected whole; only split into
head.tail for genuine nested
+ // sub-field paths that do not name a field directly. This keeps
backward compatibility
+ // with the legacy exact-name project(List).
+ if (dot < 0 || fieldByName.containsKey(path)) {
Review Comment:
[P1] This exact-name preference makes the persisted dotted-path encoding
ambiguous. A legal schema can contain both a quoted top-level field named `a.b`
and a struct `a` with child `b`. `leafPaths` serializes the nested leaf as the
same string `a.b`, but this branch reconstructs it as the top-level field. I
verified that the emitted path resolves to the wrong field ID. Readers,
pruning, and conflict detection can consequently attribute a partial file to
the wrong field. Please use an unambiguous escaped/versioned or field-ID-based
encoding; at minimum, reject a nested write whenever its flattened path
collides with a top-level name, and cover the reader and conflict-checker paths
in tests.
##########
paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/DataEvolutionMergeIntoAction.java:
##########
@@ -319,13 +308,178 @@ 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() {
+ Map<String, String> changes =
parseCommaSeparatedKeyValues(matchedUpdateSet);
Review Comment:
[P2] Validate duplicate SET targets before converting the clause to a map.
`parseCommaSeparatedKeyValues` returns a map, so `T.nest.a = S.x, T.nest.a =
S.y` loses the first entry before the duplicate check below can see it, and the
last RHS silently wins. Please parse into an ordered entry list (or otherwise
retain duplicate keys), reject duplicates before grouping, and test both exact
duplicates and equivalent qualified/unqualified targets.
##########
paimon-api/src/main/java/org/apache/paimon/types/RowType.java:
##########
@@ -333,6 +334,166 @@ public RowType project(String... names) {
return project(Arrays.asList(names));
}
+ /**
+ * Project this row type by a list of (possibly nested) dotted paths, e.g.
{@code ["f0",
+ * "nest.a"]}. A path without a dot selects the whole top-level field
(same as {@link
+ * #project(List)}); a dotted path selects only the addressed sub-field of
a nested {@link
+ * RowType}, preserving field ids and nullability of every level. Fields
are emitted in the
+ * order the paths are given (exactly like {@link #project(List)}), not in
schema declaration
+ * order. This is used by data evolution to reconstruct the partial nested
schema of a
+ * column-group file from its {@code writeCols}.
+ */
+ public RowType projectByPaths(List<String> paths) {
+ return projectTypeByPaths(this, paths);
+ }
+
+ private static RowType projectTypeByPaths(RowType type, List<String>
paths) {
+ // group paths by their immediate child name, keeping the order in
which the paths are
+ // given; a child appearing without a tail (or also with a tail) is
selected as a whole
+ // field
+ Map<String, List<String>> childToSubPaths = new LinkedHashMap<>();
+ Set<String> wholeChildren = new HashSet<>();
+ Map<String, DataField> fieldByName = new LinkedHashMap<>();
+ for (DataField field : type.getFields()) {
+ fieldByName.put(field.name(), field);
+ }
+ for (String path : paths) {
+ int dot = path.indexOf('.');
+ // Prefer an exact field-name match so a column whose name itself
contains a dot (and
+ // any
+ // plain top-level name) is selected whole; only split into
head.tail for genuine nested
+ // sub-field paths that do not name a field directly. This keeps
backward compatibility
+ // with the legacy exact-name project(List).
+ if (dot < 0 || fieldByName.containsKey(path)) {
+ childToSubPaths.computeIfAbsent(path, k -> new ArrayList<>());
+ wholeChildren.add(path);
+ } else {
+ String head = path.substring(0, dot);
+ String tail = path.substring(dot + 1);
+ childToSubPaths.computeIfAbsent(head, k -> new
ArrayList<>()).add(tail);
+ }
+ }
+
+ // Emit fields in the order the paths were given, exactly like
project(List). Callers such
+ // as TableSchema.project(writeCols) rebuild the physical layout of a
data file from its
+ // writeCols, and that order is not necessarily the schema declaration
order; reordering
+ // here would silently describe the file with the columns permuted.
+ List<DataField> result = new ArrayList<>();
+ for (Map.Entry<String, List<String>> entry :
childToSubPaths.entrySet()) {
+ String name = entry.getKey();
+ DataField field = fieldByName.get(name);
+ if (field == null) {
+ throw new IllegalArgumentException(
+ "Cannot project by paths, unknown field '" + name + "'
in " + type);
+ }
+ List<String> subPaths = entry.getValue();
+ if (wholeChildren.contains(name) || subPaths.isEmpty()) {
+ result.add(field);
+ } else if (field.type() instanceof RowType) {
+ RowType prunedChild =
+ projectTypeByPaths((RowType) field.type(), subPaths)
+ .copy(field.type().isNullable());
+ result.add(field.newType(prunedChild));
+ } else {
+ // a dotted path addresses a sub-field, but this field is not
a ROW; reject rather
+ // than silently selecting the whole field, so invalid dotted
paths surface early
+ throw new IllegalArgumentException(
+ "Cannot project sub-field(s) "
+ + subPaths
+ + " of non-ROW field '"
+ + name
+ + "' in "
+ + type);
+ }
+ }
+ return new RowType(type.isNullable(), result);
+ }
+
+ /**
+ * Compute the dotted paths describing this (possibly partially nested)
write type relative to a
+ * full row type. A top-level field, or a nested field whose structure
fully covers the
+ * corresponding field in {@code fullType}, is emitted by its name; a
nested field that only
+ * covers some sub-fields is expanded into dotted leaf paths. This is the
inverse of {@link
+ * #projectByPaths(List)} and is used to derive {@code writeCols}.
+ */
+ public List<String> leafPaths(RowType fullType) {
+ List<String> result = new ArrayList<>();
+ collectLeafPaths(getFields(), fullType, "", result);
+ return result;
+ }
+
+ private static void collectLeafPaths(
+ List<DataField> writeFields, RowType fullType, String prefix,
List<String> out) {
+ for (DataField writeField : writeFields) {
+ String path = prefix.isEmpty() ? writeField.name() : prefix + "."
+ writeField.name();
+ // A field absent from the reference type (e.g. the _ROW_ID /
_SEQUENCE_NUMBER special
+ // fields added by row tracking, which are not part of the table's
logical row type) has
+ // no sub-field split: emit it whole by name, matching the legacy
getFieldNames()
+ // output.
+ if (!fullType.containsField(writeField.id())) {
+ out.add(path);
+ continue;
+ }
+ DataField fullField = fullType.getField(writeField.id());
+ boolean willExpand =
+ writeField.type() instanceof RowType
+ && fullField.type() instanceof RowType
+ && !coversFully(
+ (RowType) writeField.type(), (RowType)
fullField.type());
+ // A dotted path is only unambiguous if no name segment contains a
literal '.'. A name
+ // with a dot is fine when emitted whole at top level
(projectByPaths matches it
+ // exactly),
+ // but not when it participates in a multi-segment nested path.
+ if (writeField.name().indexOf('.') >= 0 && (!prefix.isEmpty() ||
willExpand)) {
+ throw new UnsupportedOperationException(
+ "Sub-field-level data evolution does not support a
nested field whose name "
+ + "contains '.': "
+ + path);
+ }
+ if (willExpand) {
+ // A partial struct nested inside another partial struct (a
path deeper than one
+ // level, e.g. nest.sub.x) cannot be composed back on read —
the data-evolution read
+ // path only assembles one nested level. Reject it here so
such a file is never
+ // written/committed and later breaks full-table reads.
+ if (!prefix.isEmpty()) {
+ throw new UnsupportedOperationException(
+ "Sub-field-level data evolution supports only one
level of partial "
+ + "nesting; the nested sub-field '"
+ + path
+ + "' cannot be partially written. Write
the whole '"
+ + path
+ + "' sub-field instead.");
+ }
+ collectLeafPaths(
+ ((RowType) writeField.type()).getFields(),
+ (RowType) fullField.type(),
+ path,
+ out);
+ } else {
+ out.add(path);
+ }
+ }
+ }
+
+ /** Whether {@code part} contains every (recursively nested) field of
{@code full}. */
+ private static boolean coversFully(RowType part, RowType full) {
Review Comment:
[P1] `coversFully` must also preserve recursive physical field order. With
full `nest<a INT,b STRING>` and `projectByPaths(["nest.b", "nest.a"])`, the
write type is `nest<b,a>`, but this method returns true, so `leafPaths`
collapses the metadata to `[nest]`; reconstruction then produces `nest<a,b>`. I
verified that the two types are not equal. Row sidecars are written with the
original physical write schema but read with the schema reconstructed from
`writeCols`, so this can swap fields or decode bytes using the wrong type.
Please require ordered recursive layout equality; otherwise retain the ordered
dotted leaves, and add a round-trip test with different leaf types.
##########
paimon-flink/paimon-flink-common/src/main/java/org/apache/paimon/flink/action/DataEvolutionMergeIntoAction.java:
##########
@@ -527,7 +681,23 @@ private void checkSchema(Table source) {
.getTypeRoot()
.getFamilies()
.contains(DataTypeFamily.BINARY_STRING);
+ // Struct columns need a structural compatibility check:
DataTypeCasts does not
+ // support ROW-to-ROW casts. For a sub-field write (dotted
paths like nest.a) the
+ // source is a partial (subset) struct carrying only the
updated sub-fields, so a
+ // subset check is correct. For a whole-column assignment
(e.g. T.nest=S.nest) the
+ // source must fully cover the target struct, so a narrower
source is rejected
+ // instead of being written as an incomplete whole-struct file.
+ boolean structCompatible = false;
+ if (paimonType instanceof RowType && targetField.type()
instanceof RowType) {
+ RowType sourceStruct = (RowType) paimonType;
+ RowType targetStruct = (RowType) targetField.type();
+ structCompatible =
+ isSubFieldWrite(flinkColumn.getName())
+ ? isCompatiblePartialStruct(sourceStruct,
targetStruct)
+ : isFullyCompatibleStruct(sourceStruct,
targetStruct);
Review Comment:
[P1] Whole-struct assignments are validated by field name here but are still
written positionally. For target `ROW<a INT,b INT>` and source `ROW<b INT,a
INT>`, this check accepts `SET T.nest = S.nest`; the projection keeps the
source struct unchanged, while `sourceType` is rebuilt from the target schema
order at lines 323-329. The nested row therefore reaches the writer in source
order but is interpreted as target order, silently storing `a = source.b` and
`b = source.a` (extra source fields can misalign it as well). Please
recursively rebuild/cast whole structs in target order, or reject any source
struct whose ordered shape differs, and add reversed-order and extra-field
tests.
--
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]