fileFieldIdsForEntry(ManifestEntry entry) {
return fileFieldIdsCache.computeIfAbsent(
Pair.of(entry.file().schemaId(), entry.file().writeCols()),
- pair -> fileFieldIds(this::scanTableSchema, entry.file()));
+ pair -> {
+ TableSchema fileSchema = scanTableSchema(entry.file().schemaId());
+ boolean nestedFieldEnabled =
+ new CoreOptions(fileSchema.options()).dataEvolutionNestedFieldEnabled();
+ return fileFieldIds(fileSchema.fields(), entry.file(), nestedFieldEnabled);
+ });
}
@VisibleForTesting
diff --git a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionReadPlanner.java b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionReadPlanner.java
index 70e50fc38169..50746b831577 100644
--- a/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionReadPlanner.java
+++ b/paimon-core/src/main/java/org/apache/paimon/operation/DataEvolutionReadPlanner.java
@@ -33,33 +33,86 @@
import java.util.List;
import java.util.Map;
import java.util.Set;
+import java.util.stream.Collectors;
import static org.apache.paimon.utils.Preconditions.checkArgument;
/**
- * Pure (no-IO) planner for sub-field-level data evolution reads. Given the requested read row type
- * and, for each column-group file ("bunch"), the row type it physically provides (its written
- * columns, already wrapped with row-tracking fields), it decides for every read field whether it is
- * taken whole from a single file or composed sub-field by sub-field across several files (latest
- * file wins per leaf), and produces the offset maps and the per-field {@link
- * DataEvolutionRow.NestedField} assembly plans.
+ * Pure (no-IO) planner for data evolution reads. Given the requested read row type and, for each
+ * column-group file ("bunch"), the row type it physically provides (its written columns, already
+ * wrapped with row-tracking fields), it produces the source offsets and per-bunch physical read
+ * fields.
*
- * Separating this from {@link DataEvolutionSplitRead} keeps the reader-building (IO) thin and
- * lets the layout logic be unit-tested directly. Only one level of nested composition is supported;
- * deeper or cross-file splits of a sub-struct throw {@link UnsupportedOperationException}.
+ *
With nested-field evolution disabled, fields are matched only by top-level id. With it
+ * enabled, the planner selects the latest provider per leaf and composes nested fields split across
+ * bunches. Only one level of nested composition is supported; deeper or cross-file splits of a
+ * sub-struct throw {@link UnsupportedOperationException}.
+ *
+ *
Separating this from {@link DataEvolutionSplitRead} keeps schema resolution and reader
+ * creation out of the layout logic and lets both planning modes be unit-tested directly.
*/
class DataEvolutionReadPlanner {
private final RowType readRowType;
// for each bunch, the (row-tracked) row type it physically provides
private final List bunchAvailTypes;
+ private final boolean nestedFieldEnabled;
- DataEvolutionReadPlanner(RowType readRowType, List bunchAvailTypes) {
+ DataEvolutionReadPlanner(
+ RowType readRowType, List bunchAvailTypes, boolean nestedFieldEnabled) {
this.readRowType = readRowType;
this.bunchAvailTypes = bunchAvailTypes;
+ this.nestedFieldEnabled = nestedFieldEnabled;
}
DataEvolutionReadPlan plan() {
+ DataEvolutionReadPlan plan = nestedFieldEnabled ? planNested() : planTopLevel();
+ List readFields = readRowType.getFields();
+ for (int i = 0; i < readFields.size(); i++) {
+ if (plan.rowOffsets[i] == -1 && plan.nested[i] == null) {
+ checkArgument(
+ readFields.get(i).type().isNullable(),
+ "Field %s is not null but can't find any file contains it.",
+ readFields.get(i));
+ }
+ }
+ return plan;
+ }
+
+ private DataEvolutionReadPlan planTopLevel() {
+ List allReadFields = readRowType.getFields();
+ int numFields = allReadFields.size();
+ int[] readFieldIds = allReadFields.stream().mapToInt(DataField::id).toArray();
+ int[] rowOffsets = new int[numFields];
+ int[] fieldOffsets = new int[numFields];
+ Arrays.fill(rowOffsets, -1);
+ Arrays.fill(fieldOffsets, -1);
+
+ List> bunchReadFields = new ArrayList<>();
+ for (int i = 0; i < bunchAvailTypes.size(); i++) {
+ Set availableFieldIds =
+ bunchAvailTypes.get(i).getFields().stream()
+ .map(DataField::id)
+ .collect(Collectors.toSet());
+ List readFields = new ArrayList<>();
+ for (int j = 0; j < readFieldIds.length; j++) {
+ if (rowOffsets[j] == -1 && availableFieldIds.contains(readFieldIds[j])) {
+ rowOffsets[j] = i;
+ fieldOffsets[j] = readFields.size();
+ readFields.add(allReadFields.get(j));
+ }
+ }
+ bunchReadFields.add(readFields);
+ }
+
+ return new DataEvolutionReadPlan(
+ rowOffsets,
+ fieldOffsets,
+ new DataEvolutionRow.NestedField[numFields],
+ bunchReadFields);
+ }
+
+ private DataEvolutionReadPlan planNested() {
List allReadFields = readRowType.getFields();
int numFields = allReadFields.size();
int numBunches = bunchAvailTypes.size();
@@ -89,12 +142,16 @@ DataEvolutionReadPlan plan() {
boolean[] composite = new boolean[numFields];
int[] wholeBunch = new int[numFields];
Arrays.fill(wholeBunch, -1);
+ List> nullnessAnchors = new ArrayList<>();
+ for (int i = 0; i < numFields; i++) {
+ nullnessAnchors.add(new LinkedHashSet<>());
+ }
for (int j = 0; j < numFields; j++) {
DataField rf = allReadFields.get(j);
List leaves = leafIdsOf(rf);
Map leafProvider = new HashMap<>();
- Set providers = new HashSet<>();
+ Set providers = new LinkedHashSet<>();
for (int leaf : leaves) {
int p = providerOf(leaf, bunchLeaves);
if (p >= 0) {
@@ -102,33 +159,52 @@ DataEvolutionReadPlan plan() {
providers.add(p);
}
}
- if (providers.isEmpty()) {
- // no file provides this field; it stays null (nullability checked below)
+
+ if (!(rf.type() instanceof RowType)) {
+ if (!providers.isEmpty()) {
+ int b = providers.iterator().next();
+ bunchSelection.get(b).put(rf.id(), null);
+ wholeBunch[j] = b;
+ }
+ continue;
+ }
+
+ // A ROW's nullness is determined by all of its latest sibling providers, including
+ // siblings omitted by the projection. Otherwise projecting only nest.a could return a
+ // null nest from a later nest.a file even though nest.b in another winning file keeps
+ // the merged nest non-null.
+ Set parentProviders =
+ topFieldProvidersOf(rf.id(), bunchAvailTypes, bunchLeaves);
+ if (parentProviders.isEmpty()) {
+ // The whole top-level ROW is absent. Leave it unplanned so the caller can either
+ // null-fill a nullable field or reject a missing non-null field.
continue;
}
- // Only read a field whole from a single file when that file covers ALL of its leaves.
- // If a single file provides only some leaves of a struct, go through the composite
- // plan so the selection is made per direct sub-field: the sub-fields it does provide
- // are read from it, and the ones absent everywhere stay null.
boolean allLeavesCovered = leafProvider.size() == leaves.size();
- if (providers.size() == 1 && allLeavesCovered) {
+ if (providers.size() == 1 && allLeavesCovered && parentProviders.equals(providers)) {
int b = providers.iterator().next();
bunchSelection.get(b).put(rf.id(), null);
wholeBunch[j] = b;
} else {
- checkArgument(
- rf.type() instanceof RowType,
- "Field %s is split across files but is not a struct.",
- rf.name());
composite[j] = true;
+ nullnessAnchors.get(j).addAll(parentProviders);
for (DataField sub : ((RowType) rf.type()).getFields()) {
- Set subProviders = new HashSet<>();
+ Set subProviders = new LinkedHashSet<>();
for (int leaf : leafIdsOf(sub)) {
int p = leafProvider.getOrDefault(leaf, -1);
if (p >= 0) {
subProviders.add(p);
}
}
+ if (subProviders.isEmpty()) {
+ // Every requested leaf may have been added after the files were written.
+ // Find the latest provider of older siblings under this direct sub-field;
+ // reading that sub-field preserves its ROW nullness while schema evolution
+ // null-fills the requested leaves.
+ subProviders =
+ subFieldProvidersOf(
+ rf.id(), sub.id(), bunchAvailTypes, bunchLeaves);
+ }
if (subProviders.size() > 1) {
throw new UnsupportedOperationException(
"Sub-field-level data evolution does not yet support splitting a "
@@ -152,6 +228,14 @@ DataEvolutionReadPlan plan() {
}
// else: sub-field absent everywhere -> stays null
}
+ for (int parentProvider : parentProviders) {
+ Map> selection = bunchSelection.get(parentProvider);
+ if (!selection.containsKey(rf.id())) {
+ // This provider only contributes an unprojected sibling. Read the projected
+ // shape as a hidden anchor so it still participates in parent nullness.
+ selection.put(rf.id(), null);
+ }
+ }
}
}
@@ -204,6 +288,16 @@ DataEvolutionReadPlan plan() {
Arrays.fill(subFieldOffsets, -1);
Map bunchToPartial = new LinkedHashMap<>();
List partials = new ArrayList<>();
+ for (int b : nullnessAnchors.get(j)) {
+ Map subOffsets = bunchSubOffset.get(b).get(rf.id());
+ bunchToPartial.put(b, partials.size());
+ partials.add(
+ new int[] {
+ b,
+ bunchTopOffset.get(b).get(rf.id()),
+ subOffsets == null ? subFields.size() : subOffsets.size()
+ });
+ }
for (int s = 0; s < subCount; s++) {
int subId = subFields.get(s).id();
int b = findSubProvider(rf.id(), subId, bunchSubOffset);
@@ -275,6 +369,53 @@ private static int providerOf(int leafId, List> bunchLeaves) {
return -1;
}
+ private static Set topFieldProvidersOf(
+ int fieldId, List bunchTypes, List> bunchLeaves) {
+ Set siblingLeaves = new LinkedHashSet<>();
+ for (RowType bunchType : bunchTypes) {
+ if (bunchType.containsField(fieldId)) {
+ collectLeafIds(
+ Collections.singletonList(bunchType.getField(fieldId)), siblingLeaves);
+ }
+ }
+ Set providers = new LinkedHashSet<>();
+ for (int leaf : siblingLeaves) {
+ int provider = providerOf(leaf, bunchLeaves);
+ if (provider >= 0) {
+ providers.add(provider);
+ }
+ }
+ return providers;
+ }
+
+ private static Set subFieldProvidersOf(
+ int topFieldId,
+ int subFieldId,
+ List bunchTypes,
+ List> bunchLeaves) {
+ Set siblingLeaves = new LinkedHashSet<>();
+ for (RowType bunchType : bunchTypes) {
+ if (!bunchType.containsField(topFieldId)) {
+ continue;
+ }
+ DataField topField = bunchType.getField(topFieldId);
+ if (topField.type() instanceof RowType
+ && ((RowType) topField.type()).containsField(subFieldId)) {
+ collectLeafIds(
+ Collections.singletonList(((RowType) topField.type()).getField(subFieldId)),
+ siblingLeaves);
+ }
+ }
+ Set providers = new LinkedHashSet<>();
+ for (int leaf : siblingLeaves) {
+ int provider = providerOf(leaf, bunchLeaves);
+ if (provider >= 0) {
+ providers.add(provider);
+ }
+ }
+ return providers;
+ }
+
private static int findSubProvider(
int topId, int subId, List