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

danielcweeks 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 1e325bd6ce Core: Add v4 manifest reader (#16958)
1e325bd6ce is described below

commit 1e325bd6cec4cb19536a5d2577723a3570c43983
Author: Anoop Johnson <[email protected]>
AuthorDate: Thu Jul 30 07:30:54 2026 -0700

    Core: Add v4 manifest reader (#16958)
    
    * Core: Add v4 manifest reader
---
 .../org/apache/iceberg/types/ReplaceTypeById.java  | 107 +++
 .../java/org/apache/iceberg/types/TypeUtil.java    |  18 +
 .../org/apache/iceberg/types/TestTypeUtil.java     | 130 +++
 .../main/java/org/apache/iceberg/Partitioning.java |  19 +
 .../main/java/org/apache/iceberg/TrackedFile.java  |  24 +-
 .../java/org/apache/iceberg/TrackedFileStruct.java |  12 +-
 .../java/org/apache/iceberg/TrackingStruct.java    |  14 +-
 .../java/org/apache/iceberg/V4ManifestReader.java  | 300 +++++++
 .../java/org/apache/iceberg/TestPartitioning.java  |  21 +
 .../java/org/apache/iceberg/TestTrackedFile.java   |  37 +-
 .../apache/iceberg/TestTrackedFileAdapters.java    |  14 +-
 .../org/apache/iceberg/TestTrackedFileStruct.java  |  26 +-
 .../org/apache/iceberg/TestV4ManifestReader.java   | 887 +++++++++++++++++++++
 13 files changed, 1564 insertions(+), 45 deletions(-)

diff --git a/api/src/main/java/org/apache/iceberg/types/ReplaceTypeById.java 
b/api/src/main/java/org/apache/iceberg/types/ReplaceTypeById.java
new file mode 100644
index 0000000000..1c94bd57c1
--- /dev/null
+++ b/api/src/main/java/org/apache/iceberg/types/ReplaceTypeById.java
@@ -0,0 +1,107 @@
+/*
+ * 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.types;
+
+import java.util.List;
+import java.util.Map;
+import org.apache.iceberg.Schema;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
+
+class ReplaceTypeById extends TypeUtil.SchemaVisitor<Type> {
+  private final Map<Integer, Type> replacementsById;
+
+  ReplaceTypeById(Map<Integer, Type> replacementsById) {
+    this.replacementsById = replacementsById;
+  }
+
+  @Override
+  public Type schema(Schema schema, Type structResult) {
+    return structResult;
+  }
+
+  @Override
+  public Type struct(Types.StructType struct, List<Type> fieldResults) {
+    List<Types.NestedField> fields = struct.fields();
+    List<Types.NestedField> newFields = 
Lists.newArrayListWithExpectedSize(fields.size());
+    boolean hasChanged = false;
+
+    for (int i = 0; i < fields.size(); i += 1) {
+      Type fieldReplacement = fieldResults.get(i);
+      Types.NestedField field = fields.get(i);
+      if (field.type() != fieldReplacement) {
+        hasChanged = true;
+        
newFields.add(Types.NestedField.from(field).ofType(fieldReplacement).build());
+      } else {
+        newFields.add(field);
+      }
+    }
+
+    if (hasChanged) {
+      return Types.StructType.of(newFields);
+    }
+
+    return struct;
+  }
+
+  @Override
+  public Type field(Types.NestedField field, Type fieldResult) {
+    return replacementsById.getOrDefault(field.fieldId(), fieldResult);
+  }
+
+  @Override
+  public Type list(Types.ListType list, Type elementResult) {
+    Type elementReplacement = replacementsById.getOrDefault(list.elementId(), 
elementResult);
+    if (list.elementType() != elementReplacement) {
+      if (list.isElementRequired()) {
+        return Types.ListType.ofRequired(list.elementId(), elementReplacement);
+      } else {
+        return Types.ListType.ofOptional(list.elementId(), elementReplacement);
+      }
+    }
+
+    return list;
+  }
+
+  @Override
+  public Type map(Types.MapType map, Type keyResult, Type valueResult) {
+    Type keyReplacement = replacementsById.getOrDefault(map.keyId(), 
keyResult);
+    Type valueReplacement = replacementsById.getOrDefault(map.valueId(), 
valueResult);
+    if (map.keyType() != keyReplacement || map.valueType() != 
valueReplacement) {
+      if (map.isValueRequired()) {
+        return Types.MapType.ofRequired(
+            map.keyId(), map.valueId(), keyReplacement, valueReplacement);
+      } else {
+        return Types.MapType.ofOptional(
+            map.keyId(), map.valueId(), keyReplacement, valueReplacement);
+      }
+    }
+
+    return map;
+  }
+
+  @Override
+  public Type variant(Types.VariantType variant) {
+    return variant;
+  }
+
+  @Override
+  public Type primitive(Type.PrimitiveType primitive) {
+    return primitive;
+  }
+}
diff --git a/api/src/main/java/org/apache/iceberg/types/TypeUtil.java 
b/api/src/main/java/org/apache/iceberg/types/TypeUtil.java
index a3bee3e3d8..8e39ae7a43 100644
--- a/api/src/main/java/org/apache/iceberg/types/TypeUtil.java
+++ b/api/src/main/java/org/apache/iceberg/types/TypeUtil.java
@@ -157,6 +157,24 @@ public class TypeUtil {
     return project(schema, projectedIds);
   }
 
+  /**
+   * Returns a copy of the schema with the type of each field in {@code 
replacementsById} replaced
+   * by its mapped type. Fields not in the map are unchanged.
+   *
+   * @param schema a schema
+   * @param replacementsById a map from field ID to the type that should 
replace the field's type
+   * @return a schema with the replaced field types
+   */
+  public static Schema replaceFieldTypes(Schema schema, Map<Integer, Type> 
replacementsById) {
+    Types.StructType struct = visit(schema, new 
ReplaceTypeById(replacementsById)).asStructType();
+    if (struct.equals(schema.asStruct())) {
+      return schema;
+    }
+
+    return new Schema(
+        schema.schemaId(), struct.fields(), schema.getAliases(), 
schema.identifierFieldIds());
+  }
+
   public static Schema join(Schema left, Schema right) {
     List<Types.NestedField> joinedColumns = Lists.newArrayList(left.columns());
     for (Types.NestedField rightColumn : right.columns()) {
diff --git a/api/src/test/java/org/apache/iceberg/types/TestTypeUtil.java 
b/api/src/test/java/org/apache/iceberg/types/TestTypeUtil.java
index b7da4b3108..d540d23961 100644
--- a/api/src/test/java/org/apache/iceberg/types/TestTypeUtil.java
+++ b/api/src/test/java/org/apache/iceberg/types/TestTypeUtil.java
@@ -29,6 +29,7 @@ import java.util.concurrent.atomic.AtomicInteger;
 import java.util.stream.Stream;
 import org.apache.iceberg.Schema;
 import org.apache.iceberg.expressions.Literal;
+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.Sets;
 import org.apache.iceberg.types.Types.IntegerType;
@@ -1037,4 +1038,133 @@ public class TestTypeUtil {
         .containsEntry(24, "addresses_value") // the leaf takes precedence
         .hasSize(22);
   }
+
+  @Test
+  public void testReplaceFieldTypes() {
+    Types.StructType replacement = Types.StructType.of(required(10, "x", 
IntegerType.get()));
+    Schema schema =
+        new Schema(
+            required(1, "id", IntegerType.get()),
+            required(2, "s", Types.StructType.of(required(3, "a", 
Types.LongType.get()))));
+
+    Schema result = TypeUtil.replaceFieldTypes(schema, ImmutableMap.of(2, 
replacement));
+
+    assertThat(result.findField(1).type()).isEqualTo(IntegerType.get());
+    assertThat(result.findField(2).type()).isEqualTo(replacement);
+  }
+
+  @Test
+  public void testReplaceFieldTypesPrimitive() {
+    Schema schema =
+        new Schema(required(1, "id", IntegerType.get()), required(2, "count", 
IntegerType.get()));
+
+    Schema result = TypeUtil.replaceFieldTypes(schema, ImmutableMap.of(2, 
Types.LongType.get()));
+
+    assertThat(result.findField(1).type()).isEqualTo(IntegerType.get());
+    assertThat(result.findField(2).type()).isEqualTo(Types.LongType.get());
+  }
+
+  @Test
+  public void testReplaceFieldTypesListElement() {
+    Schema schema =
+        new Schema(required(1, "list", Types.ListType.ofRequired(2, 
Types.StructType.of())));
+
+    Schema result =
+        TypeUtil.replaceFieldTypes(
+            schema, ImmutableMap.of(2, Types.StructType.of(required(3, "x", 
IntegerType.get()))));
+
+    Types.ListType list = (Types.ListType) result.findField(1).type();
+    
assertThat(list.elementType().asStructType().field(3).name()).isEqualTo("x");
+    assertThat(list.isElementRequired()).isTrue();
+  }
+
+  @Test
+  public void testReplaceFieldTypesMapKeyAndValue() {
+    Schema schema =
+        new Schema(
+            required(1, "m", Types.MapType.ofRequired(2, 3, IntegerType.get(), 
IntegerType.get())));
+
+    Schema result =
+        TypeUtil.replaceFieldTypes(
+            schema, ImmutableMap.of(2, Types.LongType.get(), 3, 
Types.StringType.get()));
+
+    Types.MapType map = (Types.MapType) result.findField(1).type();
+    assertThat(map.keyType()).isEqualTo(Types.LongType.get());
+    assertThat(map.valueType()).isEqualTo(Types.StringType.get());
+    assertThat(map.isValueRequired()).isTrue();
+  }
+
+  @Test
+  public void testReplaceFieldTypesPreservesDefaultsOnCompatibleType() {
+    Types.NestedField field =
+        Types.NestedField.optional("count")
+            .withId(1)
+            .ofType(IntegerType.get())
+            .withInitialDefault(Literal.of(5))
+            .withWriteDefault(Literal.of(7))
+            .build();
+    Schema schema = new Schema(field);
+
+    Schema result = TypeUtil.replaceFieldTypes(schema, ImmutableMap.of(1, 
Types.LongType.get()));
+
+    Types.NestedField replaced = result.findField(1);
+    assertThat(replaced.type()).isEqualTo(Types.LongType.get());
+    assertThat(replaced.initialDefault()).isEqualTo(5L);
+    assertThat(replaced.writeDefault()).isEqualTo(7L);
+  }
+
+  @Test
+  public void testReplaceFieldTypesRejectsDefaultIncompatibleWithType() {
+    Types.NestedField field =
+        Types.NestedField.optional("count")
+            .withId(1)
+            .ofType(IntegerType.get())
+            .withInitialDefault(Literal.of(5))
+            .build();
+    Schema schema = new Schema(field);
+
+    assertThatThrownBy(
+            () -> TypeUtil.replaceFieldTypes(schema, ImmutableMap.of(1, 
Types.StringType.get())))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("Cannot cast default value to string");
+  }
+
+  @Test
+  public void testReplaceFieldTypesPreservesIdentifierField() {
+    Schema schema =
+        new Schema(
+            Lists.newArrayList(
+                required(1, "id", IntegerType.get()), required(2, "data", 
Types.StringType.get())),
+            Sets.newHashSet(1));
+
+    Schema result = TypeUtil.replaceFieldTypes(schema, ImmutableMap.of(1, 
Types.LongType.get()));
+
+    assertThat(result.findField(1).type()).isEqualTo(Types.LongType.get());
+    assertThat(result.identifierFieldIds()).containsExactly(1);
+  }
+
+  @Test
+  public void testReplaceFieldTypesRejectsNonPrimitiveIdentifierField() {
+    Schema schema =
+        new Schema(
+            Lists.newArrayList(
+                required(1, "id", IntegerType.get()), required(2, "data", 
Types.StringType.get())),
+            Sets.newHashSet(1));
+
+    // an identifier field must be primitive, so replacing it with a struct is 
rejected
+    assertThatThrownBy(
+            () ->
+                TypeUtil.replaceFieldTypes(
+                    schema,
+                    ImmutableMap.of(1, Types.StructType.of(required(3, "x", 
IntegerType.get())))))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessageContaining("not a primitive type field");
+  }
+
+  @Test
+  public void testReplaceFieldTypesNoMatchReturnsSameSchema() {
+    Schema schema = new Schema(required(1, "id", IntegerType.get()));
+    Schema result = TypeUtil.replaceFieldTypes(schema, ImmutableMap.of(99, 
Types.LongType.get()));
+    assertThat(result).isSameAs(schema);
+  }
 }
diff --git a/core/src/main/java/org/apache/iceberg/Partitioning.java 
b/core/src/main/java/org/apache/iceberg/Partitioning.java
index c708d39f52..66a681f884 100644
--- a/core/src/main/java/org/apache/iceberg/Partitioning.java
+++ b/core/src/main/java/org/apache/iceberg/Partitioning.java
@@ -243,6 +243,17 @@ public class Partitioning {
         "table partition", specs, allActiveFieldIds(table.schema(), specs));
   }
 
+  /**
+   * Builds a unified partition type containing all partition fields from the 
given specs, including
+   * fields whose source columns are no longer present in the table schema.
+   *
+   * @param specs the partition specs to unify
+   * @return the constructed unified partition type
+   */
+  static StructType unionPartitionTypes(Collection<PartitionSpec> specs) {
+    return buildPartitionProjectionType("union partition", specs, 
allFieldIds(specs));
+  }
+
   /**
    * Checks if any of the specs in a table is partitioned.
    *
@@ -347,6 +358,14 @@ public class Partitioning {
         || t2.equals(Transforms.alwaysNull());
   }
 
+  // collects IDs of all partition fields used across specs
+  private static Set<Integer> allFieldIds(Collection<PartitionSpec> specs) {
+    return FluentIterable.from(specs)
+        .transformAndConcat(PartitionSpec::fields)
+        .transform(PartitionField::fieldId)
+        .toSet();
+  }
+
   // collects IDs of all partition field used across specs that are in the 
current schema
   private static Set<Integer> allActiveFieldIds(Schema schema, 
Collection<PartitionSpec> specs) {
     return FluentIterable.from(specs)
diff --git a/core/src/main/java/org/apache/iceberg/TrackedFile.java 
b/core/src/main/java/org/apache/iceberg/TrackedFile.java
index 9aaeae48d6..1a457fb048 100644
--- a/core/src/main/java/org/apache/iceberg/TrackedFile.java
+++ b/core/src/main/java/org/apache/iceberg/TrackedFile.java
@@ -22,6 +22,7 @@ import java.nio.ByteBuffer;
 import java.util.Collections;
 import java.util.List;
 import java.util.Set;
+import org.apache.iceberg.types.Type;
 import org.apache.iceberg.types.Types;
 
 /** A file tracked by a manifest. */
@@ -95,9 +96,14 @@ interface TrackedFile {
           Types.ListType.ofRequired(136, Types.IntegerType.get()),
           "Field ids used to determine row equality in equality delete files");
 
-  static Types.StructType schemaWithContentStats(
-      Types.StructType partitionType, Types.StructType contentStatsType) {
-    return Types.StructType.of(
+  /**
+   * Returns the schema for the given partition and content stats types.
+   *
+   * <p>The partition and content stats fields use {@link Types.UnknownType} 
when their types have
+   * no fields, so that they are not stored in manifest files.
+   */
+  static Schema schema(Types.StructType partitionType, Types.StructType 
contentStatsType) {
+    return new Schema(
         TRACKING,
         CONTENT_TYPE,
         FORMAT_VERSION,
@@ -106,9 +112,13 @@ interface TrackedFile {
         RECORD_COUNT,
         FILE_SIZE_IN_BYTES,
         SPEC_ID,
-        Types.NestedField.optional(PARTITION_ID, PARTITION_NAME, 
partitionType, PARTITION_DOC),
         Types.NestedField.optional(
-            CONTENT_STATS_ID, CONTENT_STATS_NAME, contentStatsType, 
CONTENT_STATS_DOC),
+            PARTITION_ID, PARTITION_NAME, typeOrUnknown(partitionType), 
PARTITION_DOC),
+        Types.NestedField.optional(
+            CONTENT_STATS_ID,
+            CONTENT_STATS_NAME,
+            typeOrUnknown(contentStatsType),
+            CONTENT_STATS_DOC),
         SORT_ORDER_ID,
         DELETION_VECTOR,
         MANIFEST_INFO,
@@ -117,6 +127,10 @@ interface TrackedFile {
         EQUALITY_IDS);
   }
 
+  private static Type typeOrUnknown(Types.StructType structType) {
+    return structType.fields().isEmpty() ? Types.UnknownType.get() : 
structType;
+  }
+
   /** Returns the tracking information for this entry. */
   Tracking tracking();
 
diff --git a/core/src/main/java/org/apache/iceberg/TrackedFileStruct.java 
b/core/src/main/java/org/apache/iceberg/TrackedFileStruct.java
index 3ed764ff17..f5ce03c7eb 100644
--- a/core/src/main/java/org/apache/iceberg/TrackedFileStruct.java
+++ b/core/src/main/java/org/apache/iceberg/TrackedFileStruct.java
@@ -83,10 +83,11 @@ class TrackedFileStruct extends SupportsIndexProjection 
implements TrackedFile,
   /** Used by internal readers to instantiate this class with a projection 
schema. */
   TrackedFileStruct(Types.StructType projection) {
     super(BASE_TYPE, projection);
-    // partition type may be null if the field was not projected
+    // partition type may be null if the field was not projected, or unknown 
for unpartitioned
+    // manifests
     Type partType = projection.fieldType("partition");
-    if (partType != null) {
-      this.partitionData = new 
PartitionData(partType.asNestedType().asStructType());
+    if (partType != null && partType.isStructType()) {
+      this.partitionData = new PartitionData(partType.asStructType());
     }
   }
 
@@ -101,10 +102,10 @@ class TrackedFileStruct extends SupportsIndexProjection 
implements TrackedFile,
       int formatVersion,
       String location,
       FileFormat fileFormat,
-      PartitionData partition,
       long recordCount,
       long fileSizeInBytes,
       Integer specId,
+      PartitionData partition,
       ContentStats contentStats,
       Integer sortOrderId,
       DeletionVector deletionVector,
@@ -120,9 +121,8 @@ class TrackedFileStruct extends SupportsIndexProjection 
implements TrackedFile,
     this.fileFormat = fileFormat;
     this.recordCount = recordCount;
     this.fileSizeInBytes = fileSizeInBytes;
-    this.partitionData = partition;
-
     this.specId = specId;
+    this.partitionData = partition;
     this.contentStats = contentStats;
     this.sortOrderId = sortOrderId;
     this.deletionVector = deletionVector;
diff --git a/core/src/main/java/org/apache/iceberg/TrackingStruct.java 
b/core/src/main/java/org/apache/iceberg/TrackingStruct.java
index 8ae4b7e4ce..283e0a51ff 100644
--- a/core/src/main/java/org/apache/iceberg/TrackingStruct.java
+++ b/core/src/main/java/org/apache/iceberg/TrackingStruct.java
@@ -30,7 +30,8 @@ import org.apache.iceberg.util.ByteBuffers;
 
 /** Mutable {@link StructLike} implementation of {@link Tracking}. */
 class TrackingStruct extends SupportsIndexProjection implements Tracking, 
Serializable {
-  private static final Types.StructType BASE_TYPE =
+  // Package-private only for read projection.
+  static final Types.StructType BASE_TYPE =
       Types.StructType.of(
           Tracking.STATUS,
           Tracking.SNAPSHOT_ID,
@@ -42,6 +43,17 @@ class TrackingStruct extends SupportsIndexProjection 
implements Tracking, Serial
           Tracking.REPLACED_POSITIONS,
           MetadataColumns.ROW_POSITION);
 
+  // tracking fields read on the scan path; row_position backs manifestPos.
+  // Package-private only for read projection.
+  static final Types.StructType SCAN_TYPE =
+      Types.StructType.of(
+          Tracking.STATUS,
+          Tracking.SNAPSHOT_ID,
+          Tracking.SEQUENCE_NUMBER,
+          Tracking.FILE_SEQUENCE_NUMBER,
+          Tracking.FIRST_ROW_ID,
+          MetadataColumns.ROW_POSITION);
+
   private EntryStatus status = null;
   private Long snapshotId = null;
   private Long dataSequenceNumber = null;
diff --git a/core/src/main/java/org/apache/iceberg/V4ManifestReader.java 
b/core/src/main/java/org/apache/iceberg/V4ManifestReader.java
new file mode 100644
index 0000000000..a823454845
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/V4ManifestReader.java
@@ -0,0 +1,300 @@
+/*
+ * 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.util.Arrays;
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+import org.apache.iceberg.expressions.Evaluator;
+import org.apache.iceberg.expressions.Expression;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.expressions.Projections;
+import org.apache.iceberg.io.CloseableGroup;
+import org.apache.iceberg.io.CloseableIterable;
+import org.apache.iceberg.io.CloseableIterator;
+import org.apache.iceberg.io.InputFile;
+import org.apache.iceberg.metrics.ScanMetrics;
+import org.apache.iceberg.relocated.com.google.common.base.Preconditions;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableMap;
+import org.apache.iceberg.relocated.com.google.common.collect.Maps;
+import org.apache.iceberg.relocated.com.google.common.collect.Sets;
+import org.apache.iceberg.types.TypeUtil;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.util.Pair;
+import org.apache.iceberg.util.StructProjection;
+
+/** Reader that reads a v4+ manifest file as {@link TrackedFile}s. */
+class V4ManifestReader extends CloseableGroup implements 
CloseableIterable<TrackedFile> {
+  private final InputFile file;
+  private final Schema readSchema;
+  private final boolean includeAll;
+  private final ScanMetrics scanMetrics;
+
+  // partition filters keyed by spec ID; empty when no partition filter applies
+  private final Map<Integer, Pair<Evaluator, StructProjection>> 
partitionFilters;
+
+  private V4ManifestReader(
+      InputFile file,
+      Schema readSchema,
+      Map<Integer, Pair<Evaluator, StructProjection>> partitionFilters,
+      boolean includeAll,
+      ScanMetrics scanMetrics) {
+    this.file = file;
+    this.readSchema = readSchema;
+    this.partitionFilters = partitionFilters;
+    this.includeAll = includeAll;
+    this.scanMetrics = scanMetrics;
+  }
+
+  static Builder builder(InputFile file, Map<Integer, PartitionSpec> 
specsById) {
+    return new Builder(file, specsById);
+  }
+
+  /** Returns copies of the tracked files that match this reader's configured 
filters. */
+  @Override
+  public CloseableIterator<TrackedFile> iterator() {
+    CloseableIterable<TrackedFile> entries = 
CloseableIterable.transform(open(), this::prepare);
+    if (!partitionFilters.isEmpty()) {
+      // manifests have no partition, so the partition filter cannot apply to 
them
+      entries =
+          CloseableIterable.filter(entries, entry -> isManifest(entry) || 
matchesPartition(entry));
+    }
+
+    if (!includeAll) {
+      entries = CloseableIterable.filter(entries, entry -> 
entry.tracking().isLive());
+    }
+
+    return CloseableIterable.transform(entries, TrackedFile::copy).iterator();
+  }
+
+  private boolean matchesPartition(TrackedFile trackedFile) {
+    Integer specId = trackedFile.specId();
+    if (specId == null) {
+      // a file without a spec is not partitioned and may match the filter
+      return true;
+    }
+
+    Pair<Evaluator, StructProjection> partitionFilter = 
partitionFilters.get(specId);
+    if (partitionFilter == null) {
+      // the row filter does not project to a partition filter for this spec
+      return true;
+    }
+
+    Evaluator evaluator = partitionFilter.first();
+    StructProjection projection = partitionFilter.second();
+    boolean matches = evaluator.eval(projection.wrap(trackedFile.partition()));
+    if (!matches) {
+      incrementSkipCount(trackedFile.contentType());
+    }
+
+    return matches;
+  }
+
+  private void incrementSkipCount(FileContent content) {
+    switch (content) {
+      case DATA -> scanMetrics.skippedDataFiles().increment();
+      case EQUALITY_DELETES -> scanMetrics.skippedDeleteFiles().increment();
+      case DATA_MANIFEST -> scanMetrics.skippedDataManifests().increment();
+      case DELETE_MANIFEST -> scanMetrics.skippedDeleteManifests().increment();
+      default -> throw new UnsupportedOperationException("Unsupported content 
type: " + content);
+    }
+  }
+
+  private CloseableIterable<TrackedFile> open() {
+    FileFormat format = FileFormat.fromFileName(file.location());
+    Preconditions.checkArgument(
+        format != null, "Cannot determine format of manifest: %s", 
file.location());
+
+    CloseableIterable<TrackedFile> reader =
+        InternalData.read(format, file)
+            .project(readSchema)
+            .setRootType(TrackedFileStruct.class)
+            .setCustomType(TrackedFile.TRACKING.fieldId(), 
TrackingStruct.class)
+            .setCustomType(TrackedFile.DELETION_VECTOR.fieldId(), 
DeletionVectorStruct.class)
+            .setCustomType(TrackedFile.MANIFEST_INFO.fieldId(), 
ManifestInfoStruct.class)
+            .setCustomType(TrackedFile.PARTITION_ID, PartitionData.class)
+            .reuseContainers()
+            .build();
+    addCloseable(reader);
+    return reader;
+  }
+
+  private TrackedFile prepare(TrackedFile trackedFile) {
+    Tracking tracking = trackedFile.tracking();
+    // manifestLocation is not stored in the manifest; the reader fills it in
+    if (tracking instanceof TrackingStruct) {
+      ((TrackingStruct) tracking).setManifestLocation(file.location());
+    }
+
+    return trackedFile;
+  }
+
+  private static boolean isManifest(TrackedFile trackedFile) {
+    FileContent content = trackedFile.contentType();
+    return content == FileContent.DATA_MANIFEST || content == 
FileContent.DELETE_MANIFEST;
+  }
+
+  static class Builder {
+    private final InputFile file;
+    private final Types.StructType unionPartitionType;
+    private final Map<Integer, PartitionSpec> specsById;
+    private final Schema fullSchema;
+    private Expression rowFilter = Expressions.alwaysTrue();
+    private boolean caseSensitive = true;
+    private boolean includeAll = false;
+    private boolean scanPlanning = false;
+    private Collection<String> columns = null;
+    private Schema requestedProjection = null;
+    private ScanMetrics scanMetrics = ScanMetrics.noop();
+
+    private Builder(InputFile file, Map<Integer, PartitionSpec> specsById) {
+      this.file = file;
+      this.specsById = specsById;
+      this.unionPartitionType = 
Partitioning.unionPartitionTypes(specsById.values());
+      Schema base = TrackedFile.schema(unionPartitionType, 
Types.StructType.of());
+      // the read schema carries row_position (via BASE_TYPE) so the reader 
can fill manifestPos
+      this.fullSchema =
+          TypeUtil.replaceFieldTypes(
+              base, ImmutableMap.of(TrackedFile.TRACKING.fieldId(), 
TrackingStruct.BASE_TYPE));
+    }
+
+    /** Sets a filter; files that cannot match the expression are skipped. */
+    Builder filter(Expression expr) {
+      Preconditions.checkArgument(expr != null, "Invalid filter: null");
+      this.rowFilter = expr;
+      return this;
+    }
+
+    Builder caseSensitive(boolean isCaseSensitive) {
+      this.caseSensitive = isCaseSensitive;
+      return this;
+    }
+
+    /** Returns all entries without filtering by {@link Tracking#isLive() 
liveness}. */
+    Builder includeAll() {
+      this.includeAll = true;
+      return this;
+    }
+
+    /** Configures the reader to select the minimal fields needed for scan 
planning. */
+    Builder forScanPlanning() {
+      Preconditions.checkState(
+          columns == null && requestedProjection == null,
+          "Cannot use forScanPlanning() with select(Collection<String>) or 
project(Schema)");
+      this.scanPlanning = true;
+      return this;
+    }
+
+    /** Selects columns to read by name; fields needed by the reader are 
always read. */
+    Builder select(String... newColumns) {
+      return select(Arrays.asList(newColumns));
+    }
+
+    /** Selects columns to read by name; fields needed by the reader are 
always read. */
+    Builder select(Collection<String> newColumns) {
+      Preconditions.checkArgument(newColumns != null, "Invalid columns: null");
+      Preconditions.checkState(
+          !scanPlanning, "Cannot use select(Collection<String>) with 
forScanPlanning()");
+      Preconditions.checkState(
+          requestedProjection == null,
+          "Cannot use select(Collection<String>) with project(Schema)");
+      this.columns = newColumns;
+      return this;
+    }
+
+    /** Sets the exact schema to read; used in place of {@link 
#select(Collection)}. */
+    Builder project(Schema newProjection) {
+      Preconditions.checkState(!scanPlanning, "Cannot use project(Schema) with 
forScanPlanning()");
+      Preconditions.checkState(
+          columns == null, "Cannot use project(Schema) with 
select(Collection<String>)");
+      this.requestedProjection = newProjection;
+      return this;
+    }
+
+    Builder scanMetrics(ScanMetrics newScanMetrics) {
+      Preconditions.checkArgument(newScanMetrics != null, "Invalid scan 
metrics: null");
+      this.scanMetrics = newScanMetrics;
+      return this;
+    }
+
+    V4ManifestReader build() {
+      Map<Integer, Pair<Evaluator, StructProjection>> partitionFilters = 
Maps.newHashMap();
+      if (rowFilter != Expressions.alwaysTrue() && 
!unionPartitionType.fields().isEmpty()) {
+        for (PartitionSpec spec : specsById.values()) {
+          Expression partFilter = Projections.inclusive(spec, 
caseSensitive).project(rowFilter);
+          if (partFilter != Expressions.alwaysTrue()) {
+            Evaluator evaluator = new Evaluator(spec.partitionType(), 
partFilter, caseSensitive);
+            StructProjection projection =
+                StructProjection.create(unionPartitionType, 
spec.partitionType());
+            partitionFilters.put(spec.specId(), Pair.of(evaluator, 
projection));
+          }
+        }
+      }
+
+      boolean hasPartitionFilter = !partitionFilters.isEmpty();
+      return new V4ManifestReader(
+          file, readSchema(hasPartitionFilter), partitionFilters, includeAll, 
scanMetrics);
+    }
+
+    private Schema readSchema(boolean hasPartitionFilter) {
+      if (scanPlanning) {
+        // scan planning does not read the change-tracking fields omitted by 
SCAN_TYPE
+        return TypeUtil.replaceFieldTypes(
+            fullSchema, ImmutableMap.of(TrackedFile.TRACKING.fieldId(), 
TrackingStruct.SCAN_TYPE));
+      }
+
+      if (columns != null) {
+        Schema selected =
+            caseSensitive ? fullSchema.select(columns) : 
fullSchema.caseInsensitiveSelect(columns);
+        return addRequiredColumns(selected, hasPartitionFilter);
+      }
+
+      if (requestedProjection != null) {
+        return addRequiredColumns(requestedProjection, hasPartitionFilter);
+      }
+
+      return fullSchema;
+    }
+
+    private Schema addRequiredColumns(Schema projection, boolean 
hasPartitionFilter) {
+      Set<Integer> projectedIds = 
Sets.newHashSet(TypeUtil.getProjectedIds(projection));
+
+      // fields the reader consumes internally: status for liveness filtering, 
row_position for
+      // manifestPos, and content type to distinguish entry kinds
+      projectedIds.add(Tracking.STATUS.fieldId());
+      projectedIds.add(MetadataColumns.ROW_POSITION.fieldId());
+      projectedIds.add(TrackedFile.CONTENT_TYPE.fieldId());
+      if (rowFilter != Expressions.alwaysTrue()) {
+        // record_count is read when evaluating a filter against file metrics
+        projectedIds.add(TrackedFile.RECORD_COUNT.fieldId());
+      }
+
+      // add the partition tuple only when it is needed to evaluate a 
partition filter
+      if (hasPartitionFilter) {
+        projectedIds.add(TrackedFile.SPEC_ID.fieldId());
+        projectedIds.add(TrackedFile.PARTITION_ID);
+        projectedIds.addAll(TypeUtil.getProjectedIds(unionPartitionType));
+      }
+
+      // project instead of select to preserve narrow struct projections from 
the caller
+      return TypeUtil.project(fullSchema, projectedIds);
+    }
+  }
+}
diff --git a/core/src/test/java/org/apache/iceberg/TestPartitioning.java 
b/core/src/test/java/org/apache/iceberg/TestPartitioning.java
index eaabf5d3ed..4c98d66c10 100644
--- a/core/src/test/java/org/apache/iceberg/TestPartitioning.java
+++ b/core/src/test/java/org/apache/iceberg/TestPartitioning.java
@@ -211,6 +211,27 @@ public class TestPartitioning {
     assertThat(actualType).isEqualTo(StructType.of());
   }
 
+  @Test
+  public void testUnionPartitionTypesRetainsDroppedSourceFields() {
+    TestTables.TestTable table =
+        TestTables.create(
+            tableDir, "test", SCHEMA, BY_DATA_CATEGORY_BUCKET_SPEC, 
V2_FORMAT_VERSION);
+
+    table.updateSpec().removeField("category_bucket").commit();
+    table.updateSchema().deleteColumn("category").commit();
+    table.updateSpec().removeField("data").commit();
+    table.updateSchema().deleteColumn("data").commit();
+
+    // fields with dropped source columns are retained to preserve partition 
tuple equality;
+    // bucket keeps its fixed result type while the identity result type falls 
back to unknown
+    StructType actualType = 
Partitioning.unionPartitionTypes(table.specs().values());
+    assertThat(actualType)
+        .isEqualTo(
+            StructType.of(
+                NestedField.optional(1000, "data", Types.UnknownType.get()),
+                NestedField.optional(1001, "category_bucket", 
Types.IntegerType.get())));
+  }
+
   @Test
   public void testPartitionTypeWithDroppedSourceColumn() {
     TestTables.TestTable table =
diff --git a/core/src/test/java/org/apache/iceberg/TestTrackedFile.java 
b/core/src/test/java/org/apache/iceberg/TestTrackedFile.java
index 5840a1cd5f..9a32243cc1 100644
--- a/core/src/test/java/org/apache/iceberg/TestTrackedFile.java
+++ b/core/src/test/java/org/apache/iceberg/TestTrackedFile.java
@@ -39,8 +39,8 @@ public class TestTrackedFile {
       
PartitionSpec.builderFor(TABLE_SCHEMA).identity("id").build().partitionType();
 
   @Test
-  public void schemaWithContentStatsFieldOrder() {
-    Types.StructType type = TrackedFile.schemaWithContentStats(PARTITION_TYPE, 
CONTENT_STATS_TYPE);
+  public void schemaFieldOrder() {
+    Types.StructType type = TrackedFile.schema(PARTITION_TYPE, 
CONTENT_STATS_TYPE).asStruct();
     List<Types.NestedField> fields = type.fields();
 
     assertThat(fields)
@@ -65,8 +65,8 @@ public class TestTrackedFile {
   }
 
   @Test
-  public void schemaWithContentStatsFieldIds() {
-    Types.StructType type = TrackedFile.schemaWithContentStats(PARTITION_TYPE, 
CONTENT_STATS_TYPE);
+  public void schemaFieldIds() {
+    Types.StructType type = TrackedFile.schema(PARTITION_TYPE, 
CONTENT_STATS_TYPE).asStruct();
     List<Types.NestedField> fields = type.fields();
 
     assertThat(fields)
@@ -76,8 +76,8 @@ public class TestTrackedFile {
   }
 
   @Test
-  public void schemaWithContentStatsUsesProvidedType() {
-    Types.StructType type = TrackedFile.schemaWithContentStats(PARTITION_TYPE, 
CONTENT_STATS_TYPE);
+  public void schemaUsesProvidedType() {
+    Types.StructType type = TrackedFile.schema(PARTITION_TYPE, 
CONTENT_STATS_TYPE).asStruct();
     Types.NestedField contentStatsField = 
type.field(TrackedFile.CONTENT_STATS_ID);
     Types.NestedField partitionField = type.field(TrackedFile.PARTITION_ID);
 
@@ -86,7 +86,7 @@ public class TestTrackedFile {
   }
 
   @Test
-  public void schemaWithContentStatsReflectsInput() {
+  public void schemaReflectsInput() {
     Schema smallSchema = new Schema(optional(1, "id", 
Types.IntegerType.get()));
     Schema largeSchema =
         new Schema(
@@ -97,8 +97,8 @@ public class TestTrackedFile {
     Types.StructType smallStats = StatsUtil.statsReadSchema(smallSchema, 
ImmutableList.of(1));
     Types.StructType largeStats = StatsUtil.statsReadSchema(largeSchema, 
ImmutableList.of(1, 3));
 
-    Types.StructType smallType = 
TrackedFile.schemaWithContentStats(PARTITION_TYPE, smallStats);
-    Types.StructType largeType = 
TrackedFile.schemaWithContentStats(PARTITION_TYPE, largeStats);
+    Types.StructType smallType = TrackedFile.schema(PARTITION_TYPE, 
smallStats).asStruct();
+    Types.StructType largeType = TrackedFile.schema(PARTITION_TYPE, 
largeStats).asStruct();
 
     Types.StructType smallResult =
         smallType.field(TrackedFile.CONTENT_STATS_ID).type().asStructType();
@@ -110,12 +110,27 @@ public class TestTrackedFile {
   }
 
   @Test
-  public void schemaWithContentStatsPartitionIsOptional() {
-    Types.StructType type = TrackedFile.schemaWithContentStats(PARTITION_TYPE, 
CONTENT_STATS_TYPE);
+  public void schemaPartitionIsOptional() {
+    Types.StructType type = TrackedFile.schema(PARTITION_TYPE, 
CONTENT_STATS_TYPE).asStruct();
     Types.NestedField partitionField = type.field(TrackedFile.PARTITION_ID);
 
     assertThat(partitionField.isOptional()).isTrue();
     assertThat(partitionField.name()).isEqualTo(TrackedFile.PARTITION_NAME);
     assertThat(partitionField.doc()).isEqualTo(TrackedFile.PARTITION_DOC);
   }
+
+  @Test
+  public void schemaUsesUnknownForEmptyStructs() {
+    Types.StructType type =
+        TrackedFile.schema(Types.StructType.of(), 
Types.StructType.of()).asStruct();
+
+    
assertThat(type.field(TrackedFile.PARTITION_ID).type()).isEqualTo(Types.UnknownType.get());
+    
assertThat(type.field(TrackedFile.CONTENT_STATS_ID).type()).isEqualTo(Types.UnknownType.get());
+
+    Types.StructType partitionedType =
+        TrackedFile.schema(PARTITION_TYPE, Types.StructType.of()).asStruct();
+    
assertThat(partitionedType.field(TrackedFile.PARTITION_ID).type()).isEqualTo(PARTITION_TYPE);
+    assertThat(partitionedType.field(TrackedFile.CONTENT_STATS_ID).type())
+        .isEqualTo(Types.UnknownType.get());
+  }
 }
diff --git a/core/src/test/java/org/apache/iceberg/TestTrackedFileAdapters.java 
b/core/src/test/java/org/apache/iceberg/TestTrackedFileAdapters.java
index 912f36d860..1992390506 100644
--- a/core/src/test/java/org/apache/iceberg/TestTrackedFileAdapters.java
+++ b/core/src/test/java/org/apache/iceberg/TestTrackedFileAdapters.java
@@ -103,10 +103,10 @@ class TestTrackedFileAdapters {
             FORMAT_VERSION_V4,
             DATA_FILE_LOCATION,
             FileFormat.PARQUET,
-            PARTITION,
             100L,
             1024L,
             PARTITIONED_SPEC_ID,
+            PARTITION,
             CONTENT_STATS,
             3,
             null,
@@ -179,10 +179,10 @@ class TestTrackedFileAdapters {
             FORMAT_VERSION_V4,
             "s3://bucket/eq-delete.avro",
             FileFormat.AVRO,
-            PARTITION,
             50L,
             512L,
             PARTITIONED_SPEC_ID,
+            PARTITION,
             CONTENT_STATS,
             5,
             null,
@@ -264,10 +264,10 @@ class TestTrackedFileAdapters {
             FORMAT_VERSION_V4,
             DATA_FILE_LOCATION,
             FileFormat.PARQUET,
-            PARTITION,
             100L,
             1024L,
             PARTITIONED_SPEC_ID,
+            PARTITION,
             null,
             null,
             dv,
@@ -361,12 +361,12 @@ class TestTrackedFileAdapters {
             0,
             null,
             null,
-            null,
             0L,
             0L,
             null,
             null,
             null,
+            null,
             deletionVector(),
             null,
             null,
@@ -415,7 +415,6 @@ class TestTrackedFileAdapters {
             0,
             null,
             null,
-            null,
             0L,
             0L,
             99,
@@ -425,6 +424,7 @@ class TestTrackedFileAdapters {
             null,
             null,
             null,
+            null,
             null);
 
     assertThatThrownBy(() -> TrackedFileAdapters.asDataFile(file, 
ImmutableMap.of()))
@@ -441,7 +441,6 @@ class TestTrackedFileAdapters {
             0,
             null,
             null,
-            null,
             0L,
             0L,
             PARTITIONED_SPEC_ID,
@@ -451,6 +450,7 @@ class TestTrackedFileAdapters {
             null,
             null,
             null,
+            null,
             null);
     int mismatchedSpecId = PARTITIONED_SPEC_ID + 1;
     PartitionSpec mismatched =
@@ -496,7 +496,6 @@ class TestTrackedFileAdapters {
         FORMAT_VERSION_V4,
         DATA_FILE_LOCATION,
         FileFormat.PARQUET,
-        null,
         1L,
         1L,
         null,
@@ -506,6 +505,7 @@ class TestTrackedFileAdapters {
         null,
         null,
         null,
+        null,
         null);
   }
 
diff --git a/core/src/test/java/org/apache/iceberg/TestTrackedFileStruct.java 
b/core/src/test/java/org/apache/iceberg/TestTrackedFileStruct.java
index d888c6a7dc..14265c91f6 100644
--- a/core/src/test/java/org/apache/iceberg/TestTrackedFileStruct.java
+++ b/core/src/test/java/org/apache/iceberg/TestTrackedFileStruct.java
@@ -33,13 +33,9 @@ import org.mockito.Mockito;
 
 class TestTrackedFileStruct {
   private static final int FORMAT_VERSION_V4 = 4;
-  private static final Types.StructType PARTITION_TYPE =
-      Types.StructType.of(
-          Types.NestedField.optional(1000, "id_bucket", 
Types.IntegerType.get()),
-          Types.NestedField.optional(1001, "category", 
Types.StringType.get()));
 
-  private static final List<Types.NestedField> FIELDS =
-      TrackedFile.schemaWithContentStats(PARTITION_TYPE, 
Types.StructType.of()).fields();
+  private static final List<Types.NestedField> DEFAULT_FIELDS =
+      TrackedFile.schema(Types.StructType.of(), 
Types.StructType.of()).asStruct().fields();
 
   private static final Tracking TRACKING = Mockito.mock(Tracking.class);
   private static final Tracking TRACKING_COPY = Mockito.mock(Tracking.class);
@@ -73,10 +69,10 @@ class TestTrackedFileStruct {
             FORMAT_VERSION_V4,
             "s3://bucket/data/00000-0-file.parquet",
             FileFormat.PARQUET,
-            PARTITION,
             50L,
             512L,
             1,
+            PARTITION,
             CONTENT_STATS,
             5,
             DELETION_VECTOR,
@@ -150,10 +146,10 @@ class TestTrackedFileStruct {
             FORMAT_VERSION_V4,
             "s3://bucket/data/00000-0-file.parquet",
             FileFormat.PARQUET,
-            PARTITION,
             50L,
             512L,
             1,
+            PARTITION,
             CONTENT_STATS,
             5,
             DELETION_VECTOR,
@@ -191,10 +187,10 @@ class TestTrackedFileStruct {
             FORMAT_VERSION_V4,
             "s3://bucket/data/00000-0-file.parquet",
             FileFormat.PARQUET,
-            PARTITION,
             50L,
             512L,
             1,
+            PARTITION,
             CONTENT_STATS,
             5,
             DELETION_VECTOR,
@@ -240,10 +236,10 @@ class TestTrackedFileStruct {
             FORMAT_VERSION_V4,
             "s3://bucket/data/00000-0-file.parquet",
             FileFormat.PARQUET,
-            PARTITION,
             50L,
             512L,
             1,
+            PARTITION,
             stats,
             5,
             DELETION_VECTOR,
@@ -287,10 +283,10 @@ class TestTrackedFileStruct {
             FORMAT_VERSION_V4,
             "s3://bucket/data/00000-0-file.parquet",
             FileFormat.PARQUET,
-            PARTITION,
             50L,
             512L,
             1,
+            PARTITION,
             stats,
             5,
             DELETION_VECTOR,
@@ -347,7 +343,7 @@ class TestTrackedFileStruct {
   @Test
   void structLikeSize() {
     TrackedFileStruct file = new TrackedFileStruct();
-    assertThat(file.size()).isEqualTo(FIELDS.size());
+    assertThat(file.size()).isEqualTo(DEFAULT_FIELDS.size());
   }
 
   @ParameterizedTest
@@ -360,10 +356,10 @@ class TestTrackedFileStruct {
             FORMAT_VERSION_V4,
             "s3://bucket/data/file.parquet",
             FileFormat.PARQUET,
-            null, // PartitionData has its own serialization tests
             100L,
             1024L,
             7,
+            null, // PartitionData has its own serialization tests
             null,
             1,
             null, // DeletionVector has its own serialization tests
@@ -392,8 +388,8 @@ class TestTrackedFileStruct {
   }
 
   private static int pos(String fieldName) {
-    for (int i = 0; i < FIELDS.size(); i += 1) {
-      if (FIELDS.get(i).name().equals(fieldName)) {
+    for (int i = 0; i < DEFAULT_FIELDS.size(); i += 1) {
+      if (DEFAULT_FIELDS.get(i).name().equals(fieldName)) {
         return i;
       }
     }
diff --git a/core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java 
b/core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java
new file mode 100644
index 0000000000..c8a5cfd61a
--- /dev/null
+++ b/core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java
@@ -0,0 +1,887 @@
+/*
+ * 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.types.Types.NestedField.optional;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.nio.file.Path;
+import java.util.Collection;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.function.Consumer;
+import java.util.stream.Stream;
+import org.apache.iceberg.exceptions.ValidationException;
+import org.apache.iceberg.expressions.Expressions;
+import org.apache.iceberg.inmemory.InMemoryOutputFile;
+import org.apache.iceberg.io.FileAppender;
+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.transforms.Transforms;
+import org.apache.iceberg.types.Comparators;
+import org.apache.iceberg.types.TypeUtil;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Named;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.FieldSource;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class TestV4ManifestReader {
+  private static final long SNAPSHOT_ID = 42L;
+  private static final int FORMAT_VERSION_V4 = 4;
+  private static final long RECORD_COUNT = 100L;
+  private static final long FILE_SIZE_IN_BYTES = 1024L;
+  private static final DeletionVector DV =
+      DeletionVectorStruct.builder()
+          .location("s3://bucket/dv.puffin")
+          .offset(100L)
+          .sizeInBytes(50L)
+          .cardinality(5L)
+          .build();
+
+  private static final Schema TABLE_SCHEMA =
+      new Schema(
+          optional(1, "id", Types.IntegerType.get()), optional(2, "data", 
Types.StringType.get()));
+  private static final PartitionSpec ID_PARTITIONING =
+      PartitionSpec.builderFor(TABLE_SCHEMA).identity("id").build();
+  private static final Types.StructType ID_PARTITION_TYPE = 
ID_PARTITIONING.partitionType();
+  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> ID_PARTITIONING_SPECS =
+      ImmutableMap.of(ID_PARTITIONING.specId(), ID_PARTITIONING);
+  private static final Map<Integer, PartitionSpec> UNPARTITIONED_SPECS =
+      ImmutableMap.of(PartitionSpec.unpartitioned().specId(), 
PartitionSpec.unpartitioned());
+
+  private static final List<FileFormat> MANIFEST_FORMATS =
+      ImmutableList.of(FileFormat.AVRO, FileFormat.PARQUET);
+
+  // a data file whose tracking carries every inheritable and change-tracking 
value set
+  private static final TrackedFile FILE_WITH_FULL_TRACKING =
+      new TrackedFileStruct(
+          new TrackingStruct(
+              EntryStatus.ADDED,
+              SNAPSHOT_ID,
+              5L, // data sequence number
+              6L, // file sequence number
+              7L, // dv snapshot id
+              8L, // first row id
+              new byte[] {1, 2}, // deleted positions
+              new byte[] {3, 4}), // replaced positions
+          FileContent.DATA,
+          FORMAT_VERSION_V4,
+          "s3://bucket/file.parquet",
+          FileFormat.PARQUET,
+          RECORD_COUNT,
+          FILE_SIZE_IN_BYTES,
+          0,
+          EMPTY_PARTITION_DATA,
+          null,
+          null,
+          null,
+          null,
+          null,
+          null,
+          null);
+
+  // shared data files: FILE_A is in partition id=1, FILE_B in partition id=2
+  private static final TrackedFile FILE_A = dataFile("data-a.parquet", 
partition(1));
+  private static final TrackedFile FILE_B = dataFile("data-b.parquet", 
partition(2));
+  private static final TrackedFile EQ_DELETES_A = 
deleteFile("eq-deletes-a.parquet", partition(1));
+  private static final TrackedFile EQ_DELETES_B = 
deleteFile("eq-deletes-b.parquet", partition(2));
+  private static final TrackedFile DATA_MANIFEST_REF =
+      manifestRef(FileContent.DATA_MANIFEST, "data-leaf.parquet");
+  private static final TrackedFile DELETE_MANIFEST_REF =
+      manifestRef(FileContent.DELETE_MANIFEST, "delete-leaf.parquet");
+
+  @TempDir private Path tempDir;
+
+  private final FileIO fileIO = new TestTables.LocalFileIO();
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void readsWrittenFile(FileFormat format) throws IOException {
+    TrackedFile file =
+        new TrackedFileStruct(
+            addedTracking(),
+            FileContent.DATA,
+            FORMAT_VERSION_V4,
+            "s3://bucket/data/file.parquet",
+            FileFormat.PARQUET,
+            RECORD_COUNT,
+            FILE_SIZE_IN_BYTES,
+            ID_PARTITIONING.specId(),
+            partition(7),
+            null,
+            1, // sort order id
+            DV,
+            null,
+            ByteBuffer.wrap(new byte[] {1, 2, 3}),
+            ImmutableList.of(50L, 100L),
+            null);
+
+    InputFile manifest = writeManifest(format, ID_PARTITION_TYPE, 
ImmutableList.of(file));
+
+    TrackedFile actual = Iterables.getOnlyElement(read(manifest, 
ID_PARTITIONING_SPECS));
+
+    // compare with tracking reduced to status: the reader fills 
status-independent tracking
+    // fields (row position, sequence numbers via inheritance) that the 
written file does not have
+    Types.StructType comparisonType =
+        TypeUtil.replaceFieldTypes(
+                TrackedFile.schema(ID_PARTITION_TYPE, Types.StructType.of()),
+                ImmutableMap.of(
+                    TrackedFile.TRACKING.fieldId(), 
Types.StructType.of(Tracking.STATUS)))
+            .asStruct();
+    assertThat((StructLike) actual)
+        .usingComparator(Comparators.forType(comparisonType))
+        .isEqualTo(file);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void equalityDeleteRoundTrip(FileFormat format) throws IOException {
+    TrackedFile delete =
+        new TrackedFileStruct(
+            addedTracking(),
+            FileContent.EQUALITY_DELETES,
+            FORMAT_VERSION_V4,
+            "s3://bucket/eq-delete.parquet",
+            FileFormat.PARQUET,
+            RECORD_COUNT,
+            FILE_SIZE_IN_BYTES,
+            0,
+            EMPTY_PARTITION_DATA,
+            null,
+            null,
+            null,
+            null,
+            null,
+            null,
+            ImmutableList.of(1, 2));
+
+    InputFile manifest = writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(delete));
+
+    TrackedFile actual = Iterables.getOnlyElement(read(manifest, 
UNPARTITIONED_SPECS));
+    assertThat(actual.contentType()).isEqualTo(FileContent.EQUALITY_DELETES);
+    assertThat(actual.equalityIds()).containsExactly(1, 2);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void statusFiltering(FileFormat format) throws IOException {
+    List<TrackedFile> files =
+        ImmutableList.of(
+            fileWithStatus(EntryStatus.ADDED, "s3://bucket/added.parquet"),
+            fileWithStatus(EntryStatus.EXISTING, 
"s3://bucket/existing.parquet"),
+            fileWithStatus(EntryStatus.MODIFIED, 
"s3://bucket/modified.parquet"),
+            fileWithStatus(EntryStatus.DELETED, "s3://bucket/deleted.parquet"),
+            fileWithStatus(EntryStatus.REPLACED, 
"s3://bucket/replaced.parquet"));
+
+    InputFile manifest = writeManifest(format, EMPTY_PARTITION, files);
+
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, UNPARTITIONED_SPECS).build()) {
+      assertThat(reader)
+          .extracting(file -> file.tracking().status())
+          .containsExactly(EntryStatus.ADDED, EntryStatus.EXISTING, 
EntryStatus.MODIFIED);
+    }
+
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, 
UNPARTITIONED_SPECS).includeAll().build()) {
+      assertThat(reader)
+          .extracting(file -> file.tracking().status())
+          .containsExactly(
+              EntryStatus.ADDED,
+              EntryStatus.EXISTING,
+              EntryStatus.MODIFIED,
+              EntryStatus.DELETED,
+              EntryStatus.REPLACED);
+    }
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void manifestLocationAndPosition(FileFormat format) throws 
IOException {
+    List<TrackedFile> files =
+        ImmutableList.of(
+            dataFile("s3://bucket/a.parquet", EMPTY_PARTITION_DATA),
+            dataFile("s3://bucket/b.parquet", EMPTY_PARTITION_DATA),
+            dataFile("s3://bucket/c.parquet", EMPTY_PARTITION_DATA));
+
+    InputFile manifest = writeManifest(format, EMPTY_PARTITION, files);
+
+    List<TrackedFile> read = read(manifest, UNPARTITIONED_SPECS);
+    assertThat(read)
+        .allSatisfy(
+            file -> 
assertThat(file.tracking().manifestLocation()).isEqualTo(manifest.location()));
+    assertThat(read).extracting(file -> 
file.tracking().manifestPos()).containsExactly(0L, 1L, 2L);
+  }
+
+  @ParameterizedTest(name = "{0} / {1}")
+  @MethodSource("selectiveReadModes")
+  public void selectiveReadReturnsOnlyRequestedFields(
+      FileFormat format, Consumer<V4ManifestReader.Builder> configureRead) 
throws IOException {
+    List<TrackedFile> files =
+        ImmutableList.of(
+            dataFile("s3://bucket/live.parquet", EMPTY_PARTITION_DATA),
+            fileWithStatus(EntryStatus.DELETED, "s3://bucket/deleted.parquet"),
+            fileWithStatus(EntryStatus.REPLACED, 
"s3://bucket/replaced.parquet"));
+
+    InputFile manifest = writeManifest(format, EMPTY_PARTITION, files);
+
+    V4ManifestReader.Builder builder = V4ManifestReader.builder(manifest, 
UNPARTITIONED_SPECS);
+    configureRead.accept(builder);
+    try (V4ManifestReader reader = builder.build()) {
+      TrackedFile actual = Iterables.getOnlyElement(reader);
+
+      // the requested field is read
+      assertThat(actual.location()).isEqualTo("s3://bucket/live.parquet");
+
+      // the reader always projects the fields it consumes internally, even 
though the caller
+      // selected only location: content type and status (liveness filtering 
keeps only the live
+      // entry), and manifest position (from row_position)
+      assertThat(actual.contentType()).isEqualTo(FileContent.DATA);
+      assertThat(actual.tracking().status()).isEqualTo(EntryStatus.ADDED);
+      assertThat(actual.tracking().manifestPos()).isEqualTo(0L);
+
+      // every field the caller did not request and the reader does not 
require is omitted;
+      // content stats in particular (the largest projection) is not read
+      assertThat(actual.contentStats()).isNull();
+      assertThat(actual.fileFormat()).isNull();
+      assertThat(actual.recordCount()).isEqualTo(-1L);
+      assertThat(actual.fileSizeInBytes()).isEqualTo(-1L);
+      assertThat(actual.specId()).isNull();
+      assertThat(actual.partition()).isNull();
+      assertThat(actual.sortOrderId()).isNull();
+      assertThat(actual.deletionVector()).isNull();
+      assertThat(actual.keyMetadata()).isNull();
+      assertThat(actual.splitOffsets()).isNull();
+      assertThat(actual.equalityIds()).isNull();
+    }
+  }
+
+  private static Stream<Arguments> selectiveReadModes() {
+    Map<String, Consumer<V4ManifestReader.Builder>> modes =
+        ImmutableMap.of(
+            "project",
+            builder -> builder.project(new Schema(TrackedFile.LOCATION)),
+            "select",
+            builder -> builder.select("location"),
+            "case-insensitive select",
+            builder -> builder.select("LOCATION").caseSensitive(false));
+    return MANIFEST_FORMATS.stream()
+        .flatMap(
+            format ->
+                modes.entrySet().stream()
+                    .map(mode -> Arguments.of(format, Named.of(mode.getKey(), 
mode.getValue()))));
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void rowFilterForcesRecordCount(FileFormat format) throws IOException 
{
+    TrackedFile file = dataFile("s3://bucket/file.parquet", 
EMPTY_PARTITION_DATA);
+
+    InputFile manifest = writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(file));
+
+    // record_count is read when evaluating a row filter against file metrics, 
so it is projected
+    // even though the caller selected only location
+    Schema projection = new Schema(TrackedFile.LOCATION);
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, UNPARTITIONED_SPECS)
+            .project(projection)
+            .filter(Expressions.equal("id", 1))
+            .build()) {
+      TrackedFile actual = Iterables.getOnlyElement(reader);
+      assertThat(actual.location()).isEqualTo(file.location());
+      assertThat(actual.recordCount()).isEqualTo(RECORD_COUNT);
+    }
+  }
+
+  @Test
+  public void projectionModesAreMutuallyExclusive() {
+    InputFile manifest = 
fileIO.newInputFile(tempDir.resolve("manifest.avro").toString());
+
+    assertThatThrownBy(
+            () ->
+                V4ManifestReader.builder(manifest, UNPARTITIONED_SPECS)
+                    .select("location")
+                    .project(new Schema(TrackedFile.LOCATION)))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage("Cannot use project(Schema) with 
select(Collection<String>)");
+
+    assertThatThrownBy(
+            () ->
+                V4ManifestReader.builder(manifest, UNPARTITIONED_SPECS)
+                    .project(new Schema(TrackedFile.LOCATION))
+                    .select("location"))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage("Cannot use select(Collection<String>) with 
project(Schema)");
+
+    assertThatThrownBy(
+            () ->
+                V4ManifestReader.builder(manifest, UNPARTITIONED_SPECS)
+                    .forScanPlanning()
+                    .select("location"))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage("Cannot use select(Collection<String>) with 
forScanPlanning()");
+
+    assertThatThrownBy(
+            () ->
+                V4ManifestReader.builder(manifest, UNPARTITIONED_SPECS)
+                    .select("location")
+                    .forScanPlanning())
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage(
+            "Cannot use forScanPlanning() with select(Collection<String>) or 
project(Schema)");
+
+    assertThatThrownBy(
+            () ->
+                V4ManifestReader.builder(manifest, UNPARTITIONED_SPECS)
+                    .forScanPlanning()
+                    .project(new Schema(TrackedFile.LOCATION)))
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage("Cannot use project(Schema) with forScanPlanning()");
+
+    assertThatThrownBy(
+            () ->
+                V4ManifestReader.builder(manifest, UNPARTITIONED_SPECS)
+                    .project(new Schema(TrackedFile.LOCATION))
+                    .forScanPlanning())
+        .isInstanceOf(IllegalStateException.class)
+        .hasMessage(
+            "Cannot use forScanPlanning() with select(Collection<String>) or 
project(Schema)");
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void projectionPreservesNarrowTrackingProjection(FileFormat format) 
throws IOException {
+    InputFile manifest =
+        writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(FILE_WITH_FULL_TRACKING));
+
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, 
UNPARTITIONED_SPECS).select("tracking.status").build()) {
+      Tracking actual = Iterables.getOnlyElement(reader).tracking();
+      assertThat(actual.status()).isEqualTo(EntryStatus.ADDED);
+      // the narrow tracking projection is not widened to the full tracking 
type
+      assertThat(actual.snapshotId()).isNull();
+      assertThat(actual.dvSnapshotId()).isNull();
+      assertThat(actual.deletedPositions()).isNull();
+      assertThat(actual.replacedPositions()).isNull();
+    }
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void forScanPlanningOmitsChangeTrackingFields(FileFormat format) 
throws IOException {
+    InputFile manifest =
+        writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(FILE_WITH_FULL_TRACKING));
+
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, 
UNPARTITIONED_SPECS).forScanPlanning().build()) {
+      Tracking actual = Iterables.getOnlyElement(reader).tracking();
+      // scan-relevant tracking fields are projected
+      assertThat(actual.status()).isEqualTo(EntryStatus.ADDED);
+      assertThat(actual.snapshotId()).isEqualTo(SNAPSHOT_ID);
+      assertThat(actual.dataSequenceNumber()).isEqualTo(5L);
+      assertThat(actual.fileSequenceNumber()).isEqualTo(6L);
+      assertThat(actual.firstRowId()).isEqualTo(8L);
+      // change-tracking fields are omitted from the scan projection
+      assertThat(actual.dvSnapshotId()).isNull();
+      assertThat(actual.deletedPositions()).isNull();
+      assertThat(actual.replacedPositions()).isNull();
+    }
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void defaultReadsFullTracking(FileFormat format) throws IOException {
+    InputFile manifest =
+        writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(FILE_WITH_FULL_TRACKING));
+
+    // without scanPlanning, select, or project, the reader returns the full 
schema for copying to
+    // other manifests, including the change-tracking fields
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, UNPARTITIONED_SPECS).build()) {
+      Tracking actual = Iterables.getOnlyElement(reader).tracking();
+      assertThat(actual.status()).isEqualTo(EntryStatus.ADDED);
+      assertThat(actual.snapshotId()).isEqualTo(SNAPSHOT_ID);
+      assertThat(actual.dataSequenceNumber()).isEqualTo(5L);
+      assertThat(actual.fileSequenceNumber()).isEqualTo(6L);
+      assertThat(actual.firstRowId()).isEqualTo(8L);
+      assertThat(actual.dvSnapshotId()).isEqualTo(7L);
+      assertThat(actual.deletedPositions()).isEqualTo(ByteBuffer.wrap(new 
byte[] {1, 2}));
+      assertThat(actual.replacedPositions()).isEqualTo(ByteBuffer.wrap(new 
byte[] {3, 4}));
+    }
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void projectNullReadsFullSchema(FileFormat format) throws IOException 
{
+    InputFile manifest =
+        writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(FILE_WITH_FULL_TRACKING));
+
+    // project(null) clears the projection and reads the full schema, like no 
projection at all
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, 
UNPARTITIONED_SPECS).project(null).build()) {
+      TrackedFile actual = Iterables.getOnlyElement(reader);
+      assertThat(actual.location()).isEqualTo("s3://bucket/file.parquet");
+      assertThat(actual.fileFormat()).isEqualTo(FileFormat.PARQUET);
+      assertThat(actual.recordCount()).isEqualTo(RECORD_COUNT);
+      assertThat(actual.fileSizeInBytes()).isEqualTo(FILE_SIZE_IN_BYTES);
+    }
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void partitionFilterForceProjectsFilterFields(FileFormat format) 
throws IOException {
+    InputFile manifest = writeManifest(format, ID_PARTITION_TYPE, 
ImmutableList.of(FILE_A, FILE_B));
+
+    // the caller projects only location; the reader must still project the 
fields the partition
+    // filter reads (spec_id, partition) or every row would be pruned
+    Schema projection = new Schema(TrackedFile.LOCATION);
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, ID_PARTITIONING_SPECS)
+            .project(projection)
+            .filter(Expressions.equal("id", 1))
+            .build()) {
+      TrackedFile actual = Iterables.getOnlyElement(reader);
+      assertThat(actual.location()).isEqualTo(FILE_A.location());
+      assertThat(actual.specId()).isEqualTo(ID_PARTITIONING.specId());
+      assertThat(actual.partition().get(0, Integer.class)).isEqualTo(1);
+    }
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void selectWithPartitionFilterProjectsFilterFields(FileFormat format) 
throws IOException {
+    InputFile manifest = writeManifest(format, ID_PARTITION_TYPE, 
ImmutableList.of(FILE_A, FILE_B));
+
+    // the caller selects only location; the reader must still project spec_id 
and partition
+    // for the partition filter or every row would be pruned
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, ID_PARTITIONING_SPECS)
+            .select("location")
+            .filter(Expressions.equal("id", 1))
+            .build()) {
+      TrackedFile actual = Iterables.getOnlyElement(reader);
+      assertThat(actual.location()).isEqualTo(FILE_A.location());
+      assertThat(actual.specId()).isEqualTo(ID_PARTITIONING.specId());
+      assertThat(actual.partition().get(0, Integer.class)).isEqualTo(1);
+    }
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void unpartitionedProducesNullPartitionValue(FileFormat format) 
throws IOException {
+    TrackedFile file = dataFile("s3://bucket/file.parquet", 
EMPTY_PARTITION_DATA);
+
+    InputFile manifest = writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(file));
+
+    TrackedFile actual = Iterables.getOnlyElement(read(manifest, 
UNPARTITIONED_SPECS));
+    // unpartitioned manifests omit the partition field, which is read as null
+    assertThat(actual.partition()).isNull();
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void partitionFilterPrunesFilesAndCountsSkips(FileFormat format) 
throws IOException {
+    // FILE_A and EQ_DELETES_A match the filter; FILE_B and EQ_DELETES_B are 
pruned; manifest
+    // references have no partition and are always kept
+    InputFile manifest =
+        writeManifest(
+            format,
+            ID_PARTITION_TYPE,
+            ImmutableList.of(
+                FILE_A,
+                FILE_B,
+                EQ_DELETES_A,
+                EQ_DELETES_B,
+                DATA_MANIFEST_REF,
+                DELETE_MANIFEST_REF));
+
+    ScanMetrics metrics = ScanMetrics.of(new DefaultMetricsContext());
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, ID_PARTITIONING_SPECS)
+            .filter(Expressions.equal("id", 1))
+            .scanMetrics(metrics)
+            .build()) {
+      assertThat(reader)
+          .extracting(TrackedFile::location)
+          .containsExactlyInAnyOrder(
+              FILE_A.location(),
+              EQ_DELETES_A.location(),
+              DATA_MANIFEST_REF.location(),
+              DELETE_MANIFEST_REF.location());
+    }
+
+    assertThat(metrics.skippedDataFiles().value())
+        .as("one data file is pruned by the partition filter")
+        .isEqualTo(1L);
+    assertThat(metrics.skippedDeleteFiles().value())
+        .as("one delete file is pruned by the partition filter")
+        .isEqualTo(1L);
+    assertThat(metrics.skippedDataManifests().value())
+        .as("manifests have no partition and are not pruned")
+        .isEqualTo(0L);
+    assertThat(metrics.skippedDeleteManifests().value())
+        .as("manifests have no partition and are not pruned")
+        .isEqualTo(0L);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void rowFilterKeepsFilesWithoutStats(FileFormat format) throws 
IOException {
+    // with no content stats to evaluate, the row filter cannot prune any file
+    TrackedFile file1 = dataFile("s3://bucket/a.parquet", 
EMPTY_PARTITION_DATA);
+    TrackedFile file2 = dataFile("s3://bucket/b.parquet", 
EMPTY_PARTITION_DATA);
+
+    InputFile manifest = writeManifest(format, EMPTY_PARTITION, 
ImmutableList.of(file1, file2));
+
+    ScanMetrics metrics = ScanMetrics.of(new DefaultMetricsContext());
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, UNPARTITIONED_SPECS)
+            .filter(Expressions.equal("id", 1))
+            .scanMetrics(metrics)
+            .build()) {
+      assertThat(reader)
+          .extracting(TrackedFile::location)
+          .containsExactly(file1.location(), file2.location());
+    }
+
+    assertThat(metrics.skippedDataFiles().value()).isEqualTo(0L);
+    assertThat(metrics.skippedDeleteFiles().value()).isEqualTo(0L);
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void caseInsensitivePartitionFilter(FileFormat format) throws 
IOException {
+    InputFile manifest = writeManifest(format, ID_PARTITION_TYPE, 
ImmutableList.of(FILE_A, FILE_B));
+
+    // a case-insensitive filter binds the mismatched-case "ID" reference and 
prunes FILE_B
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, ID_PARTITIONING_SPECS)
+            .filter(Expressions.equal("ID", 1))
+            .caseSensitive(false)
+            .build()) {
+      
assertThat(reader).extracting(TrackedFile::location).containsExactly(FILE_A.location());
+    }
+
+    // the same filter is case-sensitive by default, so "ID" fails to bind to 
the "id" field
+    assertThatThrownBy(
+            () ->
+                V4ManifestReader.builder(manifest, ID_PARTITIONING_SPECS)
+                    .filter(Expressions.equal("ID", 1))
+                    .build())
+        .isInstanceOf(ValidationException.class)
+        .hasMessageContaining("Cannot find field 'ID'");
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void filterMatchesFilesAcrossDisjointSpecs(FileFormat format) throws 
IOException {
+    PartitionSpec spec0 =
+        PartitionSpec.builderFor(TABLE_SCHEMA)
+            .withSpecId(0)
+            .add(1, 1000, "id", Transforms.identity())
+            .build();
+    PartitionSpec spec1 =
+        PartitionSpec.builderFor(TABLE_SCHEMA)
+            .withSpecId(1)
+            .add(2, 1001, "data", Transforms.identity())
+            .build();
+    Map<Integer, PartitionSpec> specsById =
+        ImmutableMap.of(spec0.specId(), spec0, spec1.specId(), spec1);
+    Types.StructType unionType = 
Partitioning.unionPartitionTypes(specsById.values());
+
+    TrackedFile keepById =
+        dataFile("spec0-id1.parquet", spec0.specId(), 
unionPartition(unionType, 1, null));
+    TrackedFile prunedById =
+        dataFile("spec0-id2.parquet", spec0.specId(), 
unionPartition(unionType, 2, null));
+    TrackedFile keptOtherSpec =
+        dataFile("spec1-data.parquet", spec1.specId(), 
unionPartition(unionType, null, "x"));
+
+    InputFile manifest =
+        writeManifest(format, unionType, ImmutableList.of(keepById, 
prunedById, keptOtherSpec));
+
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, 
specsById).filter(Expressions.equal("id", 1)).build()) {
+      // spec0 entries are pruned by id; the spec1 entry is not partitioned by 
id so it survives
+      assertThat(reader)
+          .extracting(TrackedFile::location)
+          .containsExactlyInAnyOrder(keepById.location(), 
keptOtherSpec.location());
+    }
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void partialFilterStillPrunesOnCompatibleField(FileFormat format) 
throws IOException {
+    // the spec partitions on id only; a filter of id = 1 AND data = 'z' 
should still prune by id
+    // even though data is not a partition source
+    TrackedFile keep = dataFile("id1.parquet", partition(1));
+    TrackedFile prune = dataFile("id2.parquet", partition(2));
+
+    InputFile manifest = writeManifest(format, ID_PARTITION_TYPE, 
ImmutableList.of(keep, prune));
+
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, ID_PARTITIONING_SPECS)
+            .filter(Expressions.and(Expressions.equal("id", 1), 
Expressions.equal("data", "z")))
+            .build()) {
+      assertThat(reader)
+          .extracting(TrackedFile::location)
+          .as("the id predicate prunes even though data is not a partition 
field")
+          .containsExactly(keep.location());
+    }
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void partitionFilterKeepsFileWithUnknownSpec(FileFormat format) 
throws IOException {
+    // spec ID 5 is not in ID_PARTITIONING_SPECS, so no partition filter 
applies to this file
+    TrackedFile file = dataFile("orphan.parquet", 5, partition(1));
+
+    InputFile manifest = writeManifest(format, ID_PARTITION_TYPE, 
ImmutableList.of(file));
+
+    // the filter would prune partition id=1 under spec 0, but cannot be 
applied to spec 5
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, ID_PARTITIONING_SPECS)
+            .filter(Expressions.equal("id", 2))
+            .build()) {
+      
assertThat(reader).extracting(TrackedFile::location).containsExactly(file.location());
+    }
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void partitionFilterKeepsFileWithNullSpecId(FileFormat format) throws 
IOException {
+    TrackedFile file = dataFile("no-spec.parquet", null, null);
+
+    InputFile manifest = writeManifest(format, ID_PARTITION_TYPE, 
ImmutableList.of(file));
+
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, ID_PARTITIONING_SPECS)
+            .filter(Expressions.equal("id", 2))
+            .build()) {
+      
assertThat(reader).extracting(TrackedFile::location).containsExactly(file.location());
+    }
+  }
+
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void iteratorReturnsLiveCopies(FileFormat format) throws IOException {
+    TrackedFile added1 = dataFile("s3://bucket/added-1.parquet", 
EMPTY_PARTITION_DATA);
+    TrackedFile added2 = dataFile("s3://bucket/added-2.parquet", 
EMPTY_PARTITION_DATA);
+    List<TrackedFile> files =
+        ImmutableList.of(
+            added1, added2, fileWithStatus(EntryStatus.DELETED, 
"s3://bucket/deleted.parquet"));
+
+    InputFile manifest = writeManifest(format, EMPTY_PARTITION, files);
+
+    try (V4ManifestReader reader =
+        V4ManifestReader.builder(manifest, UNPARTITIONED_SPECS).build()) {
+      List<TrackedFile> read = Lists.newArrayList(reader);
+      assertThat(read)
+          .hasSize(2)
+          .extracting(TrackedFile::location)
+          .containsExactly(added1.location(), added2.location());
+      assertThat(read.get(0))
+          .as("iterator() should copy each entry rather than yield one reused 
container")
+          .isNotSameAs(read.get(1));
+    }
+  }
+
+  @Test
+  public void unknownManifestFormatThrows() throws IOException {
+    InputFile badFile =
+        fileIO.newInputFile(tempDir.resolve("manifest-" + System.nanoTime() + 
".txt").toString());
+
+    try (V4ManifestReader reader = V4ManifestReader.builder(badFile, 
UNPARTITIONED_SPECS).build()) {
+      assertThatThrownBy(reader::iterator)
+          .isInstanceOf(IllegalArgumentException.class)
+          .hasMessageContaining("Cannot determine format of manifest");
+    }
+  }
+
+  @Test
+  public void invalidBuilderArguments() {
+    InputFile manifest = 
fileIO.newInputFile(tempDir.resolve("manifest.avro").toString());
+
+    assertThatThrownBy(() -> V4ManifestReader.builder(manifest, 
UNPARTITIONED_SPECS).filter(null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid filter: null");
+
+    assertThatThrownBy(
+            () -> V4ManifestReader.builder(manifest, 
UNPARTITIONED_SPECS).scanMetrics(null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid scan metrics: null");
+
+    assertThatThrownBy(
+            () ->
+                V4ManifestReader.builder(manifest, UNPARTITIONED_SPECS)
+                    .select((Collection<String>) null))
+        .isInstanceOf(IllegalArgumentException.class)
+        .hasMessage("Invalid columns: null");
+  }
+
+  private static TrackedFile dataFile(String location, PartitionData 
partition) {
+    return dataFile(location, 0, partition);
+  }
+
+  private static TrackedFile dataFile(String location, Integer specId, 
PartitionData partition) {
+    return new TrackedFileStruct(
+        addedTracking(),
+        FileContent.DATA,
+        FORMAT_VERSION_V4,
+        location,
+        FileFormat.PARQUET,
+        RECORD_COUNT,
+        FILE_SIZE_IN_BYTES,
+        specId,
+        partition,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null);
+  }
+
+  private static TrackedFile deleteFile(String location, PartitionData 
partition) {
+    return new TrackedFileStruct(
+        addedTracking(),
+        FileContent.EQUALITY_DELETES,
+        FORMAT_VERSION_V4,
+        location,
+        FileFormat.PARQUET,
+        RECORD_COUNT,
+        FILE_SIZE_IN_BYTES,
+        0,
+        partition,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        ImmutableList.of(1));
+  }
+
+  private static TrackedFile manifestRef(FileContent content, String location) 
{
+    ManifestInfo info = new ManifestInfoStruct(1, 0, 0, 0, 1L, 0L, 0L, 0L, 1L, 
null, null);
+    return new TrackedFileStruct(
+        addedTracking(),
+        content,
+        FORMAT_VERSION_V4,
+        location,
+        FileFormat.PARQUET,
+        RECORD_COUNT,
+        FILE_SIZE_IN_BYTES,
+        null, // spec_id: a manifest reference has no spec
+        null, // partition: a manifest reference has no partition tuple
+        null, // content_stats
+        null, // sort_order_id
+        null, // deletion_vector
+        info,
+        null, // key_metadata
+        null, // split_offsets
+        null); // equality_ids
+  }
+
+  private static TrackedFile fileWithStatus(EntryStatus status, String 
location) {
+    Tracking tracking =
+        new TrackingStruct(
+            status,
+            SNAPSHOT_ID,
+            3L, // data sequence number
+            3L, // file sequence number
+            null, // dv snapshot id
+            null, // first row id
+            null, // deleted positions
+            null); // replaced positions
+    return new TrackedFileStruct(
+        tracking,
+        FileContent.DATA,
+        FORMAT_VERSION_V4,
+        location,
+        FileFormat.PARQUET,
+        RECORD_COUNT,
+        FILE_SIZE_IN_BYTES,
+        0,
+        EMPTY_PARTITION_DATA,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null,
+        null);
+  }
+
+  private static Tracking addedTracking() {
+    return new TrackingStruct(EntryStatus.ADDED, SNAPSHOT_ID, null, null, 
null, null, null, null);
+  }
+
+  private static PartitionData partition(int id) {
+    PartitionData partition = new PartitionData(ID_PARTITION_TYPE);
+    partition.set(0, id);
+    return partition;
+  }
+
+  private static PartitionData unionPartition(Types.StructType unionType, 
Integer id, String data) {
+    PartitionData partition = new PartitionData(unionType);
+    partition.set(0, id);
+    partition.set(1, data);
+    return partition;
+  }
+
+  private InputFile writeManifest(
+      FileFormat format, Types.StructType partitionType, Iterable<TrackedFile> 
files)
+      throws IOException {
+    Schema writeSchema = TrackedFile.schema(partitionType, 
Types.StructType.of());
+    OutputFile out = new InMemoryOutputFile("manifest." + 
format.name().toLowerCase(Locale.ROOT));
+    try (FileAppender<StructLike> appender =
+        InternalData.write(format, 
out).schema(writeSchema).named("tracked_file").build()) {
+      for (TrackedFile file : files) {
+        appender.add((StructLike) file);
+      }
+    }
+
+    return out.toInputFile();
+  }
+
+  private List<TrackedFile> read(InputFile manifest, Map<Integer, 
PartitionSpec> specsById)
+      throws IOException {
+    try (V4ManifestReader reader = V4ManifestReader.builder(manifest, 
specsById).build()) {
+      return Lists.newArrayList(reader);
+    }
+  }
+}

Reply via email to