This is an automated email from the ASF dual-hosted git repository.

rdblue pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/iceberg.git


The following commit(s) were added to refs/heads/main by this push:
     new 7461b50476 Core: Add V4 file planner (#17541)
7461b50476 is described below

commit 7461b5047638fe07285a175a3e113e73fc5e19de
Author: Anoop Johnson <[email protected]>
AuthorDate: Thu Oct 22 08:36:03 2026 -0700

    Core: Add V4 file planner (#17541)
    
    Plan FileScanTasks from a V4 root manifest, routing DATA entries to tasks 
and
    expanding DATA_MANIFEST entries into their leaf manifests through
    V4ManifestReader. Attach each data file's colocated deletion vector as a
    DeleteFile. Add RootManifestFile so the root reads through the same reader 
as
    the leaves, and TrackedFileAdapters.asDVDeleteFile(DataFile).
---
 .../main/java/org/apache/iceberg/FilePlanner.java  | 207 +++++++
 .../java/org/apache/iceberg/RootManifestFile.java  | 148 +++++
 .../org/apache/iceberg/TrackedFileAdapters.java    |   4 +
 .../java/org/apache/iceberg/TestFilePlanner.java   | 660 +++++++++++++++++++++
 .../org/apache/iceberg/TestRootManifestFile.java   | 110 ++++
 .../java/org/apache/iceberg/V4TestHelpers.java     |  21 +-
 6 files changed, 1149 insertions(+), 1 deletion(-)

diff --git a/core/src/main/java/org/apache/iceberg/FilePlanner.java 
b/core/src/main/java/org/apache/iceberg/FilePlanner.java
new file mode 100644
index 0000000000..78c2206b47
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/FilePlanner.java
@@ -0,0 +1,207 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg;
+
+import java.io.IOException;
+import java.io.UncheckedIOException;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ExecutorService;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.expressions.ResidualEvaluator;
+import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.metrics.ScanMetrics;
+import org.apache.iceberg.metrics.ScanMetricsUtil;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.relocated.com.google.common.collect.Maps;
+import org.apache.iceberg.util.ParallelIterable;
+import org.apache.iceberg.util.ThreadPools;
+
+/**
+ * Plans {@link FileScanTask}s from a V4 root manifest.
+ *
+ * <p>Emits a task for each live {@code DATA} entry and expands {@code 
DATA_MANIFEST} entries into
+ * their leaf manifests. A data file's colocated deletion vector is attached 
to its task as a {@link
+ * DeleteFile}.
+ */
+class FilePlanner {
+  private static final DeleteFile[] NO_DELETES = new DeleteFile[0];
+
+  private final FileIO io;
+  private final ManifestFile root;
+  private final Schema tableSchema;
+  private final Map<Integer, PartitionSpec> specsById;
+  private final Map<Integer, TaskContext> taskContextsBySpec = 
Maps.newHashMap();
+
+  private String tableLocation = null;
+  private Expression dataFilter = Expressions.alwaysTrue();
+  private boolean ignoreResiduals = false;
+  private boolean caseSensitive = true;
+  private ScanMetrics scanMetrics = ScanMetrics.noop();
+  private ExecutorService executorService = ThreadPools.getWorkerPool();
+
+  FilePlanner(
+      FileIO io, ManifestFile root, Schema tableSchema, Map<Integer, 
PartitionSpec> specsById) {
+    Preconditions.checkArgument(io != null, "Invalid file IO: null");
+    Preconditions.checkArgument(root != null, "Invalid root manifest: null");
+    Preconditions.checkArgument(tableSchema != null, "Invalid table schema: 
null");
+    Preconditions.checkArgument(specsById != null, "Invalid specs by ID: 
null");
+    this.io = io;
+    this.root = root;
+    this.tableSchema = tableSchema;
+    this.specsById = ImmutableMap.copyOf(specsById);
+  }
+
+  FilePlanner tableLocation(String newTableLocation) {
+    Preconditions.checkArgument(newTableLocation != null, "Invalid table 
location: null");
+    this.tableLocation = newTableLocation;
+    return this;
+  }
+
+  FilePlanner filterData(Expression expr) {
+    Preconditions.checkArgument(expr != null, "Invalid filter: null");
+    this.dataFilter = expr;
+    return this;
+  }
+
+  FilePlanner ignoreResiduals() {
+    this.ignoreResiduals = true;
+    return this;
+  }
+
+  FilePlanner caseSensitive(boolean newCaseSensitive) {
+    this.caseSensitive = newCaseSensitive;
+    return this;
+  }
+
+  FilePlanner scanMetrics(ScanMetrics newScanMetrics) {
+    Preconditions.checkArgument(newScanMetrics != null, "Invalid scan metrics: 
null");
+    this.scanMetrics = newScanMetrics;
+    return this;
+  }
+
+  FilePlanner planWith(ExecutorService newExecutorService) {
+    this.executorService = newExecutorService;
+    return this;
+  }
+
+  CloseableIterable<FileScanTask> planFiles() {
+    List<DataFile> rootDataFiles = Lists.newArrayList();
+    List<ManifestFile> leafManifests = Lists.newArrayList();
+
+    try (CloseableIterable<TrackedFile> rootEntries = reader(root)) {
+      for (TrackedFile entry : rootEntries) {
+        switch (entry.contentType()) {
+          case DATA:
+            rootDataFiles.add(TrackedFileAdapters.asDataFile(entry, 
specsById));
+            break;
+          case DATA_MANIFEST:
+            leafManifests.add(TrackedFileAdapters.asManifestFile(entry));
+            break;
+          case DELETE_MANIFEST:
+            throw new UnsupportedOperationException("v3 and earlier deletes 
are not yet supported");
+          default:
+            throw new UnsupportedOperationException(
+                "Unsupported file type in root manifest: " + 
entry.contentType());
+        }
+      }
+    } catch (IOException e) {
+      throw new UncheckedIOException("Failed to close root manifest: " + 
root.path(), e);
+    }
+
+    List<CloseableIterable<TrackedFile>> leafPlanTasks = Lists.newArrayList();
+    for (ManifestFile leaf : leafManifests) {
+      leafPlanTasks.add(reader(leaf));
+    }
+
+    CloseableIterable<TrackedFile> leafFiles =
+        executorService != null && leafManifests.size() > 1
+            ? new ParallelIterable<>(leafPlanTasks, executorService)
+            : CloseableIterable.concat(leafPlanTasks);
+
+    CloseableIterable<DataFile> leafDataFiles =
+        CloseableIterable.transform(
+            leafFiles, file -> TrackedFileAdapters.asDataFile(file, 
specsById));
+
+    CloseableIterable<DataFile> dataFiles =
+        CloseableIterable.concat(
+            ImmutableList.of(CloseableIterable.of(rootDataFiles), 
leafDataFiles));
+
+    return CloseableIterable.transform(dataFiles, this::createTask);
+  }
+
+  private CloseableIterable<TrackedFile> reader(ManifestFile manifest) {
+    V4ManifestReader.Builder builder =
+        V4ManifestReader.builder(manifest, io, tableSchema, specsById)
+            .forScanPlanning()
+            .filter(dataFilter)
+            .caseSensitive(caseSensitive)
+            .scanMetrics(scanMetrics);
+    if (tableLocation != null) {
+      builder.tableLocation(tableLocation);
+    }
+
+    return builder.build();
+  }
+
+  private FileScanTask createTask(DataFile dataFile) {
+    TaskContext context =
+        taskContextsBySpec.computeIfAbsent(dataFile.specId(), 
this::newTaskContext);
+
+    DeleteFile[] deletes = NO_DELETES;
+    if (dataFile.deletionVector() != null) {
+      TrackedFile tracked = TrackedFileAdapters.asTrackedFile(dataFile);
+      DeleteFile dv = TrackedFileAdapters.asDVDeleteFile(tracked, specsById);
+      scanMetrics.dvs().increment();
+      deletes = new DeleteFile[] {dv};
+    }
+
+    ScanMetricsUtil.fileTask(scanMetrics, dataFile, deletes);
+
+    return new BaseFileScanTask(
+        dataFile, deletes, context.schemaAsString, context.specAsString, 
context.residuals);
+  }
+
+  private TaskContext newTaskContext(int specId) {
+    PartitionSpec spec = specsById.get(specId);
+    Expression filter = ignoreResiduals ? Expressions.alwaysTrue() : 
dataFilter;
+    return new TaskContext(
+        SchemaParser.toJson(spec.schema()),
+        PartitionSpecParser.toJson(spec),
+        ResidualEvaluator.of(spec, filter, caseSensitive));
+  }
+
+  /** Per-spec task inputs computed once and shared across all files of a 
spec. */
+  private static class TaskContext {
+    private final String schemaAsString;
+    private final String specAsString;
+    private final ResidualEvaluator residuals;
+
+    private TaskContext(String schemaAsString, String specAsString, 
ResidualEvaluator residuals) {
+      this.schemaAsString = schemaAsString;
+      this.specAsString = specAsString;
+      this.residuals = residuals;
+    }
+  }
+}
diff --git a/core/src/main/java/org/apache/iceberg/RootManifestFile.java 
b/core/src/main/java/org/apache/iceberg/RootManifestFile.java
new file mode 100644
index 0000000000..5c7bf8bbc9
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/RootManifestFile.java
@@ -0,0 +1,148 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg;
+
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.List;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.util.ByteBuffers;
+
+/** A {@link ManifestFile} for the root manifest of an adaptive metadata tree. 
*/
+class RootManifestFile implements ManifestFile {
+  private static final int FORMAT_VERSION = 4;
+
+  private final String location;
+  private final long length;
+  private final long snapshotId;
+  private final long sequenceNumber;
+  private final Long firstRowId;
+  private final byte[] keyMetadata;
+
+  RootManifestFile(
+      String location,
+      long length,
+      long snapshotId,
+      long sequenceNumber,
+      Long firstRowId,
+      ByteBuffer keyMetadata) {
+    Preconditions.checkArgument(location != null, "Invalid location: null");
+    this.location = location;
+    this.length = length;
+    this.snapshotId = snapshotId;
+    this.sequenceNumber = sequenceNumber;
+    this.firstRowId = firstRowId;
+    this.keyMetadata = ByteBuffers.toByteArray(keyMetadata);
+  }
+
+  @Override
+  public String path() {
+    return location;
+  }
+
+  @Override
+  public long length() {
+    return length;
+  }
+
+  @Override
+  public int partitionSpecId() {
+    throw new UnsupportedOperationException("Root manifest has no partition 
spec");
+  }
+
+  @Override
+  public ManifestContent content() {
+    return ManifestContent.DATA;
+  }
+
+  @Override
+  public long sequenceNumber() {
+    return sequenceNumber;
+  }
+
+  @Override
+  public long minSequenceNumber() {
+    throw new UnsupportedOperationException("Root manifest has no minimum 
sequence number");
+  }
+
+  @Override
+  public Long snapshotId() {
+    return snapshotId;
+  }
+
+  @Override
+  public Integer addedFilesCount() {
+    throw new UnsupportedOperationException("Root manifest has no added files 
count");
+  }
+
+  @Override
+  public Long addedRowsCount() {
+    throw new UnsupportedOperationException("Root manifest has no added rows 
count");
+  }
+
+  @Override
+  public Integer existingFilesCount() {
+    throw new UnsupportedOperationException("Root manifest has no existing 
files count");
+  }
+
+  @Override
+  public Long existingRowsCount() {
+    throw new UnsupportedOperationException("Root manifest has no existing 
rows count");
+  }
+
+  @Override
+  public Integer deletedFilesCount() {
+    throw new UnsupportedOperationException("Root manifest has no deleted 
files count");
+  }
+
+  @Override
+  public Long deletedRowsCount() {
+    throw new UnsupportedOperationException("Root manifest has no deleted rows 
count");
+  }
+
+  @Override
+  public List<PartitionFieldSummary> partitions() {
+    throw new UnsupportedOperationException("Root manifest has no partition 
summaries");
+  }
+
+  @Override
+  public ByteBuffer keyMetadata() {
+    return keyMetadata == null ? null : ByteBuffer.wrap(keyMetadata);
+  }
+
+  @Override
+  public Long firstRowId() {
+    return firstRowId;
+  }
+
+  @Override
+  public int formatVersion() {
+    return FORMAT_VERSION;
+  }
+
+  @Override
+  public ManifestFile copy() {
+    ByteBuffer keyMetadataCopy =
+        keyMetadata == null
+            ? null
+            : ByteBuffer.wrap(Arrays.copyOf(keyMetadata, keyMetadata.length));
+    return new RootManifestFile(
+        location, length, snapshotId, sequenceNumber, firstRowId, 
keyMetadataCopy);
+  }
+}
diff --git a/core/src/main/java/org/apache/iceberg/TrackedFileAdapters.java 
b/core/src/main/java/org/apache/iceberg/TrackedFileAdapters.java
index cd3c573968..d1d074c116 100644
--- a/core/src/main/java/org/apache/iceberg/TrackedFileAdapters.java
+++ b/core/src/main/java/org/apache/iceberg/TrackedFileAdapters.java
@@ -42,6 +42,10 @@ class TrackedFileAdapters {
     return new TrackedDataFile(file, resolveSpecId(file, specsById));
   }
 
+  static TrackedFile asTrackedFile(DataFile file) {
+    return ((TrackedDataFile) file).file();
+  }
+
   static DeleteFile asDVDeleteFile(TrackedFile file, Map<Integer, 
PartitionSpec> specsById) {
     Preconditions.checkArgument(
         file.contentType() == FileContent.DATA,
diff --git a/core/src/test/java/org/apache/iceberg/TestFilePlanner.java 
b/core/src/test/java/org/apache/iceberg/TestFilePlanner.java
new file mode 100644
index 0000000000..dd2e7d3ed1
--- /dev/null
+++ b/core/src/test/java/org/apache/iceberg/TestFilePlanner.java
@@ -0,0 +1,660 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg;
+
+import static org.apache.iceberg.V4TestHelpers.FILE_SIZE_IN_BYTES;
+import static org.apache.iceberg.V4TestHelpers.FORMAT_VERSION_V4;
+import static org.apache.iceberg.V4TestHelpers.RECORD_COUNT;
+import static org.apache.iceberg.V4TestHelpers.SNAPSHOT_ID;
+import static org.apache.iceberg.V4TestHelpers.dataFile;
+import static org.apache.iceberg.V4TestHelpers.deletionVector;
+import static org.apache.iceberg.V4TestHelpers.manifestRef;
+import static org.apache.iceberg.V4TestHelpers.manifestRefWithStats;
+import static org.apache.iceberg.V4TestHelpers.writeTrackedFiles;
+import static org.apache.iceberg.types.Types.NestedField.optional;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.tuple;
+
+import java.io.IOException;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.UnaryOperator;
+import org.apache.iceberg.exceptions.ValidationException;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.inmemory.InMemoryFileIO;
+import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.io.CloseableIterator;
+import org.apache.iceberg.io.FileIO;
+import org.apache.iceberg.io.InputFile;
+import org.apache.iceberg.io.OutputFile;
+import org.apache.iceberg.metrics.DefaultMetricsContext;
+import org.apache.iceberg.metrics.ScanMetrics;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.relocated.com.google.common.collect.Iterables;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.FieldSource;
+import org.mockito.Mockito;
+
+class TestFilePlanner {
+  private static final long SEQUENCE_NUMBER = 7L;
+  private static final long FIRST_ROW_ID = 1000L;
+  private static final String TABLE_LOCATION = "s3://bucket/db/table";
+  private static final DeletionVector DV =
+      deletionVector("s3://bucket/db/table/dv.puffin", 100L, 50L, 5L);
+
+  private static final String FILE_A = "s3://bucket/db/table/file-a.parquet";
+  private static final String FILE_B = "s3://bucket/db/table/file-b.parquet";
+  private static final String FILE_C = "s3://bucket/db/table/file-c.parquet";
+  private static final String FILE_D = "s3://bucket/db/table/file-d.parquet";
+  private static final String PARTITIONED_FILE = 
"s3://bucket/db/table/partitioned.parquet";
+  private static final String UNPARTITIONED_FILE = 
"s3://bucket/db/table/unpartitioned.parquet";
+
+  private static final Schema TABLE_SCHEMA =
+      new Schema(
+          optional(1, "id", Types.IntegerType.get()), optional(2, "data", 
Types.StringType.get()));
+  private static final int UNPARTITIONED_SPEC_ID = 
PartitionSpec.unpartitioned().specId();
+  private static final Types.StructType EMPTY_PARTITION = 
Types.StructType.of();
+  private static final PartitionData EMPTY_PARTITION_DATA = new 
PartitionData(EMPTY_PARTITION);
+  private static final Map<Integer, PartitionSpec> UNPARTITIONED_SPECS =
+      ImmutableMap.of(UNPARTITIONED_SPEC_ID, PartitionSpec.unpartitioned());
+
+  private static final PartitionSpec UNPARTITIONED_WITH_SCHEMA =
+      PartitionSpec.builderFor(TABLE_SCHEMA).withSpecId(0).build();
+  private static final PartitionSpec BY_ID_SPEC =
+      
PartitionSpec.builderFor(TABLE_SCHEMA).withSpecId(1).identity("id").build();
+  private static final Map<Integer, PartitionSpec> MIXED_SPECS =
+      ImmutableMap.of(
+          UNPARTITIONED_WITH_SCHEMA.specId(), UNPARTITIONED_WITH_SCHEMA,
+          BY_ID_SPEC.specId(), BY_ID_SPEC);
+
+  private static final List<FileFormat> MANIFEST_FORMATS =
+      ImmutableList.of(FileFormat.AVRO, FileFormat.PARQUET);
+
+  private static final MetricsConfig METRICS_CONFIG =
+      MetricsConfig.from(ImmutableMap.of(), TABLE_SCHEMA, null);
+  private static final Types.StructType STATS_TYPE =
+      StatsUtil.statsWriteSchema(TABLE_SCHEMA, METRICS_CONFIG);
+
+  private final InMemoryFileIO fileIO = new InMemoryFileIO();
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void rootWithOnlyDataFiles(FileFormat format) throws IOException {
+    ManifestFile root =
+        writeManifest(
+            format,
+            EMPTY_PARTITION,
+            ImmutableList.of(unpartitionedDataFile(FILE_A), 
unpartitionedDataFile(FILE_B)));
+
+    List<FileScanTask> tasks = plan(root, UNPARTITIONED_SPECS);
+
+    assertThat(tasks)
+        .hasSize(2)
+        .extracting(task -> task.file().location())
+        .containsExactlyInAnyOrder(FILE_A, FILE_B);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void rootWithOnlyLeafManifests(FileFormat format) throws IOException {
+    ManifestFile leafA =
+        writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(unpartitionedDataFile(FILE_A)));
+    ManifestFile leafB =
+        writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(unpartitionedDataFile(FILE_B)));
+    ManifestFile root =
+        writeManifest(
+            format,
+            EMPTY_PARTITION,
+            ImmutableList.of(dataManifest(leafA.path()), 
dataManifest(leafB.path())));
+
+    List<FileScanTask> tasks = plan(root, UNPARTITIONED_SPECS);
+
+    assertThat(tasks)
+        .hasSize(2)
+        .extracting(task -> task.file().location())
+        .containsExactlyInAnyOrder(FILE_A, FILE_B);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void rootWithMixedDataAndLeafManifests(FileFormat format) throws IOException 
{
+    ManifestFile leaf1 =
+        writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(unpartitionedDataFile(FILE_C)));
+    ManifestFile leaf2 =
+        writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(unpartitionedDataFile(FILE_D)));
+    ManifestFile root =
+        writeManifest(
+            format,
+            EMPTY_PARTITION,
+            ImmutableList.of(
+                unpartitionedDataFile(FILE_A),
+                dataManifest(leaf1.path()),
+                dataManifest(leaf2.path())));
+
+    List<FileScanTask> tasks = plan(root, UNPARTITIONED_SPECS);
+
+    assertThat(tasks)
+        .hasSize(3)
+        .extracting(task -> task.file().location())
+        .containsExactlyInAnyOrder(FILE_A, FILE_C, FILE_D);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void filterSkipsAndMatchesRootDataFiles(FileFormat format) throws 
IOException {
+    ManifestFile root =
+        writeManifest(
+            format,
+            EMPTY_PARTITION,
+            ImmutableList.of(
+                dataFileWithStats(FILE_A, idStats(0, 99)),
+                dataFileWithStats(FILE_B, idStats(100, 199))));
+
+    List<FileScanTask> tasks =
+        plan(root, UNPARTITIONED_SPECS, planner -> 
planner.filterData(Expressions.equal("id", 50)));
+
+    assertThat(tasks)
+        .extracting(task -> task.file().location())
+        .as("only the root data file whose stats match the filter survives")
+        .containsExactlyInAnyOrder(FILE_A);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void filterSkipsAndMatchesRootDataFilesAndManifests(FileFormat format) 
throws IOException {
+    ManifestFile matchedLeaf =
+        writeManifest(
+            format,
+            EMPTY_PARTITION,
+            ImmutableList.of(
+                dataFileWithStats(FILE_C, idStats(0, 99)),
+                dataFileWithStats(FILE_D, idStats(100, 199))));
+    ManifestFile root =
+        writeManifest(
+            format,
+            EMPTY_PARTITION,
+            ImmutableList.of(
+                dataFileWithStats(FILE_A, idStats(0, 99)),
+                dataFileWithStats(FILE_B, idStats(100, 199)),
+                dataManifestWithStats(matchedLeaf.path(), idStats(0, 199)),
+                dataManifestWithStats(
+                    "s3://bucket/db/table/pruned-leaf.parquet", idStats(100, 
199))));
+
+    List<FileScanTask> tasks =
+        plan(root, UNPARTITIONED_SPECS, planner -> 
planner.filterData(Expressions.equal("id", 50)));
+
+    assertThat(tasks)
+        .extracting(task -> task.file().location())
+        .as(
+            "the matched root data file and the file in the matched leaf 
survive; the pruned data file and leaf do not")
+        .containsExactlyInAnyOrder(FILE_A, FILE_C);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void filterSkipsAndMatchesLeafDataFiles(FileFormat format) throws 
IOException {
+    ManifestFile leaf =
+        writeManifest(
+            format,
+            EMPTY_PARTITION,
+            ImmutableList.of(
+                dataFileWithStats(FILE_C, idStats(0, 99)),
+                dataFileWithStats(FILE_D, idStats(100, 199))));
+    ManifestFile root =
+        writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(dataManifest(leaf.path(), 2)));
+
+    List<FileScanTask> tasks =
+        plan(root, UNPARTITIONED_SPECS, planner -> 
planner.filterData(Expressions.equal("id", 50)));
+
+    assertThat(Iterables.getOnlyElement(tasks).file().location())
+        .as("only the leaf file whose stats match the filter survives")
+        .isEqualTo(FILE_C);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void filterHonorsCaseSensitivity(FileFormat format) throws IOException {
+    ManifestFile leaf =
+        writeManifest(
+            format,
+            EMPTY_PARTITION,
+            ImmutableList.of(
+                dataFileWithStats(FILE_C, idStats(0, 99)),
+                dataFileWithStats(FILE_D, idStats(100, 199))));
+    ManifestFile root =
+        writeManifest(
+            format,
+            EMPTY_PARTITION,
+            ImmutableList.of(
+                dataFileWithStats(FILE_A, idStats(0, 99)),
+                dataFileWithStats(FILE_B, idStats(100, 199)),
+                dataManifestWithStats(leaf.path(), idStats(0, 199))));
+
+    List<FileScanTask> tasks =
+        plan(
+            root,
+            UNPARTITIONED_SPECS,
+            planner -> planner.filterData(Expressions.equal("ID", 
50)).caseSensitive(false));
+    assertThat(tasks)
+        .extracting(task -> task.file().location())
+        .as("a case-insensitive filter resolves \"ID\" to \"id\" in both the 
root and leaf readers")
+        .containsExactlyInAnyOrder(FILE_A, FILE_C);
+
+    assertThatThrownBy(
+            () ->
+                plan(
+                    root,
+                    UNPARTITIONED_SPECS,
+                    planner -> planner.filterData(Expressions.equal("ID", 
50))))
+        .as("the default case-sensitive filter cannot resolve \"ID\"")
+        .isInstanceOf(ValidationException.class)
+        .hasMessageContaining("Cannot find field 'ID'");
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void taskAttachesColocatedDV(FileFormat format) throws IOException {
+    TrackedFile fileWithDV =
+        unpartitionedDataFileWithDV("s3://bucket/db/table/with-dv.parquet", 
DV);
+    TrackedFile fileWithoutDV = unpartitionedDataFile(FILE_A);
+    ManifestFile root =
+        writeManifest(format, EMPTY_PARTITION, ImmutableList.of(fileWithDV, 
fileWithoutDV));
+
+    List<FileScanTask> tasks = plan(root, UNPARTITIONED_SPECS);
+
+    assertThat(tasks)
+        .hasSize(2)
+        .satisfiesExactlyInAnyOrder(
+            task -> {
+              
assertThat(task.file().location()).isEqualTo("s3://bucket/db/table/with-dv.parquet");
+              assertThat(task.deletes())
+                  .as("the colocated DV is attached as the file's only delete")
+                  .hasSize(1)
+                  .allSatisfy(delete -> 
assertThat(delete.location()).isEqualTo(DV.location()));
+            },
+            task -> {
+              assertThat(task.file().location()).isEqualTo(FILE_A);
+              assertThat(task.deletes())
+                  .as("a file without a colocated DV has no deletes")
+                  .isEmpty();
+            });
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void taskExposesFileSchemaAndSpec(FileFormat format) throws IOException {
+    Types.StructType unionType = 
Partitioning.unionPartitionTypes(MIXED_SPECS.values());
+    ManifestFile root =
+        writeManifest(
+            format,
+            unionType,
+            ImmutableList.of(
+                dataFile(PARTITIONED_FILE, BY_ID_SPEC.specId(), 
idPartition(unionType, 1)),
+                dataFile(
+                    UNPARTITIONED_FILE,
+                    UNPARTITIONED_WITH_SCHEMA.specId(),
+                    idPartition(unionType, null))));
+
+    List<FileScanTask> tasks = plan(root, MIXED_SPECS);
+
+    assertThat(tasks)
+        .hasSize(2)
+        .satisfiesExactlyInAnyOrder(
+            task -> {
+              assertThat(task.file().location()).isEqualTo(PARTITIONED_FILE);
+              assertThat(task.spec())
+                  .as("the task carries the full spec its file was written 
with")
+                  .isEqualTo(BY_ID_SPEC);
+              
assertThat(task.schema().asStruct()).isEqualTo(TABLE_SCHEMA.asStruct());
+            },
+            task -> {
+              assertThat(task.file().location()).isEqualTo(UNPARTITIONED_FILE);
+              assertThat(task.spec())
+                  .as("the task carries the full spec its file was written 
with")
+                  .isEqualTo(UNPARTITIONED_WITH_SCHEMA);
+              
assertThat(task.schema().asStruct()).isEqualTo(TABLE_SCHEMA.asStruct());
+            });
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void residualForMixedPartitionedAndUnpartitioned(FileFormat format) throws 
IOException {
+    Types.StructType unionType = 
Partitioning.unionPartitionTypes(MIXED_SPECS.values());
+    ManifestFile root =
+        writeManifest(
+            format,
+            unionType,
+            ImmutableList.of(
+                dataFile(PARTITIONED_FILE, BY_ID_SPEC.specId(), 
idPartition(unionType, 1)),
+                dataFile(
+                    UNPARTITIONED_FILE,
+                    UNPARTITIONED_WITH_SCHEMA.specId(),
+                    idPartition(unionType, null))));
+
+    Expression filter = Expressions.and(Expressions.equal("id", 1), 
Expressions.equal("data", "x"));
+    List<FileScanTask> tasks = plan(root, MIXED_SPECS, planner -> 
planner.filterData(filter));
+
+    assertThat(tasks)
+        .extracting(task -> task.file().location(), task -> 
task.residual().toString())
+        .as(
+            "the id predicate drops out of the partitioned file's residual but 
not the unpartitioned")
+        .containsExactlyInAnyOrder(
+            tuple(PARTITIONED_FILE, Expressions.equal("data", "x").toString()),
+            tuple(UNPARTITIONED_FILE, filter.toString()));
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void ignoreResidualsProducesAlwaysTrueResidual(FileFormat format) throws 
IOException {
+    Types.StructType unionType = 
Partitioning.unionPartitionTypes(MIXED_SPECS.values());
+    ManifestFile root =
+        writeManifest(
+            format,
+            unionType,
+            ImmutableList.of(
+                dataFile(PARTITIONED_FILE, BY_ID_SPEC.specId(), 
idPartition(unionType, 1)),
+                dataFile(
+                    UNPARTITIONED_FILE,
+                    UNPARTITIONED_WITH_SCHEMA.specId(),
+                    idPartition(unionType, null))));
+
+    Expression filter = Expressions.and(Expressions.equal("id", 1), 
Expressions.equal("data", "x"));
+    List<FileScanTask> tasks =
+        plan(root, MIXED_SPECS, planner -> 
planner.filterData(filter).ignoreResiduals());
+
+    assertThat(tasks)
+        .as("ignoreResiduals forces an alwaysTrue residual for every file 
regardless of spec")
+        .hasSize(2)
+        .allSatisfy(task -> 
assertThat(task.residual()).isEqualTo(Expressions.alwaysTrue()));
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void tableLocationForwardedToRootAndLeafReaders(FileFormat format) throws 
IOException {
+    ManifestFile leaf =
+        writeManifest(
+            format, EMPTY_PARTITION, 
ImmutableList.of(unpartitionedDataFile("leaf-data.parquet")));
+    ManifestFile root =
+        writeManifest(
+            format,
+            EMPTY_PARTITION,
+            ImmutableList.of(
+                unpartitionedDataFile("root-data.parquet"), 
dataManifest(leaf.path())));
+
+    List<FileScanTask> tasks =
+        plan(root, UNPARTITIONED_SPECS, planner -> 
planner.tableLocation(TABLE_LOCATION));
+
+    assertThat(tasks)
+        .extracting(task -> task.file().location())
+        .as("relative paths resolve against the table location in both the 
root and leaf readers")
+        .containsExactlyInAnyOrder(
+            TABLE_LOCATION + "/root-data.parquet", TABLE_LOCATION + 
"/leaf-data.parquet");
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void closingWithoutIteratingOpensOnlyRoot(FileFormat format) throws 
IOException {
+    ManifestFile leaf =
+        writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(unpartitionedDataFile(FILE_A)));
+    ManifestFile root =
+        writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(dataManifest(leaf.path())));
+
+    FileIO io = Mockito.spy(fileIO);
+    new FilePlanner(io, root, TABLE_SCHEMA, 
UNPARTITIONED_SPECS).planFiles().close();
+
+    Mockito.verify(io).newInputFile(root.path());
+    Mockito.verify(io, Mockito.never()).newInputFile(leaf.path());
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void leafFilesOpenedLazily(FileFormat format) throws IOException {
+    ManifestFile leaf1 =
+        writeManifest(
+            format,
+            EMPTY_PARTITION,
+            ImmutableList.of(unpartitionedDataFile(FILE_A), 
unpartitionedDataFile(FILE_B)));
+    ManifestFile leaf2 =
+        writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(unpartitionedDataFile(FILE_C)));
+    ManifestFile root =
+        writeManifest(
+            format,
+            EMPTY_PARTITION,
+            ImmutableList.of(dataManifest(leaf1.path()), 
dataManifest(leaf2.path())));
+
+    FileIO io = Mockito.spy(fileIO);
+    FilePlanner planner =
+        new FilePlanner(io, root, TABLE_SCHEMA, 
UNPARTITIONED_SPECS).planWith(null);
+
+    try (CloseableIterable<FileScanTask> plan = planner.planFiles()) {
+      CloseableIterator<FileScanTask> tasks = plan.iterator();
+
+      assertThat(tasks.next().file().location()).isEqualTo(FILE_A);
+      Mockito.verify(io).newInputFile(leaf1.path());
+      Mockito.verify(io, Mockito.never()).newInputFile(leaf2.path());
+
+      assertThat(tasks.next().file().location()).isEqualTo(FILE_B);
+      Mockito.verify(io, Mockito.never()).newInputFile(leaf2.path());
+
+      assertThat(tasks.next().file().location()).isEqualTo(FILE_C);
+      Mockito.verify(io).newInputFile(leaf2.path());
+      assertThat(tasks).isExhausted();
+    }
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void deleteManifestInRootUnsupported(FileFormat format) throws IOException {
+    ManifestFile root =
+        writeManifest(
+            format,
+            EMPTY_PARTITION,
+            
ImmutableList.of(deleteManifest("s3://bucket/db/table/deletes.avro")));
+
+    FilePlanner planner = new FilePlanner(fileIO, root, TABLE_SCHEMA, 
UNPARTITIONED_SPECS);
+    assertThatThrownBy(() -> Lists.newArrayList(planner.planFiles()))
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessage("v3 and earlier deletes are not yet supported");
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void v3ManifestLeafUnsupported(FileFormat format) throws IOException {
+    String leafLocation = "s3://bucket/db/table/v3-leaf." + 
format.name().toLowerCase(Locale.ROOT);
+    TrackedFile v3Leaf =
+        manifestRef(FileContent.DATA_MANIFEST, leafLocation, 
dataManifestInfo(1, 0));
+    ManifestFile root = writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(v3Leaf));
+
+    FilePlanner planner = new FilePlanner(fileIO, root, TABLE_SCHEMA, 
UNPARTITIONED_SPECS);
+    assertThatThrownBy(() -> Lists.newArrayList(planner.planFiles()))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Cannot read manifest with format version 0: only 4 is 
supported");
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  void scanMetricsForComplexFilteredPlan(FileFormat format) throws IOException 
{
+    ManifestFile matchedLeaf =
+        writeManifest(
+            format,
+            EMPTY_PARTITION,
+            ImmutableList.of(
+                dataFileWithStats(FILE_C, idStats(0, 99)),
+                dataFileWithStats(FILE_D, idStats(100, 199))));
+    ManifestFile root =
+        writeManifest(
+            format,
+            EMPTY_PARTITION,
+            ImmutableList.of(
+                dataFileWithStats(FILE_A, idStats(0, 99), DV),
+                dataFileWithStats(FILE_B, idStats(100, 199)),
+                dataManifestWithStats(matchedLeaf.path(), idStats(0, 199)),
+                dataManifestWithStats(
+                    "s3://bucket/db/table/pruned-leaf.parquet", idStats(100, 
199))));
+
+    ScanMetrics metrics = ScanMetrics.of(new DefaultMetricsContext());
+    List<FileScanTask> tasks =
+        plan(
+            root,
+            UNPARTITIONED_SPECS,
+            planner -> planner.filterData(Expressions.equal("id", 
50)).scanMetrics(metrics));
+
+    assertThat(tasks)
+        .extracting(task -> task.file().location())
+        .containsExactlyInAnyOrder(FILE_A, FILE_C);
+    assertThat(metrics.resultDataFiles().value()).isEqualTo(2L);
+    assertThat(metrics.totalFileSizeInBytes().value()).isEqualTo(2 * 
FILE_SIZE_IN_BYTES);
+    assertThat(metrics.scannedDataManifests().value())
+        .as("the root and the matched leaf are scanned")
+        .isEqualTo(2L);
+    assertThat(metrics.skippedDataManifests().value())
+        .as("the pruned leaf ref is skipped")
+        .isEqualTo(1L);
+    assertThat(metrics.skippedDataFiles().value())
+        .as("the pruned root and leaf data files are skipped")
+        .isEqualTo(2L);
+    assertThat(metrics.resultDeleteFiles().value())
+        .as("only the colocated DV contributes a delete file")
+        .isEqualTo(1L);
+    
assertThat(metrics.totalDeleteFileSizeInBytes().value()).isEqualTo(DV.sizeInBytes());
+    assertThat(metrics.dvs().value()).isEqualTo(1L);
+    assertThat(metrics.indexedDeleteFiles().value())
+        .as("colocated DVs are not indexed delete files")
+        .isEqualTo(0L);
+  }
+
+  private List<FileScanTask> plan(ManifestFile root, Map<Integer, 
PartitionSpec> specsById)
+      throws IOException {
+    return plan(root, specsById, UnaryOperator.identity());
+  }
+
+  private List<FileScanTask> plan(
+      ManifestFile root,
+      Map<Integer, PartitionSpec> specsById,
+      UnaryOperator<FilePlanner> configure)
+      throws IOException {
+    FilePlanner planner = configure.apply(new FilePlanner(fileIO, root, 
TABLE_SCHEMA, specsById));
+    try (CloseableIterable<FileScanTask> tasks = planner.planFiles()) {
+      return Lists.newArrayList(tasks);
+    }
+  }
+
+  private static TrackedFile unpartitionedDataFile(String location) {
+    return dataFile(location, UNPARTITIONED_SPEC_ID, EMPTY_PARTITION_DATA);
+  }
+
+  private static TrackedFile unpartitionedDataFileWithDV(String location, 
DeletionVector dv) {
+    return dataFile(location, UNPARTITIONED_SPEC_ID, EMPTY_PARTITION_DATA, 
null, dv);
+  }
+
+  private static TrackedFile dataFileWithStats(String location, ContentStats 
stats) {
+    return dataFileWithStats(location, stats, null);
+  }
+
+  private static TrackedFile dataFileWithStats(
+      String location, ContentStats stats, DeletionVector dv) {
+    return dataFile(location, UNPARTITIONED_SPEC_ID, EMPTY_PARTITION_DATA, 
stats, dv);
+  }
+
+  private static TrackedFile dataManifest(String location) {
+    return dataManifest(location, 1);
+  }
+
+  private static TrackedFile dataManifest(String location, int addedFiles) {
+    return manifestRef(FileContent.DATA_MANIFEST, location, 
dataManifestInfo(addedFiles));
+  }
+
+  private static TrackedFile dataManifestWithStats(String location, 
ContentStats stats) {
+    return manifestRefWithStats(FileContent.DATA_MANIFEST, location, stats, 
dataManifestInfo(1));
+  }
+
+  private static ContentStats idStats(int lower, int upper) {
+    ContentStatsStruct stats = new ContentStatsStruct(STATS_TYPE);
+    stats.setStats(
+        TABLE_SCHEMA.findField("id").fieldId(),
+        new FieldStatsStruct<>(
+            STATS_TYPE.fieldType("id").asStructType(),
+            lower,
+            upper,
+            true, // tightBounds
+            RECORD_COUNT,
+            0, // nullValueCount
+            0, // nanValueCount
+            0L)); // totalBytes
+    return stats;
+  }
+
+  private static ManifestInfo dataManifestInfo(int addedFiles) {
+    return dataManifestInfo(addedFiles, FORMAT_VERSION_V4);
+  }
+
+  private static ManifestInfo dataManifestInfo(int addedFiles, int 
formatVersion) {
+    return ManifestInfoStruct.builder()
+        .addedFilesCount(addedFiles)
+        .existingFilesCount(0)
+        .deletedFilesCount(0)
+        .replacedFilesCount(0)
+        .modifiedFilesCount(0)
+        .addedRowsCount(addedFiles * RECORD_COUNT)
+        .existingRowsCount(0)
+        .deletedRowsCount(0)
+        .replacedRowsCount(0)
+        .modifiedRowsCount(0)
+        .minSequenceNumber(0L)
+        .formatVersion(formatVersion)
+        .build();
+  }
+
+  private static TrackedFile deleteManifest(String location) {
+    return manifestRef(FileContent.DELETE_MANIFEST, location, null);
+  }
+
+  private static PartitionData idPartition(Types.StructType unionType, Integer 
id) {
+    PartitionData partition = new PartitionData(unionType);
+    partition.set(0, id);
+    return partition;
+  }
+
+  private ManifestFile writeManifest(
+      FileFormat format, Types.StructType partitionType, Iterable<TrackedFile> 
files)
+      throws IOException {
+    OutputFile out =
+        fileIO.newOutputFile(
+            TABLE_LOCATION
+                + "/metadata/manifest-"
+                + System.nanoTime()
+                + "."
+                + format.name().toLowerCase(Locale.ROOT));
+    InputFile written =
+        writeTrackedFiles(out, format, partitionType, STATS_TYPE, 
files).toInputFile();
+    return new RootManifestFile(
+        written.location(),
+        written.getLength(),
+        SNAPSHOT_ID,
+        SEQUENCE_NUMBER,
+        FIRST_ROW_ID,
+        /* keyMetadata= */ null);
+  }
+}
diff --git a/core/src/test/java/org/apache/iceberg/TestRootManifestFile.java 
b/core/src/test/java/org/apache/iceberg/TestRootManifestFile.java
new file mode 100644
index 0000000000..b69e6a21db
--- /dev/null
+++ b/core/src/test/java/org/apache/iceberg/TestRootManifestFile.java
@@ -0,0 +1,110 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+package org.apache.iceberg;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.nio.ByteBuffer;
+import org.junit.jupiter.api.Test;
+
+class TestRootManifestFile {
+  private static final long SNAPSHOT_ID = 42L;
+  private static final long SEQUENCE_NUMBER = 7L;
+  private static final long FIRST_ROW_ID = 1000L;
+  private static final long LENGTH = 1024L;
+  private static final String LOCATION = 
"s3://bucket/db/table/metadata/root.parquet";
+
+  @Test
+  void fieldAccess() {
+    ByteBuffer keyMetadata = ByteBuffer.wrap(new byte[] {9, 8, 7});
+    RootManifestFile root =
+        new RootManifestFile(
+            LOCATION, LENGTH, SNAPSHOT_ID, SEQUENCE_NUMBER, FIRST_ROW_ID, 
keyMetadata);
+
+    assertThat(root.path()).isEqualTo(LOCATION);
+    assertThat(root.length()).isEqualTo(LENGTH);
+    assertThat(root.snapshotId()).isEqualTo(SNAPSHOT_ID);
+    assertThat(root.sequenceNumber()).isEqualTo(SEQUENCE_NUMBER);
+    assertThat(root.firstRowId()).isEqualTo(FIRST_ROW_ID);
+    assertThat(root.keyMetadata()).isEqualTo(keyMetadata);
+  }
+
+  @Test
+  void defaultedFields() {
+    RootManifestFile root =
+        new RootManifestFile(LOCATION, LENGTH, SNAPSHOT_ID, SEQUENCE_NUMBER, 
FIRST_ROW_ID, null);
+
+    assertThat(root.content()).isEqualTo(ManifestContent.DATA);
+    assertThat(root.formatVersion()).isEqualTo(4);
+  }
+
+  @Test
+  void copy() {
+    ByteBuffer keyMetadata = ByteBuffer.wrap(new byte[] {9, 8, 7});
+    RootManifestFile original =
+        new RootManifestFile(
+            LOCATION, LENGTH, SNAPSHOT_ID, SEQUENCE_NUMBER, FIRST_ROW_ID, 
keyMetadata);
+    ManifestFile copy = original.copy();
+
+    assertThat(copy.path()).isEqualTo(LOCATION);
+    assertThat(copy.length()).isEqualTo(LENGTH);
+    assertThat(copy.snapshotId()).isEqualTo(SNAPSHOT_ID);
+    assertThat(copy.sequenceNumber()).isEqualTo(SEQUENCE_NUMBER);
+    assertThat(copy.firstRowId()).isEqualTo(FIRST_ROW_ID);
+    assertThat(copy.keyMetadata()).isEqualTo(keyMetadata);
+    assertThat(copy.keyMetadata().array())
+        .as("copy should not share the key metadata backing array")
+        .isNotSameAs(original.keyMetadata().array());
+  }
+
+  @Test
+  void unsupportedAccessorsThrow() {
+    RootManifestFile root =
+        new RootManifestFile(LOCATION, LENGTH, SNAPSHOT_ID, SEQUENCE_NUMBER, 
FIRST_ROW_ID, null);
+
+    assertThatThrownBy(root::partitionSpecId)
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessage("Root manifest has no partition spec");
+    assertThatThrownBy(root::minSequenceNumber)
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessage("Root manifest has no minimum sequence number");
+    assertThatThrownBy(root::addedFilesCount)
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessage("Root manifest has no added files count");
+    assertThatThrownBy(root::addedRowsCount)
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessage("Root manifest has no added rows count");
+    assertThatThrownBy(root::existingFilesCount)
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessage("Root manifest has no existing files count");
+    assertThatThrownBy(root::existingRowsCount)
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessage("Root manifest has no existing rows count");
+    assertThatThrownBy(root::deletedFilesCount)
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessage("Root manifest has no deleted files count");
+    assertThatThrownBy(root::deletedRowsCount)
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessage("Root manifest has no deleted rows count");
+    assertThatThrownBy(root::partitions)
+        .isInstanceOf(UnsupportedOperationException.class)
+        .hasMessage("Root manifest has no partition summaries");
+  }
+}
diff --git a/core/src/test/java/org/apache/iceberg/V4TestHelpers.java 
b/core/src/test/java/org/apache/iceberg/V4TestHelpers.java
index 2e803be312..b8cf5164d4 100644
--- a/core/src/test/java/org/apache/iceberg/V4TestHelpers.java
+++ b/core/src/test/java/org/apache/iceberg/V4TestHelpers.java
@@ -42,6 +42,15 @@ class V4TestHelpers {
     return dataFile(ADDED_TRACKING, location, specId, partition, null, null);
   }
 
+  static TrackedFile dataFile(
+      String location,
+      Integer specId,
+      PartitionData partition,
+      ContentStats stats,
+      DeletionVector dv) {
+    return dataFile(ADDED_TRACKING, location, specId, partition, stats, dv);
+  }
+
   static TrackedFile dataFileWithStats(String location, ContentStats stats) {
     return dataFile(ADDED_TRACKING, location, null, null, stats, null);
   }
@@ -141,8 +150,18 @@ class V4TestHelpers {
       Types.StructType statsType,
       Iterable<TrackedFile> files)
       throws IOException {
-    Schema writeSchema = TrackedFile.schema(partitionType, statsType);
     OutputFile out = io.newOutputFile(format.addExtension("manifest." + 
System.nanoTime()));
+    return writeTrackedFiles(out, format, partitionType, statsType, files);
+  }
+
+  static OutputFile writeTrackedFiles(
+      OutputFile out,
+      FileFormat format,
+      Types.StructType partitionType,
+      Types.StructType statsType,
+      Iterable<TrackedFile> files)
+      throws IOException {
+    Schema writeSchema = TrackedFile.schema(partitionType, statsType);
     try (FileAppender<StructLike> appender =
         InternalData.write(format, 
out).schema(writeSchema).named("tracked_file").build()) {
       for (TrackedFile file : files) {

Reply via email to