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

RussellSpitzer 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 b56b4bf9d6 Core: Basic fields and schemas for column files (#16285)
b56b4bf9d6 is described below

commit b56b4bf9d6f3f4547fd3773e8fd78239869fad4b
Author: gaborkaszab <[email protected]>
AuthorDate: Thu Nov 5 20:12:47 2026 +0100

    Core: Basic fields and schemas for column files (#16285)
---
 .../main/java/org/apache/iceberg/ColumnFile.java   |  72 +++++++++
 .../java/org/apache/iceberg/ColumnFileStruct.java  | 166 +++++++++++++++++++++
 .../main/java/org/apache/iceberg/TrackedFile.java  |   9 +-
 .../org/apache/iceberg/TrackedFileAdapters.java    |  10 ++
 .../java/org/apache/iceberg/TrackedFileStruct.java |  27 +++-
 .../java/org/apache/iceberg/TrackingBuilder.java   |  15 +-
 .../java/org/apache/iceberg/TrackingStruct.java    |   3 +-
 .../java/org/apache/iceberg/V4ManifestReader.java  |  13 ++
 .../java/org/apache/iceberg/StatsTestUtil.java     |   1 +
 .../org/apache/iceberg/TestColumnFileStruct.java   | 132 ++++++++++++++++
 .../java/org/apache/iceberg/TestTrackedFile.java   |   5 +-
 .../apache/iceberg/TestTrackedFileAdapters.java    |  13 +-
 .../org/apache/iceberg/TestTrackedFileStruct.java  |  34 ++++-
 .../org/apache/iceberg/TestTrackingBuilder.java    |  39 +++++
 .../org/apache/iceberg/TestTrackingStruct.java     |  14 +-
 .../org/apache/iceberg/TestV4ManifestReader.java   | 141 ++++++++++++++++-
 .../java/org/apache/iceberg/V4TestComparators.java |  38 ++++-
 .../java/org/apache/iceberg/V4TestHelpers.java     |  63 ++++++--
 18 files changed, 757 insertions(+), 38 deletions(-)

diff --git a/core/src/main/java/org/apache/iceberg/ColumnFile.java 
b/core/src/main/java/org/apache/iceberg/ColumnFile.java
new file mode 100644
index 0000000000..7bdab8d88d
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/ColumnFile.java
@@ -0,0 +1,72 @@
+/*
+ * 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.Set;
+import org.apache.iceberg.types.Types;
+
+interface ColumnFile {
+  Types.NestedField LOCATION =
+      Types.NestedField.required(
+          160, "location", Types.StringType.get(), "Location of the column 
file");
+  Types.NestedField FIELD_IDS =
+      Types.NestedField.required(
+          161,
+          "field_ids",
+          Types.ListType.ofRequired(162, Types.IntegerType.get()),
+          "Live field IDs in this column file");
+  Types.NestedField FILE_FORMAT =
+      Types.NestedField.required(
+          163,
+          "file_format",
+          Types.StringType.get(),
+          "String file format name for this column file");
+  Types.NestedField FILE_SIZE_IN_BYTES =
+      Types.NestedField.required(
+          164, "file_size_in_bytes", Types.LongType.get(), "Total column file 
size in bytes");
+  Types.NestedField KEY_METADATA =
+      Types.NestedField.optional(
+          165,
+          "key_metadata",
+          Types.BinaryType.get(),
+          "Key metadata for encryption; specific to the encryption scheme.");
+
+  static Types.StructType schema() {
+    return Types.StructType.of(LOCATION, FIELD_IDS, FILE_FORMAT, 
FILE_SIZE_IN_BYTES, KEY_METADATA);
+  }
+
+  /** Returns the location of this column file. */
+  String location();
+
+  /** Returns the field IDs contained in this column file. */
+  Set<Integer> fieldIds();
+
+  /** Returns the format of this column file. */
+  FileFormat fileFormat();
+
+  /** Returns the total size of this column file in bytes. */
+  long fileSizeInBytes();
+
+  /** Returns encryption key metadata, or null if this column file is not 
encrypted. */
+  ByteBuffer keyMetadata();
+
+  /** Copies this column file. */
+  ColumnFile copy();
+}
diff --git a/core/src/main/java/org/apache/iceberg/ColumnFileStruct.java 
b/core/src/main/java/org/apache/iceberg/ColumnFileStruct.java
new file mode 100644
index 0000000000..5115be121b
--- /dev/null
+++ b/core/src/main/java/org/apache/iceberg/ColumnFileStruct.java
@@ -0,0 +1,166 @@
+/*
+ * 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.Serializable;
+import java.nio.ByteBuffer;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Set;
+import org.apache.iceberg.avro.SupportsIndexProjection;
+import org.apache.iceberg.relocated.com.google.common.base.MoreObjects;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet;
+import org.apache.iceberg.relocated.com.google.common.primitives.Ints;
+import org.apache.iceberg.types.Types;
+import org.apache.iceberg.util.ArrayUtil;
+import org.apache.iceberg.util.ByteBuffers;
+
+/** Mutable {@link StructLike} implementation of {@link ColumnFile}. */
+class ColumnFileStruct extends SupportsIndexProjection implements ColumnFile, 
Serializable {
+  private static final Types.StructType BASE_TYPE =
+      Types.StructType.of(
+          ColumnFile.LOCATION,
+          ColumnFile.FIELD_IDS,
+          ColumnFile.FILE_FORMAT,
+          ColumnFile.FILE_SIZE_IN_BYTES,
+          ColumnFile.KEY_METADATA);
+
+  private String location = null;
+  private int[] fieldIds = null;
+  private FileFormat fileFormat = null;
+  private long fileSizeInBytes = -1L;
+  private byte[] keyMetadata = null;
+
+  /** Used by internal readers to instantiate this class with a projection 
schema. */
+  ColumnFileStruct(Types.StructType projection) {
+    super(BASE_TYPE, projection);
+  }
+
+  ColumnFileStruct(
+      String location,
+      Set<Integer> fieldIds,
+      FileFormat fileFormat,
+      long fileSizeInBytes,
+      ByteBuffer keyMetadata) {
+    super(BASE_TYPE.fields().size());
+    this.location = location;
+    this.fieldIds = fieldIds != null ? Ints.toArray(fieldIds) : null;
+    this.fileFormat = fileFormat;
+    this.fileSizeInBytes = fileSizeInBytes;
+    this.keyMetadata = ByteBuffers.toByteArray(keyMetadata);
+  }
+
+  /** Copy constructor. */
+  private ColumnFileStruct(ColumnFileStruct toCopy) {
+    super(toCopy);
+    this.location = toCopy.location;
+    this.fieldIds =
+        toCopy.fieldIds != null ? Arrays.copyOf(toCopy.fieldIds, 
toCopy.fieldIds.length) : null;
+    this.fileFormat = toCopy.fileFormat;
+    this.fileSizeInBytes = toCopy.fileSizeInBytes;
+    this.keyMetadata =
+        toCopy.keyMetadata != null
+            ? Arrays.copyOf(toCopy.keyMetadata, toCopy.keyMetadata.length)
+            : null;
+  }
+
+  /** Constructor for Java serialization. */
+  ColumnFileStruct() {
+    super(BASE_TYPE.fields().size());
+  }
+
+  @Override
+  public String location() {
+    return location;
+  }
+
+  // Package-private only so the manifest reader can store the location 
resolved against the
+  // table location; other callers must go through construction.
+  void setLocation(String newLocation) {
+    this.location = newLocation;
+  }
+
+  @Override
+  public Set<Integer> fieldIds() {
+    return fieldIds != null ? ImmutableSet.copyOf(Ints.asList(fieldIds)) : 
null;
+  }
+
+  @Override
+  public FileFormat fileFormat() {
+    return fileFormat;
+  }
+
+  @Override
+  public long fileSizeInBytes() {
+    return fileSizeInBytes;
+  }
+
+  @Override
+  public ByteBuffer keyMetadata() {
+    return keyMetadata != null ? ByteBuffer.wrap(keyMetadata) : null;
+  }
+
+  @Override
+  public ColumnFile copy() {
+    return new ColumnFileStruct(this);
+  }
+
+  @Override
+  protected <T> T internalGet(int pos, Class<T> javaClass) {
+    return javaClass.cast(getByPos(pos));
+  }
+
+  private Object getByPos(int pos) {
+    return switch (pos) {
+      case 0 -> location;
+      case 1 -> ArrayUtil.toUnmodifiableIntList(fieldIds);
+      case 2 -> fileFormat != null ? fileFormat.toString() : null;
+      case 3 -> fileSizeInBytes;
+      case 4 -> keyMetadata();
+      default -> throw new UnsupportedOperationException("Unknown field 
ordinal: " + pos);
+    };
+  }
+
+  @Override
+  @SuppressWarnings("unchecked")
+  protected <T> void internalSet(int pos, T value) {
+    switch (pos) {
+        // always coerce to String for Serializable
+      case 0 -> this.location = value.toString();
+      case 1 -> this.fieldIds = ArrayUtil.toIntArray((List<Integer>) value);
+      case 2 -> this.fileFormat = FileFormat.fromString(value.toString());
+      case 3 -> this.fileSizeInBytes = (long) value;
+      case 4 -> this.keyMetadata = ByteBuffers.toByteArray((ByteBuffer) value);
+      default -> {
+        // ignore the object, it must be from a newer version of the format
+      }
+    }
+  }
+
+  @Override
+  public String toString() {
+    return MoreObjects.toStringHelper(this)
+        .add("location", location)
+        .add("field_ids", fieldIds)
+        .add("file_format", fileFormat)
+        .add("file_size_in_bytes", fileSizeInBytes)
+        .add("key_metadata", keyMetadata == null ? "null" : "(redacted)")
+        .toString();
+  }
+}
diff --git a/core/src/main/java/org/apache/iceberg/TrackedFile.java 
b/core/src/main/java/org/apache/iceberg/TrackedFile.java
index 32757d235b..3581a22ec3 100644
--- a/core/src/main/java/org/apache/iceberg/TrackedFile.java
+++ b/core/src/main/java/org/apache/iceberg/TrackedFile.java
@@ -87,6 +87,9 @@ interface TrackedFile {
           "split_offsets",
           Types.ListType.ofRequired(133, Types.LongType.get()),
           "Split offsets for the data file");
+  Types.NestedField COLUMN_FILES =
+      Types.NestedField.optional(
+          158, "column_files", Types.ListType.ofRequired(159, 
ColumnFile.schema()), "Column files");
 
   private static List<Types.NestedField> fields(
       Types.StructType partitionType, Types.StructType contentStatsType) {
@@ -109,7 +112,8 @@ interface TrackedFile {
         DELETION_VECTOR,
         MANIFEST_INFO,
         KEY_METADATA,
-        SPLIT_OFFSETS);
+        SPLIT_OFFSETS,
+        COLUMN_FILES);
   }
 
   private static Type typeOrUnknown(Types.StructType structType) {
@@ -178,6 +182,9 @@ interface TrackedFile {
   /** Returns the list of recommended split locations, or null. */
   List<Long> splitOffsets();
 
+  /** Returns the column files for this file. */
+  List<ColumnFile> columnFiles();
+
   /** Copies this tracked file. */
   TrackedFile copy();
 
diff --git a/core/src/main/java/org/apache/iceberg/TrackedFileAdapters.java 
b/core/src/main/java/org/apache/iceberg/TrackedFileAdapters.java
index d1d074c116..77ad3b2139 100644
--- a/core/src/main/java/org/apache/iceberg/TrackedFileAdapters.java
+++ b/core/src/main/java/org/apache/iceberg/TrackedFileAdapters.java
@@ -652,6 +652,11 @@ class TrackedFileAdapters {
       return file.splitOffsets();
     }
 
+    @Override
+    public List<ColumnFile> columnFiles() {
+      return null;
+    }
+
     @Override
     public TrackedFile copy() {
       throw new UnsupportedOperationException("copy is not implemented");
@@ -774,6 +779,11 @@ class TrackedFileAdapters {
       return null;
     }
 
+    @Override
+    public List<ColumnFile> columnFiles() {
+      return null;
+    }
+
     @Override
     public TrackedFile copy() {
       throw new UnsupportedOperationException("copy is not implemented");
diff --git a/core/src/main/java/org/apache/iceberg/TrackedFileStruct.java 
b/core/src/main/java/org/apache/iceberg/TrackedFileStruct.java
index e19ab7ab0e..f6a96edeb5 100644
--- a/core/src/main/java/org/apache/iceberg/TrackedFileStruct.java
+++ b/core/src/main/java/org/apache/iceberg/TrackedFileStruct.java
@@ -21,11 +21,13 @@ package org.apache.iceberg;
 import java.io.Serializable;
 import java.nio.ByteBuffer;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.List;
 import java.util.Set;
 import org.apache.iceberg.avro.SupportsIndexProjection;
 import org.apache.iceberg.exceptions.ValidationException;
 import org.apache.iceberg.relocated.com.google.common.base.MoreObjects;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
 import org.apache.iceberg.types.Types;
 import org.apache.iceberg.util.ArrayUtil;
 import org.apache.iceberg.util.ByteBuffers;
@@ -59,7 +61,8 @@ class TrackedFileStruct extends SupportsIndexProjection 
implements TrackedFile,
           TrackedFile.DELETION_VECTOR,
           TrackedFile.MANIFEST_INFO,
           TrackedFile.KEY_METADATA,
-          TrackedFile.SPLIT_OFFSETS);
+          TrackedFile.SPLIT_OFFSETS,
+          TrackedFile.COLUMN_FILES);
 
   private FileContent contentType = null;
   private String location = null;
@@ -77,6 +80,7 @@ class TrackedFileStruct extends SupportsIndexProjection 
implements TrackedFile,
   private ManifestInfo manifestInfo = null;
   private byte[] keyMetadata = null;
   private long[] splitOffsets = null;
+  private List<ColumnFile> columnFiles = null;
 
   private transient StructProjection partitionProjection = null;
 
@@ -104,7 +108,8 @@ class TrackedFileStruct extends SupportsIndexProjection 
implements TrackedFile,
       DeletionVector deletionVector,
       ManifestInfo manifestInfo,
       ByteBuffer keyMetadata,
-      List<Long> splitOffsets) {
+      List<Long> splitOffsets,
+      List<ColumnFile> columnFiles) {
     super(BASE_TYPE.fields().size());
     this.tracking = tracking;
     this.contentType = contentType;
@@ -120,9 +125,11 @@ class TrackedFileStruct extends SupportsIndexProjection 
implements TrackedFile,
     this.manifestInfo = manifestInfo;
     this.keyMetadata = ByteBuffers.toByteArray(keyMetadata);
     this.splitOffsets = ArrayUtil.toLongArray(splitOffsets);
+    this.columnFiles = columnFiles != null ? Lists.newArrayList(columnFiles) : 
null;
   }
 
   /** Copy constructor. */
+  @SuppressWarnings("CyclomaticComplexity")
   private TrackedFileStruct(TrackedFileStruct toCopy, Set<Integer> statsIds) {
     super(toCopy);
     this.contentType = toCopy.contentType;
@@ -152,6 +159,14 @@ class TrackedFileStruct extends SupportsIndexProjection 
implements TrackedFile,
         toCopy.splitOffsets != null
             ? Arrays.copyOf(toCopy.splitOffsets, toCopy.splitOffsets.length)
             : null;
+    if (toCopy.columnFiles != null) {
+      this.columnFiles = 
Lists.newArrayListWithCapacity(toCopy.columnFiles.size());
+      for (ColumnFile columnFile : toCopy.columnFiles) {
+        this.columnFiles.add(columnFile.copy());
+      }
+    } else {
+      this.columnFiles = null;
+    }
   }
 
   @Override
@@ -244,6 +259,11 @@ class TrackedFileStruct extends SupportsIndexProjection 
implements TrackedFile,
     return splitOffsets != null ? 
ArrayUtil.toUnmodifiableLongList(splitOffsets) : null;
   }
 
+  @Override
+  public List<ColumnFile> columnFiles() {
+    return columnFiles != null ? Collections.unmodifiableList(columnFiles) : 
null;
+  }
+
   @Override
   public TrackedFile copy() {
     return new TrackedFileStruct(this, null);
@@ -275,6 +295,7 @@ class TrackedFileStruct extends SupportsIndexProjection 
implements TrackedFile,
       case 11 -> manifestInfo;
       case 12 -> keyMetadata();
       case 13 -> splitOffsets();
+      case 14 -> columnFiles();
       default -> throw new UnsupportedOperationException("Unknown field 
ordinal: " + pos);
     };
   }
@@ -298,6 +319,7 @@ class TrackedFileStruct extends SupportsIndexProjection 
implements TrackedFile,
       case 11 -> this.manifestInfo = (ManifestInfo) value;
       case 12 -> this.keyMetadata = ByteBuffers.toByteArray((ByteBuffer) 
value);
       case 13 -> this.splitOffsets = ArrayUtil.toLongArray((List<Long>) value);
+      case 14 -> this.columnFiles = (List<ColumnFile>) value;
       default -> {
         // ignore the object, it must be from a newer version of the format
       }
@@ -321,6 +343,7 @@ class TrackedFileStruct extends SupportsIndexProjection 
implements TrackedFile,
         .add("manifest_info", manifestInfo)
         .add("key_metadata", keyMetadata == null ? "null" : "(redacted)")
         .add("split_offsets", splitOffsets == null ? "null" : splitOffsets())
+        .add("column_files", columnFiles)
         .toString();
   }
 }
diff --git a/core/src/main/java/org/apache/iceberg/TrackingBuilder.java 
b/core/src/main/java/org/apache/iceberg/TrackingBuilder.java
index c076531fcb..64e7cae043 100644
--- a/core/src/main/java/org/apache/iceberg/TrackingBuilder.java
+++ b/core/src/main/java/org/apache/iceberg/TrackingBuilder.java
@@ -25,10 +25,10 @@ import org.apache.iceberg.util.ByteBuffers;
 class TrackingBuilder {
   private final long newSnapshotId;
   private final Long snapshotId;
-  private final Long dataSequenceNumber;
   private final Long fileSequenceNumber;
   private final Long firstRowId;
   private EntryStatus status;
+  private Long dataSequenceNumber;
   private Long modifiedSnapshotId;
   private byte[] deletedPositions;
   private byte[] replacedPositions;
@@ -115,6 +115,19 @@ class TrackingBuilder {
     return this;
   }
 
+  /** Indicates that the column files list has been updated for the new 
Tracking. */
+  TrackingBuilder columnFilesUpdated() {
+    this.modifiedSnapshotId = newSnapshotId;
+    if (status == EntryStatus.EXISTING) {
+      this.status = EntryStatus.MODIFIED;
+    }
+    // Reset to null to inherit from the new snapshot sequence number. It is 
safe to bump up the
+    // dataSequenceNumber as writers are required to rewrite v2 equality and 
position deletes to DVs
+    // when applying column update.
+    this.dataSequenceNumber = null;
+    return this;
+  }
+
   /** Sets the positions deleted by this commit for a manifest entry. */
   TrackingBuilder deletedPositions(ByteBuffer positions) {
     Preconditions.checkState(
diff --git a/core/src/main/java/org/apache/iceberg/TrackingStruct.java 
b/core/src/main/java/org/apache/iceberg/TrackingStruct.java
index c804497d82..d06eae71c5 100644
--- a/core/src/main/java/org/apache/iceberg/TrackingStruct.java
+++ b/core/src/main/java/org/apache/iceberg/TrackingStruct.java
@@ -141,8 +141,9 @@ class TrackingStruct extends SupportsIndexProjection 
implements Tracking, Serial
     }
 
     boolean isAdded = status == EntryStatus.ADDED;
+    boolean isModified = status == EntryStatus.MODIFIED;
 
-    if (null == dataSequenceNumber && (isAdded || manifestSeqNumber == 0)) {
+    if (null == dataSequenceNumber && (isAdded || isModified || 
manifestSeqNumber == 0)) {
       this.dataSequenceNumber = manifestSeqNumber;
     }
 
diff --git a/core/src/main/java/org/apache/iceberg/V4ManifestReader.java 
b/core/src/main/java/org/apache/iceberg/V4ManifestReader.java
index b1009a8ea6..a91c32d4ff 100644
--- a/core/src/main/java/org/apache/iceberg/V4ManifestReader.java
+++ b/core/src/main/java/org/apache/iceberg/V4ManifestReader.java
@@ -19,6 +19,7 @@
 package org.apache.iceberg;
 
 import java.util.Arrays;
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import org.apache.iceberg.expressions.Binder;
@@ -227,6 +228,8 @@ class V4ManifestReader extends CloseableGroup implements 
CloseableIterable<Track
             .setCustomType(TrackedFile.TRACKING.fieldId(), 
TrackingStruct.class)
             .setCustomType(TrackedFile.DELETION_VECTOR.fieldId(), 
DeletionVectorStruct.class)
             .setCustomType(TrackedFile.MANIFEST_INFO.fieldId(), 
ManifestInfoStruct.class)
+            .setCustomType(
+                TrackedFile.COLUMN_FILES.type().asListType().elementId(), 
ColumnFileStruct.class)
             .reuseContainers();
 
     Types.NestedField partitionField = 
readSchema.findField(TrackedFile.PARTITION_ID);
@@ -283,6 +286,16 @@ class V4ManifestReader extends CloseableGroup implements 
CloseableIterable<Track
           .setLocation(LocationUtil.resolveLocation(tableLocation, 
deletionVector.location()));
     }
 
+    List<ColumnFile> columnFiles = copy.columnFiles();
+    if (columnFiles != null) {
+      for (ColumnFile columnFile : columnFiles) {
+        if (columnFile.location() != null) {
+          ((ColumnFileStruct) columnFile)
+              .setLocation(LocationUtil.resolveLocation(tableLocation, 
columnFile.location()));
+        }
+      }
+    }
+
     return copy;
   }
 
diff --git a/core/src/test/java/org/apache/iceberg/StatsTestUtil.java 
b/core/src/test/java/org/apache/iceberg/StatsTestUtil.java
index 2e0307fe9f..9f06e17c4f 100644
--- a/core/src/test/java/org/apache/iceberg/StatsTestUtil.java
+++ b/core/src/test/java/org/apache/iceberg/StatsTestUtil.java
@@ -40,6 +40,7 @@ class StatsTestUtil {
         null,
         null,
         null,
+        null,
         null);
   }
 
diff --git a/core/src/test/java/org/apache/iceberg/TestColumnFileStruct.java 
b/core/src/test/java/org/apache/iceberg/TestColumnFileStruct.java
new file mode 100644
index 0000000000..8e7b4ae06e
--- /dev/null
+++ b/core/src/test/java/org/apache/iceberg/TestColumnFileStruct.java
@@ -0,0 +1,132 @@
+/*
+ * 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 java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableList;
+import org.apache.iceberg.relocated.com.google.common.collect.ImmutableSet;
+import org.apache.iceberg.types.Comparators;
+import org.apache.iceberg.types.Types;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.MethodSource;
+
+class TestColumnFileStruct {
+
+  private static final String LOCATION = "s3://bucket/data/column.parquet";
+  private static final Set<Integer> FIELD_IDS = ImmutableSet.of(1, 2, 3);
+  private static final FileFormat FILE_FORMAT = FileFormat.PARQUET;
+  private static final long FILE_SIZE_IN_BYTES = 1024L;
+  private static final ByteBuffer KEY_METADATA = ByteBuffer.wrap(new byte[] 
{1, 2, 3});
+  private static final Comparator<StructLike> COLUMN_FILE_COMPARATOR =
+      Comparators.forType(ColumnFile.schema());
+
+  @Test
+  void fieldAccess() {
+    ColumnFile columnFile =
+        new ColumnFileStruct(LOCATION, FIELD_IDS, FILE_FORMAT, 
FILE_SIZE_IN_BYTES, KEY_METADATA);
+
+    assertThat(columnFile.location()).isEqualTo(LOCATION);
+    assertThat(columnFile.fieldIds()).isEqualTo(FIELD_IDS);
+    assertThat(columnFile.fileFormat()).isEqualTo(FILE_FORMAT);
+    assertThat(columnFile.fileSizeInBytes()).isEqualTo(FILE_SIZE_IN_BYTES);
+    assertThat(columnFile.keyMetadata()).isEqualTo(KEY_METADATA);
+  }
+
+  @Test
+  void copy() {
+    ColumnFile columnFile =
+        new ColumnFileStruct(LOCATION, FIELD_IDS, FILE_FORMAT, 
FILE_SIZE_IN_BYTES, KEY_METADATA);
+
+    ColumnFile copy = columnFile.copy();
+
+    assertThat((StructLike) 
copy).usingComparator(COLUMN_FILE_COMPARATOR).isEqualTo(columnFile);
+
+    // verify deep copy of key metadata
+    
assertThat(copy.keyMetadata().array()).isNotSameAs(columnFile.keyMetadata().array());
+  }
+
+  @Test
+  void setFieldsByOrdinals() {
+    ColumnFileStruct columnFile = new ColumnFileStruct();
+
+    columnFile.set(0, LOCATION);
+    columnFile.set(1, ImmutableList.copyOf(FIELD_IDS));
+    columnFile.set(2, FILE_FORMAT.toString());
+    columnFile.set(3, FILE_SIZE_IN_BYTES);
+    columnFile.set(4, KEY_METADATA);
+
+    assertThat(columnFile.location()).isEqualTo(LOCATION);
+    assertThat(columnFile.fieldIds()).isEqualTo(FIELD_IDS);
+    assertThat(columnFile.fileFormat()).isEqualTo(FILE_FORMAT);
+    assertThat(columnFile.fileSizeInBytes()).isEqualTo(FILE_SIZE_IN_BYTES);
+    assertThat(columnFile.keyMetadata()).isEqualTo(KEY_METADATA);
+  }
+
+  @Test
+  void getFieldsByOrdinals() {
+    ColumnFileStruct columnFile =
+        new ColumnFileStruct(LOCATION, FIELD_IDS, FILE_FORMAT, 
FILE_SIZE_IN_BYTES, KEY_METADATA);
+
+    assertThat(columnFile.get(0, String.class)).isEqualTo(LOCATION);
+    assertThat(columnFile.get(1, 
List.class)).containsExactlyElementsOf(FIELD_IDS);
+    assertThat(columnFile.get(2, 
String.class)).isEqualTo(FILE_FORMAT.toString());
+    assertThat(columnFile.get(3, Long.class)).isEqualTo(FILE_SIZE_IN_BYTES);
+    assertThat(columnFile.get(4, ByteBuffer.class)).isEqualTo(KEY_METADATA);
+  }
+
+  @Test
+  void projectedStructLike() {
+    Types.StructType projection =
+        Types.StructType.of(ColumnFile.LOCATION, 
ColumnFile.FILE_SIZE_IN_BYTES);
+
+    ColumnFileStruct columnFile = new ColumnFileStruct(projection);
+    assertThat(columnFile.size()).isEqualTo(2);
+
+    // projected position 0 maps to internal position of location
+    // projected position 1 maps to internal position of file_size_in_bytes
+    columnFile.set(0, LOCATION);
+    columnFile.set(1, FILE_SIZE_IN_BYTES);
+
+    assertThat(columnFile.location()).isEqualTo(LOCATION);
+    assertThat(columnFile.fileSizeInBytes()).isEqualTo(FILE_SIZE_IN_BYTES);
+    assertThat(columnFile.get(0, String.class)).isEqualTo(LOCATION);
+    assertThat(columnFile.get(1, Long.class)).isEqualTo(FILE_SIZE_IN_BYTES);
+  }
+
+  @ParameterizedTest
+  @MethodSource("org.apache.iceberg.TestHelpers#serializers")
+  void serializationRoundTrip(TestHelpers.RoundTripSerializer<ColumnFile> 
roundTripSerializer)
+      throws IOException, ClassNotFoundException {
+    ColumnFile columnFile =
+        new ColumnFileStruct(LOCATION, FIELD_IDS, FILE_FORMAT, 
FILE_SIZE_IN_BYTES, KEY_METADATA);
+
+    ColumnFile deserialized = roundTripSerializer.apply(columnFile);
+
+    assertThat((StructLike) deserialized)
+        .usingComparator(COLUMN_FILE_COMPARATOR)
+        .isEqualTo(columnFile);
+  }
+}
diff --git a/core/src/test/java/org/apache/iceberg/TestTrackedFile.java 
b/core/src/test/java/org/apache/iceberg/TestTrackedFile.java
index dbb589ee36..d4c4e467e8 100644
--- a/core/src/test/java/org/apache/iceberg/TestTrackedFile.java
+++ b/core/src/test/java/org/apache/iceberg/TestTrackedFile.java
@@ -59,7 +59,8 @@ public class TestTrackedFile {
             "deletion_vector",
             "manifest_info",
             "key_metadata",
-            "split_offsets");
+            "split_offsets",
+            "column_files");
   }
 
   @Test
@@ -69,7 +70,7 @@ public class TestTrackedFile {
 
     assertThat(fields)
         .extracting(Types.NestedField::fieldId)
-        .containsExactly(147, 134, 100, 101, 103, 104, 141, 102, 146, 140, 
148, 150, 131, 132);
+        .containsExactly(147, 134, 100, 101, 103, 104, 141, 102, 146, 140, 
148, 150, 131, 132, 158);
   }
 
   @Test
diff --git a/core/src/test/java/org/apache/iceberg/TestTrackedFileAdapters.java 
b/core/src/test/java/org/apache/iceberg/TestTrackedFileAdapters.java
index 1d72a884e7..9ccdd8753e 100644
--- a/core/src/test/java/org/apache/iceberg/TestTrackedFileAdapters.java
+++ b/core/src/test/java/org/apache/iceberg/TestTrackedFileAdapters.java
@@ -214,7 +214,8 @@ class TestTrackedFileAdapters {
             dv,
             null,
             ByteBuffer.wrap(new byte[] {1, 2, 3}),
-            ImmutableList.of(50L, 100L));
+            ImmutableList.of(50L, 100L),
+            null);
 
     DataFile dataFile = TrackedFileAdapters.asDataFile(file, 
specsById(PARTITIONED_SPEC));
 
@@ -301,6 +302,7 @@ class TestTrackedFileAdapters {
             dv,
             null,
             null,
+            null,
             null);
 
     DeleteFile dvFile = TrackedFileAdapters.asDVDeleteFile(file, 
specsById(PARTITIONED_SPEC));
@@ -431,7 +433,8 @@ class TestTrackedFileAdapters {
         dv, // deletionVector
         null, // manifestInfo
         KEY_METADATA,
-        ImmutableList.of(50L, 100L)); // splitOffsets
+        ImmutableList.of(50L, 100L), // splitOffsets
+        null); // columnFiles
   }
 
   @ParameterizedTest
@@ -473,7 +476,8 @@ class TestTrackedFileAdapters {
             null, // deletionVector
             MANIFEST_INFO,
             MANIFEST_KEY_METADATA,
-            null); // splitOffsets
+            null, // splitOffsets
+            null); // columnFiles
 
     ManifestFile manifest = TrackedFileAdapters.asManifestFile(file);
 
@@ -579,6 +583,7 @@ class TestTrackedFileAdapters {
             deletionVector(),
             null,
             null,
+            null,
             null);
     assertNullTrackingFields(TrackedFileAdapters.asDVDeleteFile(fileWithDV, 
UNPARTITIONED));
   }
@@ -631,6 +636,7 @@ class TestTrackedFileAdapters {
             null,
             null,
             null,
+            null,
             null);
 
     assertThatThrownBy(() -> TrackedFileAdapters.asDataFile(file, 
ImmutableMap.of()))
@@ -934,6 +940,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 91919ca248..f65ce4941e 100644
--- a/core/src/test/java/org/apache/iceberg/TestTrackedFileStruct.java
+++ b/core/src/test/java/org/apache/iceberg/TestTrackedFileStruct.java
@@ -57,11 +57,18 @@ class TestTrackedFileStruct {
   private static final ManifestInfo MANIFEST_INFO = 
Mockito.mock(ManifestInfo.class);
   private static final ManifestInfo MANIFEST_INFO_COPY = 
Mockito.mock(ManifestInfo.class);
 
+  private static final ColumnFile COLUMN_FILE_1 = 
Mockito.mock(ColumnFile.class);
+  private static final ColumnFile COLUMN_FILE_2 = 
Mockito.mock(ColumnFile.class);
+  private static final ColumnFile COLUMN_FILE_1_COPY = 
Mockito.mock(ColumnFile.class);
+  private static final ColumnFile COLUMN_FILE_2_COPY = 
Mockito.mock(ColumnFile.class);
+
   static {
     Mockito.when(TRACKING.copy()).thenReturn(TRACKING_COPY);
     Mockito.when(CONTENT_STATS.copy()).thenReturn(CONTENT_STATS_COPY);
     Mockito.when(DELETION_VECTOR.copy()).thenReturn(DELETION_VECTOR_COPY);
     Mockito.when(MANIFEST_INFO.copy()).thenReturn(MANIFEST_INFO_COPY);
+    Mockito.when(COLUMN_FILE_1.copy()).thenReturn(COLUMN_FILE_1_COPY);
+    Mockito.when(COLUMN_FILE_2.copy()).thenReturn(COLUMN_FILE_2_COPY);
   }
 
   @Test
@@ -81,7 +88,8 @@ class TestTrackedFileStruct {
             DELETION_VECTOR,
             MANIFEST_INFO,
             ByteBuffer.wrap(new byte[] {1, 2, 3}),
-            ImmutableList.of(100L, 200L));
+            ImmutableList.of(100L, 200L),
+            ImmutableList.of(COLUMN_FILE_1, COLUMN_FILE_2));
 
     assertThat(file.tracking()).isSameAs(TRACKING);
     assertThat(file.contentType()).isEqualTo(FileContent.DATA);
@@ -97,6 +105,7 @@ class TestTrackedFileStruct {
     assertThat(file.manifestInfo()).isSameAs(MANIFEST_INFO);
     assertThat(file.keyMetadata()).isEqualTo(ByteBuffer.wrap(new byte[] {1, 2, 
3}));
     assertThat(file.splitOffsets()).containsExactly(100L, 200L);
+    assertThat(file.columnFiles()).containsExactly(COLUMN_FILE_1, 
COLUMN_FILE_2);
   }
 
   @Test
@@ -150,7 +159,8 @@ class TestTrackedFileStruct {
             DELETION_VECTOR,
             MANIFEST_INFO,
             ByteBuffer.wrap(new byte[] {1, 2, 3}),
-            ImmutableList.of(100L, 200L));
+            ImmutableList.of(100L, 200L),
+            ImmutableList.of(COLUMN_FILE_1, COLUMN_FILE_2));
 
     assertThat(file.get(pos("tracking"), Tracking.class)).isSameAs(TRACKING);
     assertThat(file.get(pos("content_type"), 
Integer.class)).isEqualTo(FileContent.DATA.id());
@@ -187,7 +197,8 @@ class TestTrackedFileStruct {
             DELETION_VECTOR,
             MANIFEST_INFO,
             ByteBuffer.wrap(new byte[] {1, 2, 3}),
-            ImmutableList.of(100L, 200L));
+            ImmutableList.of(100L, 200L),
+            ImmutableList.of(COLUMN_FILE_1, COLUMN_FILE_2));
 
     TrackedFile copy = file.copy();
 
@@ -206,6 +217,7 @@ class TestTrackedFileStruct {
     assertThat(copy.keyMetadata()).isEqualTo(ByteBuffer.wrap(new byte[] {1, 2, 
3}));
     assertThat(copy.splitOffsets()).containsExactly(100L, 200L);
     assertThat(copy.partition()).isNotSameAs(PARTITION);
+    assertThat(copy.columnFiles()).containsExactly(COLUMN_FILE_1_COPY, 
COLUMN_FILE_2_COPY);
 
     // mutable fields are deep-copied, not shared with the original
     assertThat(copy.keyMetadata()).isNotSameAs(file.keyMetadata());
@@ -232,7 +244,8 @@ class TestTrackedFileStruct {
             DELETION_VECTOR,
             MANIFEST_INFO,
             ByteBuffer.wrap(new byte[] {1, 2, 3}),
-            ImmutableList.of(100L, 200L));
+            ImmutableList.of(100L, 200L),
+            ImmutableList.of(COLUMN_FILE_1, COLUMN_FILE_2));
 
     TrackedFile copy = file.copyWithStats(ImmutableSet.of(1));
 
@@ -251,6 +264,7 @@ class TestTrackedFileStruct {
     assertThat(copy.keyMetadata()).isEqualTo(ByteBuffer.wrap(new byte[] {1, 2, 
3}));
     assertThat(copy.splitOffsets()).containsExactly(100L, 200L);
     assertThat(copy.partition()).isNotSameAs(PARTITION);
+    assertThat(copy.columnFiles()).containsExactly(COLUMN_FILE_1_COPY, 
COLUMN_FILE_2_COPY);
 
     // mutable fields are deep-copied, not shared with the original
     assertThat(copy.keyMetadata()).isNotSameAs(file.keyMetadata());
@@ -275,7 +289,8 @@ class TestTrackedFileStruct {
             DELETION_VECTOR,
             MANIFEST_INFO,
             ByteBuffer.wrap(new byte[] {1, 2, 3}),
-            ImmutableList.of(100L, 200L));
+            ImmutableList.of(100L, 200L),
+            ImmutableList.of(COLUMN_FILE_1, COLUMN_FILE_2));
 
     TrackedFile copy = file.copyWithoutStats();
 
@@ -297,9 +312,11 @@ class TestTrackedFileStruct {
     assertThat(copy.keyMetadata()).isEqualTo(ByteBuffer.wrap(new byte[] {1, 2, 
3}));
     assertThat(copy.splitOffsets()).containsExactly(100L, 200L);
     assertThat(copy.partition()).isNotSameAs(PARTITION);
+    assertThat(copy.columnFiles()).containsExactly(COLUMN_FILE_1_COPY, 
COLUMN_FILE_2_COPY);
 
     // mutable fields are deep-copied, not shared with the original
     assertThat(copy.keyMetadata()).isNotSameAs(file.keyMetadata());
+    assertThat(copy.columnFiles()).isNotSameAs(file.columnFiles());
   }
 
   @Test
@@ -421,7 +438,8 @@ class TestTrackedFileStruct {
             null, // DeletionVector has its own serialization tests
             null, // ManifestInfo has its own serialization tests
             ByteBuffer.wrap(new byte[] {1, 2, 3}),
-            ImmutableList.of(50L));
+            ImmutableList.of(50L),
+            null); // ColumnFile has its own serialization tests
 
     TrackedFileStruct deserialized = serializer.apply(file);
 
@@ -440,6 +458,7 @@ class TestTrackedFileStruct {
     assertThat(deserialized.manifestInfo()).isNull();
     assertThat(deserialized.keyMetadata()).isEqualTo(ByteBuffer.wrap(new 
byte[] {1, 2, 3}));
     assertThat(deserialized.splitOffsets()).containsExactly(50L);
+    assertThat(deserialized.columnFiles()).isNull();
   }
 
   @ParameterizedTest
@@ -494,7 +513,8 @@ class TestTrackedFileStruct {
         null, // deletionVector
         null, // manifestInfo
         null, // keyMetadata
-        null); // splitOffsets
+        null, // splitOffsets
+        null); // column files
   }
 
   private static int pos(String fieldName) {
diff --git a/core/src/test/java/org/apache/iceberg/TestTrackingBuilder.java 
b/core/src/test/java/org/apache/iceberg/TestTrackingBuilder.java
index 6fac40ecc9..ae1a13570f 100644
--- a/core/src/test/java/org/apache/iceberg/TestTrackingBuilder.java
+++ b/core/src/test/java/org/apache/iceberg/TestTrackingBuilder.java
@@ -122,6 +122,18 @@ class TestTrackingBuilder {
     assertThat(modified.modifiedSnapshotId()).isEqualTo(999L);
   }
 
+  @Test
+  void columnFilesUpdatedProducesModifiedAndResetsDataSequenceNumber() {
+    Tracking modified =
+        TrackingBuilder.from(SOURCE_TRACKING_ADDED, 
999L).columnFilesUpdated().build();
+
+    assertThat(modified.status()).isEqualTo(EntryStatus.MODIFIED);
+    assertThat(modified.modifiedSnapshotId()).isEqualTo(999L);
+    // data sequence number is reset to inherit the new snapshot's sequence 
number
+    assertThat(modified.dataSequenceNumber()).isNull();
+    
assertThat(modified.fileSequenceNumber()).isEqualTo(SOURCE_TRACKING_ADDED.fileSequenceNumber());
+  }
+
   @Test
   void manifestDVMutatorsRejectedOnAdded() {
     assertThatThrownBy(
@@ -280,4 +292,31 @@ class TestTrackingBuilder {
     assertThat(modified.modifiedSnapshotId()).isEqualTo(999L);
     assertThat(modified.deletedPositions()).isEqualTo(deletedBytes);
   }
+
+  @Test
+  void manifestPositionsWithColumnFilesUpdated() {
+    ByteBuffer deletedBytes = ByteBuffer.wrap(new byte[] {1});
+    Tracking withDeletedPositions =
+        TrackingBuilder.from(SOURCE_TRACKING_ADDED, 999L)
+            .columnFilesUpdated()
+            .deletedPositions(deletedBytes)
+            .build();
+
+    assertThat(withDeletedPositions.status()).isEqualTo(EntryStatus.MODIFIED);
+    assertThat(withDeletedPositions.modifiedSnapshotId()).isEqualTo(999L);
+    
assertThat(withDeletedPositions.deletedPositions()).isEqualTo(deletedBytes);
+    assertThat(withDeletedPositions.dataSequenceNumber()).isNull();
+
+    ByteBuffer replacedBytes = ByteBuffer.wrap(new byte[] {2});
+    Tracking withReplacedPositions =
+        TrackingBuilder.from(SOURCE_TRACKING_ADDED, 999L)
+            .columnFilesUpdated()
+            .replacedPositions(replacedBytes)
+            .build();
+
+    assertThat(withReplacedPositions.status()).isEqualTo(EntryStatus.MODIFIED);
+    assertThat(withReplacedPositions.modifiedSnapshotId()).isEqualTo(999L);
+    
assertThat(withReplacedPositions.replacedPositions()).isEqualTo(replacedBytes);
+    assertThat(withReplacedPositions.dataSequenceNumber()).isNull();
+  }
 }
diff --git a/core/src/test/java/org/apache/iceberg/TestTrackingStruct.java 
b/core/src/test/java/org/apache/iceberg/TestTrackingStruct.java
index 3975706e5c..eea6e4e0a7 100644
--- a/core/src/test/java/org/apache/iceberg/TestTrackingStruct.java
+++ b/core/src/test/java/org/apache/iceberg/TestTrackingStruct.java
@@ -189,9 +189,19 @@ class TestTrackingStruct {
     assertThat(tracking.fileSequenceNumber()).isEqualTo(60L);
   }
 
+  @Test
+  void inheritanceModifiedEntriesInheritDataSequenceNumber() {
+    TrackingStruct tracking =
+        new TrackingStruct(EntryStatus.MODIFIED, 42L, null, null, null, null, 
null, null);
+
+    tracking.inherit(100L, 60L);
+
+    assertThat(tracking.dataSequenceNumber()).isEqualTo(60L);
+    assertThat(tracking.fileSequenceNumber()).isNull();
+  }
+
   private static final List<EntryStatus> NON_INHERITING_STATUSES =
-      List.of(
-          EntryStatus.EXISTING, EntryStatus.MODIFIED, EntryStatus.DELETED, 
EntryStatus.REPLACED);
+      List.of(EntryStatus.EXISTING, EntryStatus.DELETED, EntryStatus.REPLACED);
 
   @ParameterizedTest
   @FieldSource("NON_INHERITING_STATUSES")
diff --git a/core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java 
b/core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java
index 36f9712a81..864a0e1eec 100644
--- a/core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java
+++ b/core/src/test/java/org/apache/iceberg/TestV4ManifestReader.java
@@ -23,17 +23,21 @@ 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.columnFile;
 import static org.apache.iceberg.V4TestHelpers.dataFile;
+import static org.apache.iceberg.V4TestHelpers.dataFileWithColumnFiles;
 import static org.apache.iceberg.V4TestHelpers.dataFileWithDV;
 import static org.apache.iceberg.V4TestHelpers.dataFileWithStats;
 import static org.apache.iceberg.V4TestHelpers.deletionVector;
 import static org.apache.iceberg.V4TestHelpers.manifestRef;
+import static org.apache.iceberg.V4TestHelpers.manifestRefWithColumnFiles;
 import static org.apache.iceberg.V4TestHelpers.manifestRefWithStats;
 import static org.apache.iceberg.V4TestHelpers.partition;
 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 static org.mockito.Mockito.mock;
 import static org.mockito.Mockito.when;
 
@@ -155,6 +159,19 @@ class TestV4ManifestReader {
 
   private static final InMemoryFileIO IO = new InMemoryFileIO();
 
+  private static final ColumnFile COLUMN_FILE_1 =
+      columnFile(
+          "s3a://bucket/column_file_1.parquet",
+          Set.of(1, 3),
+          ByteBuffer.wrap(new byte[] {1, 2, 3}));
+  private static final ColumnFile COLUMN_FILE_2 =
+      columnFile(
+          "s3a://bucket/column_file_2.parquet",
+          Set.of(2),
+          ByteBuffer.wrap(new byte[] {1, 2, 3, 4}));
+  private static final List<ColumnFile> COLUMN_FILES =
+      ImmutableList.of(COLUMN_FILE_1, COLUMN_FILE_2);
+
   @ParameterizedTest
   @FieldSource("MANIFEST_FORMATS")
   public void readDataFile(FileFormat format) throws IOException {
@@ -173,7 +190,8 @@ class TestV4ManifestReader {
             DV,
             null, // manifest info
             ByteBuffer.wrap(new byte[] {1, 2, 3}), // key metadata
-            ImmutableList.of(50L, 100L));
+            ImmutableList.of(50L, 100L),
+            COLUMN_FILES);
 
     ManifestFile manifest = writeManifest(format, ID_PARTITIONED_TYPE, file);
 
@@ -203,7 +221,8 @@ class TestV4ManifestReader {
             DV,
             null, // manifest info
             ByteBuffer.wrap(new byte[] {1, 2, 3}), // key metadata
-            ImmutableList.of(50L, 100L));
+            ImmutableList.of(50L, 100L),
+            COLUMN_FILES);
 
     ManifestFile manifest = writeManifest(format, ID_PARTITIONED_TYPE, file);
 
@@ -235,7 +254,8 @@ class TestV4ManifestReader {
             DV,
             null, // manifest info
             ByteBuffer.wrap(new byte[] {1, 2, 3}), // key metadata
-            ImmutableList.of(50L, 100L));
+            ImmutableList.of(50L, 100L),
+            COLUMN_FILES);
 
     ManifestFile manifest = writeManifest(format, ID_PARTITIONED_TYPE, file);
 
@@ -269,7 +289,8 @@ class TestV4ManifestReader {
             null, // dv
             MANIFEST_INFO,
             ByteBuffer.wrap(new byte[] {1, 2, 3}), // key metadata
-            null); // split offsets
+            null, // split offsets
+            COLUMN_FILES);
 
     ManifestFile manifest = writeManifest(format, ID_PARTITIONED_TYPE, 
manifestRef);
 
@@ -567,6 +588,42 @@ class TestV4ManifestReader {
         .isEqualTo(expected);
   }
 
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void inheritanceModifiedDataSequenceNumber(FileFormat format) throws 
IOException {
+    TrackedFile withoutDataSeq =
+        dataFileWithColumnFiles(
+            new TrackingStruct(
+                EntryStatus.MODIFIED, SNAPSHOT_ID, null, 3L, 34L, 5_000L, 
null, null),
+            "s3://bucket/table/file-a.parquet",
+            COLUMN_FILES);
+
+    ManifestFile manifest =
+        writeManifest(format, UNPARTITIONED_TYPE, 
ImmutableList.of(withoutDataSeq));
+
+    when(manifest.firstRowId()).thenReturn(10_000L);
+    when(manifest.snapshotId()).thenReturn(34L);
+    when(manifest.sequenceNumber()).thenReturn(5L);
+
+    V4ManifestReader.Builder builder =
+        V4ManifestReader.builder(manifest, IO, TABLE_SCHEMA, 
ID_PARTITIONING_SPECS)
+            .metricsConfig(METRICS_CONFIG);
+    List<TrackedFile> actual = read(builder);
+
+    // inherits data sequence number
+    List<TrackedFile> expected =
+        List.of(
+            dataFileWithColumnFiles(
+                new TrackingStruct(
+                    EntryStatus.MODIFIED, SNAPSHOT_ID, 5L, 3L, 34L, 5_000L, 
null, null),
+                "s3://bucket/table/file-a.parquet",
+                COLUMN_FILES));
+
+    assertThat(actual)
+        .usingComparatorForType(FILE_AND_TRACKING_COMPARATOR, 
TrackedFile.class)
+        .isEqualTo(expected);
+  }
+
   @ParameterizedTest
   @FieldSource("MANIFEST_FORMATS")
   public void inheritanceFirstRowId(FileFormat format) throws IOException {
@@ -1128,7 +1185,8 @@ class TestV4ManifestReader {
             null,
             null,
             null,
-            List.of(4L));
+            List.of(4L),
+            null); // column files
 
     ManifestFile manifest =
         writeManifest(format, UNPARTITIONED_TYPE, 
ImmutableList.of(emptyTrackedFile, FILE_D));
@@ -1163,7 +1221,8 @@ class TestV4ManifestReader {
             null,
             null,
             null,
-            List.of(4L));
+            List.of(4L),
+            null); // column files
 
     ManifestFile manifest =
         writeManifest(format, UNPARTITIONED_TYPE, 
ImmutableList.of(invalidRecordCountFile, FILE_D));
@@ -1261,6 +1320,33 @@ class TestV4ManifestReader {
     
assertThat(actualFiles).extracting(TrackedFile::location).containsExactly(FILE_D.location());
   }
 
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void statsFilterWithColumnFilesProjection(FileFormat format) throws 
IOException {
+    // FILE_C has stats {id in [0, 99], data in [a, z]}, the file with column 
files has no stats
+    TrackedFile fileWithColumnFiles =
+        dataFileWithColumnFiles("s3://bucket/table/file-e.parquet", 
COLUMN_FILES);
+    ManifestFile manifest =
+        writeManifest(format, UNPARTITIONED_TYPE, ImmutableList.of(FILE_C, 
fileWithColumnFiles));
+
+    V4ManifestReader.Builder builder =
+        V4ManifestReader.builder(manifest, IO, TABLE_SCHEMA, 
UNPARTITIONED_SPECS)
+            .filter(Expressions.equal("id", 105)) // eliminates FILE_C
+            .metricsConfig(METRICS_CONFIG)
+            .select("location", "column_files.field_ids");
+
+    TrackedFile actual = readOne(builder);
+
+    assertThat(actual.location()).isEqualTo(fileWithColumnFiles.location());
+    assertThat(actual.columnFiles())
+        .extracting(ColumnFile::fieldIds)
+        .containsExactly(COLUMN_FILE_1.fieldIds(), COLUMN_FILE_2.fieldIds());
+    assertThat(actual.columnFiles())
+        .as("Unselected column file fields should not be projected")
+        .extracting(ColumnFile::location, ColumnFile::fileFormat, 
ColumnFile::keyMetadata)
+        .containsOnly(tuple(null, null, null));
+  }
+
   @ParameterizedTest
   @FieldSource("MANIFEST_FORMATS")
   public void statsFilterCaseSensitivity(FileFormat format) throws IOException 
{
@@ -1346,7 +1432,8 @@ class TestV4ManifestReader {
             null, // dv
             null, // manifest info
             null, // key metadata
-            List.of(4L));
+            List.of(4L),
+            null); // column files
 
     ManifestFile manifest =
         writeManifest(
@@ -1743,6 +1830,44 @@ class TestV4ManifestReader {
                 "s3://other/abs.parquet", 
"s3://bucket/db/table/data/dv.puffin"));
   }
 
+  @ParameterizedTest
+  @FieldSource("MANIFEST_FORMATS")
+  public void resolutionRelativeColumnFileLocation(FileFormat format) throws 
IOException {
+    ManifestFile manifest =
+        writeManifest(
+            format,
+            UNPARTITIONED_TYPE,
+            ImmutableList.of(
+                dataFileWithColumnFiles(
+                    "s3://other/abs.parquet",
+                    List.of(columnFile("data/column.parquet", Set.of(1)), 
COLUMN_FILE_2)),
+                manifestRefWithColumnFiles(
+                    FileContent.DATA_MANIFEST,
+                    "s3://other/leaf.avro",
+                    MANIFEST_INFO,
+                    List.of(columnFile("metadata/leaf-column.parquet", 
Set.of(1))))));
+
+    V4ManifestReader.Builder builder =
+        V4ManifestReader.builder(manifest, IO, TABLE_SCHEMA, 
UNPARTITIONED_SPECS)
+            .tableLocation("s3://bucket/db/table")
+            .metricsConfig(METRICS_CONFIG);
+
+    assertThat(read(builder))
+        .usingComparatorForType(FILE_COMPARATOR, TrackedFile.class)
+        .containsExactly(
+            dataFileWithColumnFiles(
+                "s3://other/abs.parquet",
+                List.of(
+                    columnFile("s3://bucket/db/table/data/column.parquet", 
Set.of(1)),
+                    COLUMN_FILE_2)),
+            manifestRefWithColumnFiles(
+                FileContent.DATA_MANIFEST,
+                "s3://other/leaf.avro",
+                MANIFEST_INFO,
+                List.of(
+                    
columnFile("s3://bucket/db/table/metadata/leaf-column.parquet", Set.of(1)))));
+  }
+
   @ParameterizedTest
   @FieldSource("MANIFEST_FORMATS")
   public void resolutionResolvesOnlyRelativePaths(FileFormat format) throws 
IOException {
@@ -1892,7 +2017,7 @@ class TestV4ManifestReader {
             SNAPSHOT_ID,
             3L, // data sequence number
             3L, // file sequence number
-            null, // dv snapshot id
+            null, // modified snapshot id
             null, // first row id
             null, // deleted positions
             null); // replaced positions
diff --git a/core/src/test/java/org/apache/iceberg/V4TestComparators.java 
b/core/src/test/java/org/apache/iceberg/V4TestComparators.java
index 85ac12138c..5c999fcd7d 100644
--- a/core/src/test/java/org/apache/iceberg/V4TestComparators.java
+++ b/core/src/test/java/org/apache/iceberg/V4TestComparators.java
@@ -24,6 +24,7 @@ import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import org.apache.iceberg.relocated.com.google.common.collect.Iterables;
+import org.apache.iceberg.relocated.com.google.common.collect.Lists;
 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.Comparators;
@@ -66,7 +67,8 @@ class V4TestComparators {
             .thenComparing(TrackedFile::deletionVector, DELETION_VECTOR)
             .thenComparing(TrackedFile::manifestInfo, MANIFEST_INFO)
             .thenComparing(TrackedFile::keyMetadata, BYTES)
-            .thenComparing(TrackedFile::splitOffsets, SPLIT_OFFSETS));
+            .thenComparing(TrackedFile::splitOffsets, SPLIT_OFFSETS)
+            .thenComparing(TrackedFile::columnFiles, COLUMN_FILES));
   }
 
   // convenience method for a null-safe natural order comparator
@@ -81,6 +83,22 @@ class V4TestComparators {
   private static final Comparator<List<Long>> SPLIT_OFFSETS =
       
Comparator.nullsFirst(Comparators.forType(TrackedFile.SPLIT_OFFSETS.type().asListType()));
 
+  // field IDs are a set, so compare them in sorted order to ignore iteration 
order
+  private static final Comparator<Set<Integer>> FIELD_IDS =
+      Comparator.nullsFirst(
+          Comparator.comparing(
+              ids -> Lists.newArrayList(Sets.newTreeSet(ids)),
+              Comparators.forType(ColumnFile.FIELD_IDS.type().asListType())));
+  private static final Comparator<ColumnFile> COLUMN_FILE =
+      Comparator.nullsFirst(
+          Comparator.comparing(ColumnFile::location, natural())
+              .thenComparing(ColumnFile::fieldIds, FIELD_IDS)
+              .thenComparing(ColumnFile::fileFormat, natural())
+              .thenComparingLong(ColumnFile::fileSizeInBytes)
+              .thenComparing(ColumnFile::keyMetadata, BYTES));
+  private static final Comparator<List<ColumnFile>> COLUMN_FILES =
+      Comparator.nullsFirst(columnFilesComparator());
+
   static final Comparator<Tracking> TRACKING =
       Comparator.nullsFirst(
           Comparator.comparing(Tracking::status, natural())
@@ -145,6 +163,24 @@ class V4TestComparators {
     }
   }
 
+  private static Comparator<List<ColumnFile>> columnFilesComparator() {
+    return (left, right) -> {
+      int sizeCmp = Integer.compare(left.size(), right.size());
+      if (sizeCmp != 0) {
+        return sizeCmp;
+      }
+
+      for (int i = 0; i < left.size(); i += 1) {
+        int cmp = COLUMN_FILE.compare(left.get(i), right.get(i));
+        if (cmp != 0) {
+          return cmp;
+        }
+      }
+
+      return 0;
+    };
+  }
+
   private static Map<Integer, FieldStats<?>> statsById(ContentStats stats) {
     Map<Integer, FieldStats<?>> statsById = Maps.newHashMap();
     for (FieldStats<?> fieldStats : stats.fieldStats()) {
diff --git a/core/src/test/java/org/apache/iceberg/V4TestHelpers.java 
b/core/src/test/java/org/apache/iceberg/V4TestHelpers.java
index b8cf5164d4..9829f3e679 100644
--- a/core/src/test/java/org/apache/iceberg/V4TestHelpers.java
+++ b/core/src/test/java/org/apache/iceberg/V4TestHelpers.java
@@ -19,6 +19,9 @@
 package org.apache.iceberg;
 
 import java.io.IOException;
+import java.nio.ByteBuffer;
+import java.util.List;
+import java.util.Set;
 import org.apache.iceberg.io.FileAppender;
 import org.apache.iceberg.io.FileIO;
 import org.apache.iceberg.io.OutputFile;
@@ -35,11 +38,11 @@ class V4TestHelpers {
   static final Tracking ADDED_TRACKING = 
TrackingBuilder.added(SNAPSHOT_ID).build();
 
   static TrackedFile dataFile(Tracking tracking, String location) {
-    return dataFile(tracking, location, null, null, null, null);
+    return dataFile(tracking, location, null, null, null, null, null);
   }
 
   static TrackedFile dataFile(String location, Integer specId, PartitionData 
partition) {
-    return dataFile(ADDED_TRACKING, location, specId, partition, null, null);
+    return dataFile(ADDED_TRACKING, location, specId, partition, null, null, 
null);
   }
 
   static TrackedFile dataFile(
@@ -48,15 +51,24 @@ class V4TestHelpers {
       PartitionData partition,
       ContentStats stats,
       DeletionVector dv) {
-    return dataFile(ADDED_TRACKING, location, specId, partition, stats, dv);
+    return dataFile(ADDED_TRACKING, location, specId, partition, stats, dv, 
null);
   }
 
   static TrackedFile dataFileWithStats(String location, ContentStats stats) {
-    return dataFile(ADDED_TRACKING, location, null, null, stats, null);
+    return dataFile(ADDED_TRACKING, location, null, null, stats, null, null);
   }
 
   static TrackedFile dataFileWithDV(String location, DeletionVector dv) {
-    return dataFile(ADDED_TRACKING, location, null, null, null, dv);
+    return dataFile(ADDED_TRACKING, location, null, null, null, dv, null);
+  }
+
+  static TrackedFile dataFileWithColumnFiles(String location, List<ColumnFile> 
columnFiles) {
+    return dataFile(ADDED_TRACKING, location, null, null, null, null, 
columnFiles);
+  }
+
+  static TrackedFile dataFileWithColumnFiles(
+      Tracking tracking, String location, List<ColumnFile> columnFiles) {
+    return dataFile(tracking, location, null, null, null, null, columnFiles);
   }
 
   private static TrackedFile dataFile(
@@ -65,7 +77,8 @@ class V4TestHelpers {
       Integer specId,
       PartitionData partition,
       ContentStats stats,
-      DeletionVector dv) {
+      DeletionVector dv,
+      List<ColumnFile> columnFiles) {
     return trackedFile(
         tracking,
         FileContent.DATA,
@@ -74,7 +87,8 @@ class V4TestHelpers {
         partition,
         stats,
         dv,
-        null /* manifestInfo */);
+        null /* manifestInfo */,
+        columnFiles);
   }
 
   static TrackedFile manifestRef(FileContent content, String location, 
ManifestInfo manifestInfo) {
@@ -91,7 +105,25 @@ class V4TestHelpers {
         null, // partition
         stats,
         null, // dv
-        manifestInfo);
+        manifestInfo,
+        null); // columnFiles
+  }
+
+  static TrackedFile manifestRefWithColumnFiles(
+      FileContent content,
+      String location,
+      ManifestInfo manifestInfo,
+      List<ColumnFile> columnFiles) {
+    return trackedFile(
+        ADDED_TRACKING,
+        content,
+        location,
+        null, // specId
+        null, // partition
+        null, // stats
+        null, // dv
+        manifestInfo,
+        columnFiles);
   }
 
   private static TrackedFile trackedFile(
@@ -102,7 +134,8 @@ class V4TestHelpers {
       PartitionData partition,
       ContentStats stats,
       DeletionVector dv,
-      ManifestInfo manifestInfo) {
+      ManifestInfo manifestInfo,
+      List<ColumnFile> columnFiles) {
     return new TrackedFileStruct(
         tracking,
         content,
@@ -117,7 +150,8 @@ class V4TestHelpers {
         dv,
         manifestInfo,
         null, // keyMetadata
-        null); // splitOffsets
+        null, // splitOffsets
+        columnFiles);
   }
 
   static DeletionVector deletionVector(String location) {
@@ -134,6 +168,15 @@ class V4TestHelpers {
         .build();
   }
 
+  static ColumnFile columnFile(String location, Set<Integer> fieldIds) {
+    return columnFile(location, fieldIds, null);
+  }
+
+  static ColumnFile columnFile(String location, Set<Integer> fieldIds, 
ByteBuffer keyMetadata) {
+    return new ColumnFileStruct(
+        location, fieldIds, FileFormat.fromFileName(location), 
FILE_SIZE_IN_BYTES, keyMetadata);
+  }
+
   static PartitionData partition(PartitionSpec spec, Object... values) {
     PartitionData data = new PartitionData(spec.partitionType());
     for (int i = 0; i < values.length; i += 1) {

Reply via email to